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.
This commit is contained in:
1 parent
6e59df22e5
commit
4ddffbbeb1
68 files changed
+4348
-1215
No files matched your search
+154
-8
@@ -11,7 +11,7 @@ from urllib.parse import quote
|
||||
|
||||
import markdown
|
||||
import psycopg
|
||||
from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for
|
||||
from flask import Flask, Response, abort, g, jsonify, redirect, render_template, request, send_file, session, stream_with_context, url_for
|
||||
from markupsafe import Markup
|
||||
from psycopg.rows import dict_row
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@@ -44,6 +44,7 @@ UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
||||
TOKEN_QUERY_PATHS = (
|
||||
"/api/v1/kamera",
|
||||
"/api/v1/kamera/stream",
|
||||
"/api/v1/vorschau",
|
||||
)
|
||||
STEER_ACTIONS = {
|
||||
@@ -243,10 +244,10 @@ def create_app(cfg):
|
||||
if path == "/api/v1/einstellungen" and request.method == "GET":
|
||||
need_admin = False
|
||||
elif path == "/api/v1/einstellungen" and request.method == "PATCH":
|
||||
# Operator darf nur auto_queue schalten
|
||||
# Operator darf nur Automatik-Schalter setzen
|
||||
payload = request.get_json(silent=True) or {}
|
||||
keys = {k for k, v in payload.items() if v is not None}
|
||||
need_admin = keys - {"auto_queue"} != set()
|
||||
need_admin = keys - {"auto_queue", "auto_retry"} != set()
|
||||
elif path.startswith("/api/v1/users"):
|
||||
need_admin = True
|
||||
elif request.method != "GET":
|
||||
@@ -340,6 +341,24 @@ def create_app(cfg):
|
||||
require_host(host)
|
||||
return host
|
||||
|
||||
_icons_cache = {"html": "", "mtime": None}
|
||||
|
||||
def icons_sprite_html():
|
||||
path = ROOT / "static" / "icons.svg"
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return ""
|
||||
if _icons_cache["html"] and _icons_cache["mtime"] == mtime:
|
||||
return _icons_cache["html"]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = re.sub(r"<\?xml[^?]*\?>\s*", "", text)
|
||||
text = re.sub(r"<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL)
|
||||
text = text.replace("<svg ", '<svg width="0" height="0" style="position:absolute" aria-hidden="true" ', 1)
|
||||
_icons_cache["html"] = text
|
||||
_icons_cache["mtime"] = mtime
|
||||
return text
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
user = auth_user()
|
||||
@@ -347,6 +366,7 @@ def create_app(cfg):
|
||||
"version": __version__,
|
||||
"csrf": session.get("csrf") or "",
|
||||
"current_user": user,
|
||||
"icons_sprite": Markup(icons_sprite_html()),
|
||||
}
|
||||
|
||||
def page(name, title):
|
||||
@@ -732,6 +752,10 @@ def create_app(cfg):
|
||||
def ideen():
|
||||
return redirect("/auftraege", code=301)
|
||||
|
||||
@app.get("/jetzt")
|
||||
def jetzt_page():
|
||||
return page("jetzt.html", "Jetzt")
|
||||
|
||||
@app.get("/aktivitaet")
|
||||
def aktivitaet():
|
||||
return page("aktivitaet.html", "Aktivität")
|
||||
@@ -791,6 +815,7 @@ def create_app(cfg):
|
||||
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
|
||||
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
|
||||
auto_queue = data.get("auto_queue") if "auto_queue" in data else None
|
||||
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
|
||||
fqdn = data.get("fqdn") if "fqdn" in data else None
|
||||
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
|
||||
return jsonify(grotti_config.save_settings(
|
||||
@@ -800,6 +825,7 @@ def create_app(cfg):
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
))
|
||||
@@ -956,6 +982,40 @@ def create_app(cfg):
|
||||
abort(404)
|
||||
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
|
||||
|
||||
@app.get("/api/v1/kamera/stream")
|
||||
def camera_stream():
|
||||
"""MJPEG vom Drucker durchreichen (kein Frame-Cache)."""
|
||||
host = require_host(request.args.get("host", ""))
|
||||
try:
|
||||
upstream = moonraker.open_camera_stream(host)
|
||||
except Exception:
|
||||
abort(404)
|
||||
ctype = upstream.headers.get("Content-Type") or "multipart/x-mixed-replace; boundary=--"
|
||||
|
||||
@stream_with_context
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
chunk = upstream.read(16384)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
upstream.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": ctype,
|
||||
"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/api/v1/vorschau")
|
||||
def live_preview():
|
||||
host = require_host(request.args.get("host", ""))
|
||||
@@ -1371,6 +1431,62 @@ def create_app(cfg):
|
||||
})
|
||||
return jsonify(items)
|
||||
|
||||
@app.get("/api/v1/jetzt")
|
||||
def jetzt_api():
|
||||
"""Priorisierte Handlungsliste, laufende Drucke und Farm-ETA."""
|
||||
from grotti import jetzt as jetzt_mod
|
||||
|
||||
send_jobs = []
|
||||
try:
|
||||
send_jobs = app.extensions["send_queue"].list_jobs() or []
|
||||
except Exception:
|
||||
send_jobs = []
|
||||
return jsonify(jetzt_mod.build(
|
||||
conn(),
|
||||
app.extensions["watcher"].latest or [],
|
||||
printers=store.list_printers(conn()),
|
||||
send_jobs=send_jobs,
|
||||
))
|
||||
|
||||
@app.get("/api/v1/prognose")
|
||||
def prognose_api():
|
||||
"""Wann welcher Drucker frei ist und wann alles fertig ist."""
|
||||
from grotti import jetzt as jetzt_mod
|
||||
|
||||
running = []
|
||||
for live in app.extensions["watcher"].latest or []:
|
||||
state = (live.get("state") or "").lower()
|
||||
if state not in ("printing", "paused", "heating"):
|
||||
continue
|
||||
running.append({
|
||||
"host": live.get("host") or "",
|
||||
"name": live.get("name") or "",
|
||||
"remain": live.get("remain") or 0,
|
||||
"progress": live.get("progress") or 0,
|
||||
"filename": live.get("filename") or "",
|
||||
"state": state,
|
||||
"free_at": None,
|
||||
})
|
||||
eta = jetzt_mod.farm_eta(running)
|
||||
for item in running:
|
||||
rem = float(item.get("remain") or 0)
|
||||
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
|
||||
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
|
||||
return jsonify({
|
||||
"running": running,
|
||||
"waiting_count": len(waiting),
|
||||
"eta": eta,
|
||||
})
|
||||
|
||||
@app.get("/api/v1/suche")
|
||||
def suche_api():
|
||||
"""Globale Suche für Ctrl+K."""
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if len(q) < 1:
|
||||
return jsonify({"items": []})
|
||||
items = store.global_search(conn(), q, limit=20)
|
||||
return jsonify({"items": items})
|
||||
|
||||
@app.post("/api/v1/druck-queue")
|
||||
def print_queue_create():
|
||||
data = body()
|
||||
@@ -1445,8 +1561,8 @@ def create_app(cfg):
|
||||
c = conn()
|
||||
store.set_bed_needs_clear(c, host, False, "quittiert")
|
||||
run_json = None
|
||||
run_row = store.run_for_abraeumen_verdict(c, host)
|
||||
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
|
||||
run_row = store.run_for_abraeumen_verdict(c, host)
|
||||
if not run_row:
|
||||
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
|
||||
try:
|
||||
@@ -1459,12 +1575,31 @@ def create_app(cfg):
|
||||
except ValueError as exc:
|
||||
raise GrottiError(str(exc), "urteil") from exc
|
||||
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
|
||||
reprint = None
|
||||
if bool(data.get("reprint")) and run_row and run_row.get("file_id"):
|
||||
reprint = store.enqueue_print(
|
||||
c,
|
||||
run_row["file_id"],
|
||||
host,
|
||||
"",
|
||||
50,
|
||||
run_row.get("spool_id"),
|
||||
[],
|
||||
False,
|
||||
False,
|
||||
)
|
||||
store.add_event(
|
||||
c, host, "", "queue",
|
||||
"Nochmal eingereiht: %s" % (run_row.get("filename") or run_row["file_id"]),
|
||||
run_row.get("filename") or "",
|
||||
)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"host": host,
|
||||
"needs_clear": False,
|
||||
"message": "Platte quittiert.",
|
||||
"message": "Platte quittiert." + (" Nochmal in der Warteschlange." if reprint else ""),
|
||||
"run": run_json,
|
||||
"reprint": reprint,
|
||||
"verdict_reasons": VERDICT_REASONS,
|
||||
})
|
||||
|
||||
@@ -1499,7 +1634,12 @@ def create_app(cfg):
|
||||
if status == "running" and not data.get("resume"):
|
||||
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
|
||||
if status == "waiting" and current_row.get("status") not in ("waiting",):
|
||||
raise GrottiError("Status lässt sich so nicht setzen.", "status")
|
||||
# Undo: abgebrochen/entfernt wieder einreihen
|
||||
if not (data.get("undo") and current_row.get("status") in ("cancelled", "dismissed")):
|
||||
raise GrottiError("Status lässt sich so nicht setzen.", "status")
|
||||
fields["message"] = ""
|
||||
fields["phase"] = ""
|
||||
fields["phase_started_at"] = None
|
||||
fields["status"] = status
|
||||
if status == "running" and data.get("resume"):
|
||||
row = current_row
|
||||
@@ -1557,11 +1697,14 @@ def create_app(cfg):
|
||||
@app.get("/api/v1/ereignisse")
|
||||
def events_list():
|
||||
unread = request.args.get("unread") == "1"
|
||||
alerts_only = request.args.get("alerts") == "1"
|
||||
try:
|
||||
limit = max(1, min(1000, int(request.args.get("limit") or 40)))
|
||||
except (TypeError, ValueError):
|
||||
limit = 40
|
||||
kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()]
|
||||
if alerts_only and not kinds:
|
||||
kinds = list(store.ALERT_EVENT_KINDS)
|
||||
since = None
|
||||
if request.args.get("since"):
|
||||
try:
|
||||
@@ -1577,7 +1720,10 @@ def create_app(cfg):
|
||||
kinds=kinds or None,
|
||||
since=since,
|
||||
),
|
||||
"unread": store.unread_event_count(conn()),
|
||||
"unread": store.unread_event_count(
|
||||
conn(),
|
||||
kinds=list(store.ALERT_EVENT_KINDS) if alerts_only or unread else None,
|
||||
),
|
||||
})
|
||||
|
||||
@app.post("/api/v1/ereignisse/gelesen")
|
||||
@@ -1589,7 +1735,7 @@ def create_app(cfg):
|
||||
ids = [int(item) for item in ids]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GrottiError("IDs müssen Zahlen sein.", "id") from exc
|
||||
unread = store.mark_events_read(conn(), ids)
|
||||
unread = store.mark_events_read(conn(), ids, kinds=list(store.ALERT_EVENT_KINDS) if not ids else None)
|
||||
return jsonify({"unread": unread})
|
||||
|
||||
@app.post("/api/v1/dateien/von-drucker")
|
||||
|
||||
+31
-2
@@ -79,6 +79,34 @@ function withToken(url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const CAMERA_MODE_KEY = "grotti-camera";
|
||||
|
||||
function cameraMode() {
|
||||
try {
|
||||
return localStorage.getItem(CAMERA_MODE_KEY) === "stream" ? "stream" : "snapshot";
|
||||
} catch (error) {
|
||||
return "snapshot";
|
||||
}
|
||||
}
|
||||
|
||||
function setCameraMode(mode) {
|
||||
const value = mode === "stream" ? "stream" : "snapshot";
|
||||
try { localStorage.setItem(CAMERA_MODE_KEY, value); } catch (error) {}
|
||||
window.dispatchEvent(new CustomEvent("grotti-camera-mode", { detail: value }));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Snapshot oder Stream-URL je nach Browser-Einstellung. bustMs nur für Snapshot. */
|
||||
function cameraUrl(printer, bustMs) {
|
||||
if (!printer || !printer.camera) return "";
|
||||
if (cameraMode() === "stream" && printer.camera_stream) {
|
||||
return withToken(printer.camera_stream);
|
||||
}
|
||||
let url = withToken(printer.camera);
|
||||
if (bustMs) url += "&t=" + Math.floor(Date.now() / bustMs);
|
||||
return url;
|
||||
}
|
||||
|
||||
function euro(value) {
|
||||
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
}
|
||||
@@ -247,9 +275,10 @@ function icon(name, cls) {
|
||||
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
||||
}
|
||||
|
||||
function chipHtml(state, label, small) {
|
||||
function chipHtml(state, label, small, iconName) {
|
||||
const text = label || STATE_LABELS[state] || state || "";
|
||||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
|
||||
const ico = iconName ? icon(iconName, "chip-ico") : "";
|
||||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${ico}${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function shownState(printer) {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+881
-148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,528 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Lucide icons, built by scripts/build_icons.py — 111 symbols -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<symbol id="i-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
||||
</symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14" />
|
||||
<path d="m19 12-7 7-7-7" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
<path d="M12 19V5" />
|
||||
</symbol>
|
||||
<symbol id="i-ban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m4.9 4.9 14.2 14.2" />
|
||||
</symbol>
|
||||
<symbol id="i-bed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="12" x="2" y="6" rx="2" />
|
||||
</symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
|
||||
</symbol>
|
||||
<symbol id="i-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</symbol>
|
||||
<symbol id="i-brush" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
|
||||
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
|
||||
</symbol>
|
||||
<symbol id="i-bulb" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
</symbol>
|
||||
<symbol id="i-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</symbol>
|
||||
<symbol id="i-camera" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</symbol>
|
||||
<symbol id="i-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<path d="M18 17V9" />
|
||||
<path d="M13 17V5" />
|
||||
<path d="M8 17v-3" />
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-circle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</symbol>
|
||||
<symbol id="i-circle-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</symbol>
|
||||
<symbol id="i-circle-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m15 9-6 6" />
|
||||
<path d="m9 9 6 6" />
|
||||
</symbol>
|
||||
<symbol id="i-clipboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="M12 11h4" />
|
||||
<path d="M12 16h4" />
|
||||
<path d="M8 11h.01" />
|
||||
<path d="M8 16h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</symbol>
|
||||
<symbol id="i-cube" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</symbol>
|
||||
<symbol id="i-droplet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />
|
||||
</symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</symbol>
|
||||
<symbol id="i-euro" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 10h12" />
|
||||
<path d="M4 14h9" />
|
||||
<path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2" />
|
||||
</symbol>
|
||||
<symbol id="i-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</symbol>
|
||||
<symbol id="i-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</symbol>
|
||||
<symbol id="i-fan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z" />
|
||||
<path d="M12 12v.01" />
|
||||
</symbol>
|
||||
<symbol id="i-fan-aux" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.8 19.6A2 2 0 1 0 14 16H2" />
|
||||
<path d="M17.5 8a2.5 2.5 0 1 1 2 4H2" />
|
||||
<path d="M9.8 4.4A2 2 0 1 1 11 8H2" />
|
||||
</symbol>
|
||||
<symbol id="i-fan-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M6 8h12" />
|
||||
<path d="M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12" />
|
||||
<path d="M6.6 15.6A2 2 0 1 0 10 17v-5" />
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</symbol>
|
||||
<symbol id="i-flame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</symbol>
|
||||
<symbol id="i-folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</symbol>
|
||||
<symbol id="i-gauge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 14 4-4" />
|
||||
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
<line x1="6" x2="6.01" y1="16" y2="16" />
|
||||
<line x1="10" x2="10.01" y1="16" y2="16" />
|
||||
</symbol>
|
||||
<symbol id="i-hash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" x2="20" y1="9" y2="9" />
|
||||
<line x1="4" x2="20" y1="15" y2="15" />
|
||||
<line x1="10" x2="8" y1="3" y2="21" />
|
||||
<line x1="16" x2="14" y1="3" y2="21" />
|
||||
</symbol>
|
||||
<symbol id="i-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
||||
</symbol>
|
||||
<symbol id="i-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M12 7v5l4 2" />
|
||||
</symbol>
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</symbol>
|
||||
<symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</symbol>
|
||||
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
|
||||
<path d="m21 2-9.6 9.6" />
|
||||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||
</symbol>
|
||||
<symbol id="i-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 8h.01" />
|
||||
<path d="M12 12h.01" />
|
||||
<path d="M14 8h.01" />
|
||||
<path d="M16 12h.01" />
|
||||
<path d="M18 8h.01" />
|
||||
<path d="M6 8h.01" />
|
||||
<path d="M7 16h10" />
|
||||
<path d="M8 12h.01" />
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
</symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12h.01" />
|
||||
<path d="M3 18h.01" />
|
||||
<path d="M3 6h.01" />
|
||||
<path d="M8 12h13" />
|
||||
<path d="M8 18h13" />
|
||||
<path d="M8 6h13" />
|
||||
</symbol>
|
||||
<symbol id="i-loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</symbol>
|
||||
<symbol id="i-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
||||
</symbol>
|
||||
<symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
</symbol>
|
||||
<symbol id="i-monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</symbol>
|
||||
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</symbol>
|
||||
<symbol id="i-nozzle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m2 22 1-1h3l9-9" />
|
||||
<path d="M3 21v-3l9-9" />
|
||||
<path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z" />
|
||||
</symbol>
|
||||
<symbol id="i-package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
|
||||
<path d="M12 22V12" />
|
||||
<path d="m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7" />
|
||||
<path d="m7.5 4.27 9 5.15" />
|
||||
</symbol>
|
||||
<symbol id="i-palette" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
|
||||
</symbol>
|
||||
<symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-percent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" x2="5" y1="5" y2="19" />
|
||||
<circle cx="6.5" cy="6.5" r="2.5" />
|
||||
<circle cx="17.5" cy="17.5" r="2.5" />
|
||||
</symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</symbol>
|
||||
<symbol id="i-power" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v10" />
|
||||
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
|
||||
</symbol>
|
||||
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" />
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 12h11" />
|
||||
<path d="M10 18h11" />
|
||||
<path d="M10 6h11" />
|
||||
<path d="M4 10h2" />
|
||||
<path d="M4 6h1v4" />
|
||||
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
|
||||
</symbol>
|
||||
<symbol id="i-receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" />
|
||||
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
|
||||
<path d="M12 17.5v-11" />
|
||||
</symbol>
|
||||
<symbol id="i-redo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</symbol>
|
||||
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</symbol>
|
||||
<symbol id="i-save" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
|
||||
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" />
|
||||
<path d="M7 3v4a1 1 0 0 0 1 1h7" />
|
||||
</symbol>
|
||||
<symbol id="i-scan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M7 12h10" />
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
|
||||
<path d="m21.854 2.147-10.94 10.939" />
|
||||
</symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</symbol>
|
||||
<symbol id="i-sliders" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</symbol>
|
||||
<symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
|
||||
<path d="M20 3v4" />
|
||||
<path d="M22 5h-4" />
|
||||
<path d="M4 17v2" />
|
||||
<path d="M5 18H3" />
|
||||
</symbol>
|
||||
<symbol id="i-spool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M6 12c0-1.7.7-3.2 1.8-4.2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M18 12c0 1.7-.7 3.2-1.8 4.2" />
|
||||
</symbol>
|
||||
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</symbol>
|
||||
<symbol id="i-sweep" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
|
||||
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
|
||||
</symbol>
|
||||
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
|
||||
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</symbol>
|
||||
<symbol id="i-thermo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" />
|
||||
</symbol>
|
||||
<symbol id="i-timer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="10" x2="14" y1="2" y2="2" />
|
||||
<line x1="12" x2="15" y1="14" y2="11" />
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
</symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</symbol>
|
||||
<symbol id="i-truck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2" />
|
||||
<path d="M15 18H9" />
|
||||
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14" />
|
||||
<circle cx="17" cy="18" r="2" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
</symbol>
|
||||
<symbol id="i-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</symbol>
|
||||
<symbol id="i-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</symbol>
|
||||
<symbol id="i-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</symbol>
|
||||
<symbol id="i-weight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<path d="M6.5 8a2 2 0 0 0-1.905 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8Z" />
|
||||
</symbol>
|
||||
<symbol id="i-wifi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h.01" />
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0" />
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
||||
</symbol>
|
||||
<symbol id="i-wifi-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h.01" />
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
||||
<path d="M5 12.859a10 10 0 0 1 5.17-2.69" />
|
||||
<path d="M19 12.859a10 10 0 0 0-2.007-1.523" />
|
||||
<path d="M2 8.82a15 15 0 0 1 4.177-2.643" />
|
||||
<path d="M22 8.82a15 15 0 0 0-11.288-3.764" />
|
||||
<path d="m2 2 20 20" />
|
||||
</symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</symbol>
|
||||
<symbol id="i-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 34 KiB |
+6
-4
@@ -1,14 +1,16 @@
|
||||
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
|
||||
const CACHE = "grotti-shell-v2";
|
||||
const CACHE = "grotti-shell-v3";
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.addAll([
|
||||
"/static/grotti.css?v=56",
|
||||
"/static/api.js?v=29",
|
||||
"/static/ui.js?v=3",
|
||||
"/static/grotti.css?v=62",
|
||||
"/static/api.js?v=32",
|
||||
"/static/ui.js?v=5",
|
||||
"/static/icons.svg",
|
||||
"/static/fonts/InterVariable.woff2",
|
||||
]).catch(() => undefined)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -160,6 +160,7 @@ function promptAbraeumen(hostLabel) {
|
||||
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
|
||||
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
|
||||
</div>
|
||||
<label class="check"><input type="checkbox" id="ab-reprint"> Gleich nochmal drucken</label>
|
||||
<div class="actions">
|
||||
<button type="submit" value="ok" class="btn-primary">Quittieren</button>
|
||||
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
|
||||
@@ -193,6 +194,7 @@ function promptAbraeumen(hostLabel) {
|
||||
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
|
||||
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
|
||||
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
|
||||
reprint: !!dlg.querySelector("#ab-reprint").checked,
|
||||
};
|
||||
dlg.remove();
|
||||
resolve(payload);
|
||||
@@ -201,6 +203,131 @@ function promptAbraeumen(hostLabel) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast mit Rückgängig für umkehrbare Aktionen.
|
||||
* @param {string} message
|
||||
* @param {() => void | Promise<void>} onUndo
|
||||
* @param {number} [ttlMs]
|
||||
*/
|
||||
function undoToast(message, onUndo, ttlMs) {
|
||||
let host = document.querySelector(".toast-host");
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
host.className = "toast-host";
|
||||
host.setAttribute("role", "status");
|
||||
host.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
const node = document.createElement("div");
|
||||
node.className = "toast good undo-toast";
|
||||
node.innerHTML = '<div></div><button type="button">Rückgängig</button>';
|
||||
node.querySelector("div").textContent = String(message ?? "");
|
||||
let undone = false;
|
||||
const btn = node.querySelector("button");
|
||||
btn.addEventListener("click", async () => {
|
||||
if (undone) return;
|
||||
undone = true;
|
||||
node.remove();
|
||||
try { await onUndo(); } catch (error) { toast(error.message || String(error), "bad"); }
|
||||
});
|
||||
host.appendChild(node);
|
||||
setTimeout(() => {
|
||||
if (undone) return;
|
||||
node.classList.add("out");
|
||||
setTimeout(() => node.remove(), 260);
|
||||
}, ttlMs || 6000);
|
||||
}
|
||||
|
||||
/** Globale Suche Ctrl+K / Cmd+K. */
|
||||
function openCommandPalette() {
|
||||
const existing = document.getElementById("cmdk-dlg");
|
||||
if (existing) {
|
||||
existing.showModal();
|
||||
existing.querySelector("input")?.focus();
|
||||
return;
|
||||
}
|
||||
const dlg = document.createElement("dialog");
|
||||
dlg.id = "cmdk-dlg";
|
||||
dlg.className = "cmdk-dlg";
|
||||
dlg.innerHTML = `<div class="cmdk-wrap">
|
||||
<input type="search" placeholder="Drucker, Datei, Spule, Charge, Auftrag…" autocomplete="off">
|
||||
<ul class="cmdk-list"></ul>
|
||||
</div>`;
|
||||
document.body.appendChild(dlg);
|
||||
const input = dlg.querySelector("input");
|
||||
const list = dlg.querySelector(".cmdk-list");
|
||||
let items = [];
|
||||
let active = 0;
|
||||
let timer = 0;
|
||||
|
||||
function paint() {
|
||||
list.innerHTML = items.map((item, i) =>
|
||||
`<li class="${i === active ? "on" : ""}" data-i="${i}">
|
||||
<span>${escapeHtml(item.title)}</span>
|
||||
<span class="tiny">${escapeHtml(item.subtitle || "")}</span>
|
||||
</li>`
|
||||
).join("") || '<li class="tiny">Nichts gefunden.</li>';
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
q = (q || "").trim();
|
||||
if (!q) { items = []; paint(); return; }
|
||||
try {
|
||||
const data = await api("/suche?q=" + encodeURIComponent(q));
|
||||
items = data.items || [];
|
||||
} catch (error) {
|
||||
items = [];
|
||||
}
|
||||
active = 0;
|
||||
paint();
|
||||
}
|
||||
|
||||
function go(item) {
|
||||
if (!item || !item.href) return;
|
||||
dlg.close();
|
||||
location.href = item.href;
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
active = Math.min(items.length - 1, active + 1);
|
||||
paint();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
active = Math.max(0, active - 1);
|
||||
paint();
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
go(items[active]);
|
||||
} else if (event.key === "Escape") {
|
||||
dlg.close();
|
||||
}
|
||||
});
|
||||
list.addEventListener("click", (event) => {
|
||||
const li = event.target.closest("[data-i]");
|
||||
if (!li) return;
|
||||
go(items[Number(li.dataset.i)]);
|
||||
});
|
||||
dlg.addEventListener("close", () => { /* bleibt im DOM */ });
|
||||
dlg.showModal();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && (event.key === "k" || event.key === "K")) {
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
window.undoToast = undoToast;
|
||||
window.openCommandPalette = openCommandPalette;
|
||||
|
||||
window.RUN_STATES = RUN_STATES;
|
||||
window.setConnectionBanner = setConnectionBanner;
|
||||
window.createPoller = createPoller;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<div class="jetzt-eta" id="jetzt-eta" hidden></div>
|
||||
<section class="queue-box is-empty" data-box="jetzt" id="jetzt-box">
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-sparkles"/></svg><span class="grow">Jetzt</span><span class="count todo" id="jetzt-count">0</span></h2>
|
||||
<ul class="queue-list" id="jetzt-list"></ul>
|
||||
</section>
|
||||
<section class="run-stack" id="run-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-play"/></svg><span class="grow">Läuft</span></h2>
|
||||
<ul class="queue-list" id="run-list"></ul>
|
||||
</section>
|
||||
<section class="queue-box" data-box="send" id="send-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-send"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></h2>
|
||||
<ul class="queue-list" id="send-list"></ul>
|
||||
</section>
|
||||
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</section>
|
||||
@@ -11,13 +11,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" id="f-range" role="group" aria-label="Zeitraum">
|
||||
<button type="button" class="fchip on" data-range="day">Heute</button>
|
||||
<button type="button" class="fchip" data-range="week">7 Tage</button>
|
||||
<button type="button" class="fchip" data-range="all">Alle</button>
|
||||
<div class="toolbar" id="f-range" role="group" aria-label="Zeitraum">
|
||||
<div class="fchips">
|
||||
<button type="button" class="fchip on" data-range="day">Heute</button>
|
||||
<button type="button" class="fchip" data-range="week">7 Tage</button>
|
||||
<button type="button" class="fchip" data-range="all">Alle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="f-kind" role="group" aria-label="Art"></div>
|
||||
<div class="filter-bar" id="f-host" role="group" aria-label="Drucker"></div>
|
||||
<div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
|
||||
<div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
|
||||
|
||||
<p id="tl-note" class="tiny"></p>
|
||||
<div class="timeline" id="timeline">
|
||||
|
||||
+106
-18
@@ -55,9 +55,9 @@
|
||||
</div>
|
||||
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="ghost" id="order-new">Neu</button>
|
||||
<button type="button" class="warn" id="order-delete" hidden>Löschen</button>
|
||||
<button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="order-note" class="tiny"></p>
|
||||
@@ -81,11 +81,16 @@
|
||||
<div class="actions"><button type="submit">Position hinzufügen</button></div>
|
||||
</form>
|
||||
<details class="section-gap">
|
||||
<summary>Kunde anlegen</summary>
|
||||
<summary>Kunde anlegen / bearbeiten</summary>
|
||||
<form id="customer-form">
|
||||
<input type="hidden" id="cust-id">
|
||||
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
|
||||
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
|
||||
<button type="submit">Kunde speichern</button>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Kunde speichern</button>
|
||||
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
|
||||
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
@@ -130,7 +135,14 @@ function paintActuals(order) {
|
||||
const el = document.getElementById("order-actuals");
|
||||
const a = order.actuals;
|
||||
if (!a) { el.textContent = ""; return; }
|
||||
el.textContent = "Ist: " + euro(a.cost_total) + " · " + (hours(a.duration_s) || "0 min") + " · Ausschuss " + a.pieces_bad + " Stk";
|
||||
const bits = [
|
||||
"Ist: " + euro(a.cost_total),
|
||||
hours(a.duration_s) || "0 min",
|
||||
"Ausschuss " + a.pieces_bad + " Stk",
|
||||
];
|
||||
if (a.cost_per_part != null) bits.push(euro(a.cost_per_part) + "/Teil");
|
||||
if (a.yield_percent != null) bits.push("Yield " + Math.round(a.yield_percent) + " %");
|
||||
el.textContent = bits.join(" · ");
|
||||
}
|
||||
|
||||
function fillOrder(order) {
|
||||
@@ -169,17 +181,55 @@ function renderItems(items) {
|
||||
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
|
||||
tr.children[1].textContent = item.qty;
|
||||
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
|
||||
const tools = document.createElement("div");
|
||||
tools.className = "queue-tools";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "ghost tiny-btn";
|
||||
btn.textContent = "Einreihen";
|
||||
btn.disabled = !item.file_id || !current;
|
||||
btn.addEventListener("click", () => einreihen(item.id));
|
||||
tr.children[3].appendChild(btn);
|
||||
const edit = document.createElement("button");
|
||||
edit.type = "button";
|
||||
edit.className = "ghost tiny-btn";
|
||||
edit.textContent = "Ändern";
|
||||
edit.addEventListener("click", () => editItem(item));
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-danger tiny-btn";
|
||||
del.textContent = "Löschen";
|
||||
del.addEventListener("click", (event) => {
|
||||
armButton(event.currentTarget, "Position löschen?", async () => {
|
||||
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
|
||||
toast("Position gelöscht.");
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
fillOrder(full);
|
||||
});
|
||||
});
|
||||
tools.append(btn, edit, del);
|
||||
tr.children[3].appendChild(tools);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function editItem(item) {
|
||||
const qty = prompt("Stückzahl", String(item.qty || 1));
|
||||
if (qty == null) return;
|
||||
const price = prompt("Preis/Stk €", String(item.unit_price || 0));
|
||||
if (price == null) return;
|
||||
try {
|
||||
await api("/auftraege/positionen/" + item.id, {
|
||||
method: "PATCH",
|
||||
json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 },
|
||||
});
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
fillOrder(full);
|
||||
toast("Position gespeichert.");
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function einreihen(itemId) {
|
||||
if (!current) return;
|
||||
try {
|
||||
@@ -200,7 +250,8 @@ function renderList() {
|
||||
const body = document.getElementById("order-rows");
|
||||
body.innerHTML = "";
|
||||
if (!orders.length) {
|
||||
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
|
||||
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
|
||||
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
|
||||
return;
|
||||
}
|
||||
const selected = document.getElementById("order-id").value;
|
||||
@@ -209,7 +260,9 @@ function renderList() {
|
||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = order.title;
|
||||
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
|
||||
const st = order.status || "";
|
||||
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
|
||||
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
|
||||
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
||||
tr.addEventListener("click", async () => {
|
||||
try {
|
||||
@@ -259,7 +312,7 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
|
||||
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/auftraege", { method: "POST", json: payload });
|
||||
showNote("order-note", null);
|
||||
document.getElementById("order-note").textContent = "Gemerkt.";
|
||||
document.getElementById("order-note").textContent = "Gespeichert.";
|
||||
await loadList();
|
||||
fillOrder(saved);
|
||||
} catch (error) {
|
||||
@@ -302,25 +355,60 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
|
||||
|
||||
document.getElementById("customer-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("cust-id").value;
|
||||
const payload = {
|
||||
name: document.getElementById("cust-name").value,
|
||||
email: document.getElementById("cust-email").value,
|
||||
};
|
||||
try {
|
||||
const row = await api("/kunden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
name: document.getElementById("cust-name").value,
|
||||
email: document.getElementById("cust-email").value,
|
||||
},
|
||||
});
|
||||
customers.push(row);
|
||||
const row = id
|
||||
? await api("/kunden/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/kunden", { method: "POST", json: payload });
|
||||
if (id) {
|
||||
customers = customers.map((c) => (c.id === row.id ? row : c));
|
||||
} else {
|
||||
customers.push(row);
|
||||
}
|
||||
fillCustomers();
|
||||
document.getElementById("order-customer").value = row.id;
|
||||
document.getElementById("cust-id").value = "";
|
||||
document.getElementById("cust-name").value = "";
|
||||
document.getElementById("cust-email").value = "";
|
||||
toast("Kunde angelegt.");
|
||||
document.getElementById("cust-delete").hidden = true;
|
||||
toast(id ? "Kunde gespeichert." : "Kunde angelegt.");
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("cust-edit-current").addEventListener("click", () => {
|
||||
const id = document.getElementById("order-customer").value;
|
||||
const row = customers.find((c) => String(c.id) === String(id));
|
||||
if (!row) {
|
||||
toast("Zuerst einen Kunden wählen.", "bad");
|
||||
return;
|
||||
}
|
||||
document.getElementById("cust-id").value = row.id;
|
||||
document.getElementById("cust-name").value = row.name || "";
|
||||
document.getElementById("cust-email").value = row.email || "";
|
||||
document.getElementById("cust-delete").hidden = false;
|
||||
});
|
||||
|
||||
document.getElementById("cust-delete").addEventListener("click", (event) => {
|
||||
const id = document.getElementById("cust-id").value;
|
||||
if (!id) return;
|
||||
armButton(event.currentTarget, "Kunde löschen?", async () => {
|
||||
await api("/kunden/" + id, { method: "DELETE" });
|
||||
customers = customers.filter((c) => String(c.id) !== String(id));
|
||||
fillCustomers();
|
||||
document.getElementById("cust-id").value = "";
|
||||
document.getElementById("cust-name").value = "";
|
||||
document.getElementById("cust-email").value = "";
|
||||
document.getElementById("cust-delete").hidden = true;
|
||||
toast("Kunde gelöscht.");
|
||||
});
|
||||
});
|
||||
|
||||
loadBase().catch((error) => showNote("order-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -29,6 +29,72 @@ function sourceLabel(source, estimate) {
|
||||
return source || "";
|
||||
}
|
||||
|
||||
const ENERGY_PART_COLORS = {
|
||||
base: "#94a3b8",
|
||||
nozzle: "#ea580c",
|
||||
bed: "#2563eb",
|
||||
dryer: "#16a34a",
|
||||
};
|
||||
|
||||
function kwhLabel(value) {
|
||||
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
|
||||
}
|
||||
|
||||
function energyPieSvg(parts) {
|
||||
const items = (parts || []).filter((p) => Number(p.kwh) > 0);
|
||||
const total = items.reduce((sum, p) => sum + Number(p.kwh || 0), 0);
|
||||
if (!items.length || total <= 0) return "";
|
||||
const cx = 42;
|
||||
const cy = 42;
|
||||
const r = 36;
|
||||
let angle = -Math.PI / 2;
|
||||
const slices = items.map((p) => {
|
||||
const share = Number(p.kwh) / total;
|
||||
const sweep = share * Math.PI * 2;
|
||||
const x1 = cx + r * Math.cos(angle);
|
||||
const y1 = cy + r * Math.sin(angle);
|
||||
angle += sweep;
|
||||
const x2 = cx + r * Math.cos(angle);
|
||||
const y2 = cy + r * Math.sin(angle);
|
||||
const large = sweep > Math.PI ? 1 : 0;
|
||||
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
|
||||
if (share >= 0.999) {
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`;
|
||||
}
|
||||
return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`;
|
||||
}).join("");
|
||||
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Stromanteile">${slices}</svg>`;
|
||||
}
|
||||
|
||||
function energyBreakdownHtml(energy) {
|
||||
const parts = energy.parts || [];
|
||||
if (!parts.length) return "";
|
||||
const src = energy.source === "ed" ? "aus ED" : "pauschal";
|
||||
const rate = energy.euro_per_kwh != null
|
||||
? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh"
|
||||
: "";
|
||||
const rows = parts.map((p) => {
|
||||
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
|
||||
return `<tr>
|
||||
<td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td>
|
||||
<td class="num">${kwhLabel(p.kwh)}</td>
|
||||
<td class="num">${euro(p.cost)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
return `<div class="energy-break">
|
||||
<div class="energy-break-chart">${energyPieSvg(parts)}</div>
|
||||
<div class="energy-break-table">
|
||||
<div class="lib-head"><h3>Strom</h3><span class="tiny">${escapeHtml(src)}${rate ? " · " + rate : ""}</span></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead>
|
||||
<tbody>${rows}
|
||||
<tr class="energy-total"><td>Summe</td><td class="num">${kwhLabel(energy.kwh)}</td><td class="num">${euro(energy.cost)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const run = data.run;
|
||||
const name = (run.filename || "").split("/").pop() || "ohne Datei";
|
||||
@@ -80,10 +146,12 @@ function render(data) {
|
||||
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
|
||||
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
|
||||
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
|
||||
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})</p>
|
||||
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
|
||||
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</p>` : ""}
|
||||
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
|
||||
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
|
||||
</div>
|
||||
${energyBreakdownHtml(energy)}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Spulen</h2>
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
|
||||
<div class="send-layout">
|
||||
<section class="card lib-card" id="lib-card">
|
||||
<div class="lib-head">
|
||||
<div class="card-head lib-head">
|
||||
<svg class="ico head-ico"><use href="#i-folder-open"/></svg>
|
||||
<h2>Bibliothek</h2>
|
||||
<span class="count" id="lib-count">0</span>
|
||||
<span class="count-pill" id="lib-count">0</span>
|
||||
</div>
|
||||
<div class="lib-tools">
|
||||
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
|
||||
<div class="toolbar lib-tools">
|
||||
<div class="search-wrap">
|
||||
<svg class="ico"><use href="#i-search"/></svg>
|
||||
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
|
||||
</div>
|
||||
<select id="file-filter-family">
|
||||
<option value="">Alle Maschinen</option>
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
@@ -63,18 +67,18 @@
|
||||
</div>
|
||||
|
||||
<div class="flow-step" id="step-file">
|
||||
<h3><span class="num-dot">1</span>Datei</h3>
|
||||
<h3><span class="step-num">1</span>Datei</h3>
|
||||
<div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="flow-step" id="step-target">
|
||||
<h3><span class="num-dot">2</span>Drucker</h3>
|
||||
<h3><span class="step-num">2</span>Drucker</h3>
|
||||
<div class="targets" id="targets"></div>
|
||||
<p class="tiny" id="target-hint"></p>
|
||||
</div>
|
||||
|
||||
<div class="flow-step ok" data-pane="send" id="step-mode">
|
||||
<h3><span class="num-dot">3</span>Wann</h3>
|
||||
<h3><span class="step-num">3</span>Wann</h3>
|
||||
<div class="segmented" id="mode" role="radiogroup">
|
||||
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button>
|
||||
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button>
|
||||
@@ -83,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden>
|
||||
<h3><span class="num-dot">3</span>Schritte</h3>
|
||||
<h3><span class="step-num">3</span>Schritte</h3>
|
||||
<p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p>
|
||||
<div id="ablauf-preview" class="ablauf-preview"></div>
|
||||
<div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden>
|
||||
|
||||
+234
-101
@@ -5,9 +5,26 @@
|
||||
<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>
|
||||
|
||||
<section class="card">
|
||||
<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">
|
||||
@@ -36,20 +53,28 @@
|
||||
<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 und Zeitzone nur dieser Browser.</p>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
@@ -57,13 +82,13 @@
|
||||
<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" 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">
|
||||
<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">
|
||||
@@ -71,14 +96,14 @@
|
||||
<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>
|
||||
<div class="actions"><button type="button" id="fqdn-save" hidden>Speichern</button></div>
|
||||
<p id="fqdn-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<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).</p>
|
||||
<div id="printer-list" class="printer-edit-list"></div>
|
||||
<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>
|
||||
@@ -101,7 +126,7 @@
|
||||
<p id="printer-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<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">
|
||||
@@ -124,13 +149,13 @@
|
||||
</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" 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">
|
||||
<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">
|
||||
@@ -149,13 +174,13 @@
|
||||
</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" 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">
|
||||
<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">
|
||||
@@ -165,7 +190,7 @@
|
||||
<p id="backup-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<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">
|
||||
@@ -188,11 +213,11 @@
|
||||
</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>
|
||||
<div class="actions"><button type="button" id="en-save" hidden>Speichern</button></div>
|
||||
<p id="en-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<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">
|
||||
@@ -213,7 +238,7 @@
|
||||
<p id="user-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<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">
|
||||
@@ -234,6 +259,38 @@
|
||||
<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>
|
||||
@@ -261,7 +318,27 @@ function windowFrom(row) {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -271,86 +348,22 @@ function renderPrinters(rows) {
|
||||
rows.forEach((row) => {
|
||||
const win = windowFrom(row);
|
||||
const card = document.createElement("div");
|
||||
card.className = "printer-edit";
|
||||
card.className = "printer-edit-card";
|
||||
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 class="grow">
|
||||
<strong></strong>
|
||||
<div class="tiny"></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);
|
||||
<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);
|
||||
});
|
||||
}
|
||||
@@ -453,6 +466,8 @@ async function load() {
|
||||
]);
|
||||
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 || "";
|
||||
@@ -551,7 +566,10 @@ document.getElementById("auto-save").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: { auto_queue: document.getElementById("auto-queue").checked },
|
||||
json: {
|
||||
auto_queue: document.getElementById("auto-queue").checked,
|
||||
auto_retry: document.getElementById("auto-retry")?.checked !== false,
|
||||
},
|
||||
});
|
||||
showNote(
|
||||
"auto-note",
|
||||
@@ -841,6 +859,121 @@ function flashDisplayNote() {
|
||||
});
|
||||
})();
|
||||
|
||||
(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 %}
|
||||
@@ -1,112 +0,0 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Ideen</h1>
|
||||
<p class="tiny">Backlog und Notizen für später.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<section class="card table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Stand</th></tr></thead>
|
||||
<tbody id="idea-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 id="idea-title">Neue Idee</h2>
|
||||
<form id="idea-form">
|
||||
<input id="idea-id" type="hidden">
|
||||
<div class="field"><label for="idea-name">Titel</label><input id="idea-name" required></div>
|
||||
<div class="field"><label for="idea-body">Notiz</label><textarea id="idea-body"></textarea></div>
|
||||
<div class="field"><label for="idea-status">Stand</label>
|
||||
<select id="idea-status">
|
||||
<option value="offen">offen</option>
|
||||
<option value="spaeter">später</option>
|
||||
<option value="erledigt">erledigt</option>
|
||||
</select></div>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="button" class="ghost" id="idea-new">Neu</button>
|
||||
<button type="button" class="warn" id="idea-delete"><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="idea-note"></p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
let ideas = [];
|
||||
const STATUS = { offen: "offen", spaeter: "später", erledigt: "erledigt" };
|
||||
|
||||
function fill(idea) {
|
||||
document.getElementById("idea-id").value = idea ? idea.id : "";
|
||||
document.getElementById("idea-name").value = idea ? idea.title : "";
|
||||
document.getElementById("idea-body").value = idea ? idea.body : "";
|
||||
document.getElementById("idea-status").value = idea ? idea.status : "offen";
|
||||
document.getElementById("idea-title").textContent = idea ? idea.title : "Neue Idee";
|
||||
document.getElementById("idea-delete").hidden = !idea;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const body = document.getElementById("idea-rows");
|
||||
body.innerHTML = "";
|
||||
if (!ideas.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="2">Noch nichts notiert.</td></tr>';
|
||||
return;
|
||||
}
|
||||
ideas.forEach((idea) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td>";
|
||||
tr.children[0].textContent = idea.title;
|
||||
tr.children[1].textContent = STATUS[idea.status] || idea.status;
|
||||
tr.addEventListener("click", () => fill(idea));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
ideas = await api("/ideen");
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById("idea-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("idea-id").value;
|
||||
const payload = {
|
||||
title: document.getElementById("idea-name").value,
|
||||
body: document.getElementById("idea-body").value,
|
||||
status: document.getElementById("idea-status").value,
|
||||
};
|
||||
try {
|
||||
const saved = id
|
||||
? await api("/ideen/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/ideen", { method: "POST", json: payload });
|
||||
document.getElementById("idea-note").textContent = "Gemerkt.";
|
||||
document.getElementById("idea-note").className = "ok";
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("idea-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("idea-new").addEventListener("click", () => fill(null));
|
||||
document.getElementById("idea-delete").addEventListener("click", (event) => {
|
||||
const id = document.getElementById("idea-id").value;
|
||||
if (!id) return;
|
||||
armButton(event.currentTarget, "Idee löschen?", async () => {
|
||||
try {
|
||||
await api("/ideen/" + id, { method: "DELETE" });
|
||||
toast("Idee gelöscht.");
|
||||
await load();
|
||||
fill(null);
|
||||
} catch (error) {
|
||||
showNote("idea-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
load().then(() => fill(null)).catch((error) => showNote("idea-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Jetzt</h1>
|
||||
<p class="tiny">Handlungen, laufende Drucke und Meldungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-panel live-page" id="live-panel">
|
||||
{% include "_live_panel.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,16 @@
|
||||
<h1>Kameras</h1>
|
||||
<p class="tiny">Livebilder aller Drucker.</p>
|
||||
</div>
|
||||
<span id="when"></span>
|
||||
<div class="page-head-side">
|
||||
<label class="field cam-mode-field">
|
||||
<span class="tiny">Bild</span>
|
||||
<select id="cam-mode" title="Kamera-Modus">
|
||||
<option value="snapshot">Snapshot</option>
|
||||
<option value="stream">Stream</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="when"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="camera-grid" id="grid"></div>
|
||||
{% endblock %}
|
||||
@@ -13,10 +22,27 @@
|
||||
<script>
|
||||
const tiles = new Map();
|
||||
|
||||
(function initCamMode() {
|
||||
const sel = document.getElementById("cam-mode");
|
||||
if (!sel || typeof cameraMode !== "function") return;
|
||||
sel.value = cameraMode();
|
||||
sel.addEventListener("change", () => {
|
||||
setCameraMode(sel.value);
|
||||
for (const tile of tiles.values()) {
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
if (img) img.dataset.srcKey = "";
|
||||
}
|
||||
refresh().catch(() => {});
|
||||
});
|
||||
window.addEventListener("grotti-camera-mode", () => {
|
||||
sel.value = cameraMode();
|
||||
});
|
||||
})();
|
||||
|
||||
function metaLine(printer) {
|
||||
const state = shownState(printer);
|
||||
const busy = state === "printing" || state === "paused";
|
||||
const bits = [STATE_LABELS[state] || state];
|
||||
const bits = [chipHtml(state, STATE_LABELS[state] || state)];
|
||||
if (busy) {
|
||||
const pct = Math.round((printer.progress || 0) * 100);
|
||||
bits.push(pct + " %");
|
||||
@@ -25,7 +51,7 @@ function metaLine(printer) {
|
||||
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
|
||||
}
|
||||
const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
|
||||
if (busy && job) bits.push(job);
|
||||
if (busy && job) bits.push(escapeHtml(job));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
@@ -61,15 +87,16 @@ async function refresh() {
|
||||
return;
|
||||
}
|
||||
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
|
||||
const streaming = cameraMode() === "stream";
|
||||
const seen = new Set();
|
||||
for (const printer of printers) {
|
||||
seen.add(printer.host);
|
||||
const tile = ensureTile(printer);
|
||||
const state = shownState(printer);
|
||||
tile.className = "camera-tile " + state;
|
||||
tile.className = "camera-tile " + state + (streaming ? " stream" : "");
|
||||
tile.querySelector(".cam-name").textContent = printer.name;
|
||||
tile.querySelector(".host").textContent = printer.host;
|
||||
tile.querySelector(".cam-meta").textContent = metaLine(printer);
|
||||
tile.querySelector(".cam-meta").innerHTML = metaLine(printer);
|
||||
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
const empty = tile.querySelector('[data-role="cam-empty"]');
|
||||
@@ -78,10 +105,13 @@ async function refresh() {
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
img.dataset.srcKey = "";
|
||||
continue;
|
||||
}
|
||||
img.alt = "Kamera " + printer.name;
|
||||
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 2000);
|
||||
const next = cameraUrl(printer, streaming ? 0 : 2000);
|
||||
if (img.dataset.srcKey === next) continue;
|
||||
img.dataset.srcKey = next;
|
||||
img.onerror = () => {
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
|
||||
+351
-356
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=56">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=66">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
@@ -65,119 +65,66 @@
|
||||
</head>
|
||||
<body class="app-shell">
|
||||
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
|
||||
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></symbol>
|
||||
<symbol id="i-spool" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/></symbol>
|
||||
<symbol id="i-folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/></symbol>
|
||||
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M5 3v18l15-9z"/></symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="m7 8 5-5 5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M3 5h12"/><path d="M3 12h9"/><path d="M3 19h6"/><path d="m16 11 5 3-5 3z"/></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/></symbol>
|
||||
<symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5A2.5 2.5 0 0 1 4 19.5"/><path d="M8 7h8M8 11h8"/></symbol>
|
||||
<symbol id="i-cube" viewBox="0 0 24 24"><path d="m21 8-9-5-9 5v8l9 5 9-5z"/><path d="m3.3 7.2 8.7 4.8 8.7-4.8"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
|
||||
<symbol id="i-sweep" viewBox="0 0 24 24"><path d="M3 21h18"/><path d="M9 8v8"/><path d="M15 8v8"/><path d="M5 12h14"/><path d="M7 16h10"/><path d="M12 3v5"/></symbol>
|
||||
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/><path d="M12 14v3"/></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="i-bulb" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
|
||||
<symbol id="i-scan" viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><rect x="7" y="7" width="10" height="10" rx="1"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></symbol>
|
||||
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol>
|
||||
<symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 8h7M14 12h7M14 16h7"/></symbol>
|
||||
<symbol id="i-nozzle" viewBox="0 0 24 24"><path d="M8 3h8v4l-2 3v5l-2 6-2-6v-5L8 7z"/><path d="M9 7h6"/></symbol>
|
||||
<symbol id="i-bed" viewBox="0 0 24 24"><path d="M4 16h16"/><path d="M5 16v3M19 16v3"/><path d="M6 12h12v4H6z"/><path d="M8 8h8"/></symbol>
|
||||
<symbol id="i-chamber" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></symbol>
|
||||
<symbol id="i-fan" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 4c2 2 2 5 0 6-2-1-2-4 0-6zM12 14c2 1 2 4 0 6-2-2-2-5 0-6zM4 12c2-2 5-2 6 0-1 2-4 2-6 0zM14 12c1-2 4-2 6 0-2 2-5 2-6 0z"/></symbol>
|
||||
<symbol id="i-fan-chamber" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.6"/><path d="M12 5.5c1.4 1.4 1.4 3.5 0 4.2-1.4-.7-1.4-2.8 0-4.2zM12 14.3c1.4.7 1.4 2.8 0 4.2-1.4-1.4-1.4-3.5 0-4.2zM5.5 12c1.4-1.4 3.5-1.4 4.2 0-.7 1.4-2.8 1.4-4.2 0zM14.3 12c.7-1.4 2.8-1.4 4.2 0-1.4 1.4-3.5 1.4-4.2 0z"/><rect x="3.5" y="3.5" width="17" height="17" rx="2" fill="none"/></symbol>
|
||||
<symbol id="i-fan-aux" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></symbol>
|
||||
</svg>
|
||||
{{ icons_sprite }}
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/" title="Grotti">
|
||||
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
|
||||
<span class="brand-name">Grotti</span>
|
||||
</a>
|
||||
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
||||
<div class="brand-tools">
|
||||
<button type="button" class="icon-btn m-only" id="cmdk-open-m" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg></button>
|
||||
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-main" aria-label="Hauptmenü">
|
||||
<span class="nav-label">Live</span>
|
||||
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
||||
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
|
||||
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
|
||||
<span class="nav-label nav-desk">Rückschau</span>
|
||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-grid"/></svg><span>Statistik</span></a>
|
||||
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<span class="nav-label nav-desk">Verwalten</span>
|
||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-nozzle"/></svg><span>Wartung</span></a>
|
||||
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
|
||||
</nav>
|
||||
<div class="live-panel" id="live-panel">
|
||||
<details class="queue-box" data-box="run" id="run-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Läuft jetzt</span><span class="count hot" id="run-count">0</span></summary>
|
||||
<ul class="queue-list" id="run-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="wait" id="wait-box" open>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Wartet</span><span class="count" id="wait-count">0</span></summary>
|
||||
<ul class="queue-list" id="wait-list"></ul>
|
||||
<div class="queue-empty" id="wait-empty"><svg class="ico"><use href="#i-inbox"/></svg><span>Leer</span></div>
|
||||
</details>
|
||||
<details class="queue-box" data-box="todo" id="todo-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Aufgaben</span><span class="count todo" id="todo-count">0</span></summary>
|
||||
<ul class="queue-list" id="todo-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="send" id="send-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></summary>
|
||||
<ul class="queue-list" id="send-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="alerts" id="alert-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Alerts</span><span class="count bad" id="alert-count">0</span></summary>
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</details>
|
||||
<details class="queue-box" data-box="bedarf" id="bedarf-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bedarf</span><span class="count bad" id="bedarf-count">0</span></summary>
|
||||
<ul class="queue-list" id="bedarf-list"></ul>
|
||||
<div class="queue-foot"><a href="/material#bedarf">Material →</a></div>
|
||||
</details>
|
||||
</div>
|
||||
<nav class="nav-secondary" aria-label="Weitere Seiten">
|
||||
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
|
||||
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
{% if current_user %}
|
||||
<div class="side-user">
|
||||
<span class="tiny" title="{{ current_user.role }}">{{ current_user.username }}</span>
|
||||
<button type="button" class="ghost tiny-btn" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
||||
<a href="/doku" title="Doku">v{{ version }}</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-main">
|
||||
<header class="topbar">
|
||||
<button type="button" class="cmdk-btn" id="cmdk-open" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg><span>Suche</span><kbd>Ctrl K</kbd></button>
|
||||
<div class="live-bar" id="live-bar">
|
||||
<div class="live-chips" id="live-chips"></div>
|
||||
{% if page != 'jetzt' %}
|
||||
<div class="live-pop" id="live-pop" hidden>
|
||||
<div class="live-panel" id="live-panel">
|
||||
{% include "_live_panel.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="topbar-tools">
|
||||
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
|
||||
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
|
||||
{% if current_user %}
|
||||
<details class="user-menu" id="user-menu">
|
||||
<summary class="side-avatar" aria-label="Benutzer-Menü">{{ current_user.username[:1]|upper }}</summary>
|
||||
<div class="menu-pop" role="menu">
|
||||
<div class="menu-user">
|
||||
<strong>{{ current_user.username }}</strong>
|
||||
<span class="tiny">{{ current_user.role }}</span>
|
||||
</div>
|
||||
<button type="button" class="ghost" id="logout-btn" role="menuitem">Abmelden</button>
|
||||
<a href="/doku" role="menuitem">v{{ version }}</a>
|
||||
<span class="tiny menu-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
@@ -185,8 +132,8 @@
|
||||
<nav class="mobile-nav" aria-label="Schnellmenü">
|
||||
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
||||
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Queue</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }}"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/jetzt" class="{{ 'on' if page == 'jetzt' else '' }}"><svg class="ico"><use href="#i-sparkles"/></svg><span>Jetzt</span><span class="nav-badge" id="mobile-jetzt-badge" hidden>0</span></a>
|
||||
<button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
|
||||
</nav>
|
||||
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
||||
@@ -195,20 +142,45 @@
|
||||
<a href="/auftraege">Aufträge</a>
|
||||
<a href="/protokoll">Protokoll</a>
|
||||
<a href="/statistik">Statistik</a>
|
||||
<a href="/aktivitaet">Aktivität</a>
|
||||
<a href="/material">Material</a>
|
||||
<a href="/wartung">Wartung</a>
|
||||
<a href="/einstellungen">Einstellungen</a>
|
||||
<a href="/doku">Doku</a>
|
||||
</nav>
|
||||
{% if current_user %}
|
||||
<div class="mobile-more-user">
|
||||
<span class="side-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
|
||||
<span class="grow">
|
||||
<strong>{{ current_user.username }}</strong>
|
||||
<span class="tiny">{{ current_user.role }} · v{{ version }}</span>
|
||||
</span>
|
||||
<button type="button" class="ghost tiny-btn" id="logout-btn-m">Abmelden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
|
||||
<script>
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
async function doLogout() {
|
||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||
location.href = "/login";
|
||||
});
|
||||
}
|
||||
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
|
||||
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
|
||||
(function userMenuClose() {
|
||||
const menu = document.getElementById("user-menu");
|
||||
if (!menu) return;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!menu.open) return;
|
||||
if (menu.contains(event.target)) return;
|
||||
menu.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && menu.open) menu.open = false;
|
||||
});
|
||||
})();
|
||||
(function applyShellClasses() {
|
||||
const shelf = document.documentElement.classList.contains("shelf");
|
||||
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
||||
@@ -247,6 +219,11 @@ function setDisplayMode(mode) {
|
||||
document.getElementById("nav-toggle")?.addEventListener("click", () => {
|
||||
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
|
||||
});
|
||||
function openCmdk() {
|
||||
if (typeof openCommandPalette === "function") openCommandPalette();
|
||||
}
|
||||
document.getElementById("cmdk-open")?.addEventListener("click", openCmdk);
|
||||
document.getElementById("cmdk-open-m")?.addEventListener("click", openCmdk);
|
||||
|
||||
(function restoreBoxes() {
|
||||
let closed = [];
|
||||
@@ -267,33 +244,74 @@ function queueTarget(job) {
|
||||
|
||||
async function cancelQueueItem(job, button) {
|
||||
const running = job.status === "running";
|
||||
const armed = running
|
||||
? (job.is_ablauf ? "Ablauf stoppen?" : "Auftrag stoppen?")
|
||||
: "Entfernen?";
|
||||
const go = async () => {
|
||||
if (button) button.disabled = true;
|
||||
const prev = { status: job.status, priority: job.priority };
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
|
||||
toast(running ? "Abgebrochen." : "Aus der Warteschlange genommen.");
|
||||
if (running) {
|
||||
toast("Abgebrochen.");
|
||||
} else if (typeof undoToast === "function") {
|
||||
undoToast("Aus der Warteschlange genommen.", async () => {
|
||||
await api("/druck-queue/" + job.id, {
|
||||
method: "PATCH",
|
||||
json: { status: "waiting", priority: prev.priority, undo: true },
|
||||
});
|
||||
toast("Wieder eingereiht.");
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
});
|
||||
} else {
|
||||
toast("Aus der Warteschlange genommen.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message, "bad");
|
||||
}
|
||||
refreshPrintQueue();
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
if (button) button.disabled = false;
|
||||
};
|
||||
if (button) armButton(button, armed, go);
|
||||
if (running && button) armButton(button, job.is_ablauf ? "Ablauf stoppen?" : "Druck stoppen?", go);
|
||||
else go();
|
||||
}
|
||||
window.cancelQueueItem = cancelQueueItem;
|
||||
|
||||
function ablaufStepFrac(job) {
|
||||
if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return "";
|
||||
const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0;
|
||||
return (idx + 1) + "/" + job.step_labels.length;
|
||||
function setListHtml(el, html) {
|
||||
if (!el) return;
|
||||
if (el._html === html) return;
|
||||
el._html = html;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
function shortPrinter(name) {
|
||||
let text = String(name || "").replace(/^Kobra\s+(?:3\s+)?/i, "").trim() || String(name || "");
|
||||
return text.length > 12 ? text.slice(0, 10) + "…" : text;
|
||||
}
|
||||
|
||||
function openLivePop(section) {
|
||||
const pop = document.getElementById("live-pop");
|
||||
if (!pop) {
|
||||
const box = section ? document.querySelector(`[data-box="${section}"]`) : null;
|
||||
if (box) {
|
||||
box.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
location.href = section ? "/jetzt#" + encodeURIComponent(section) : "/jetzt";
|
||||
return;
|
||||
}
|
||||
pop.hidden = false;
|
||||
if (section) {
|
||||
const box = pop.querySelector(`[data-box="${section}"]`);
|
||||
if (box) box.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
function closeLivePop() {
|
||||
const pop = document.getElementById("live-pop");
|
||||
if (pop) pop.hidden = true;
|
||||
}
|
||||
|
||||
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, host }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
|
||||
const finish = remain > 0 ? finishClock(remain) : "";
|
||||
const bits = [name];
|
||||
@@ -305,8 +323,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
remain > 0 ? "noch " + hours(remain) : "",
|
||||
tipExtra ? "Schritt " + tipExtra : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
const href = host ? "/#" + encodeURIComponent(host) : "/";
|
||||
return `<li class="queue-item running run-line" style="--pct:${pct}">
|
||||
<a class="queue-link run-line-link" href="/warteschlange" title="${escapeHtml(tip || "Zur Warteschlange")}">
|
||||
<a class="queue-link run-line-link" href="${escapeHtml(href)}" title="${escapeHtml(tip || name || "Drucker")}">
|
||||
<span class="run-line-fill" aria-hidden="true"></span>
|
||||
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
|
||||
</a>
|
||||
@@ -314,233 +333,74 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function runItemHtml(job) {
|
||||
return runLineHtml({
|
||||
name: queueTarget(job),
|
||||
progress: job.progress,
|
||||
remain: job.remain,
|
||||
filename: job.filename,
|
||||
tipExtra: ablaufStepFrac(job),
|
||||
cancelId: job.id,
|
||||
});
|
||||
}
|
||||
|
||||
function waitItemHtml(job, index, total) {
|
||||
const isError = job.status === "error";
|
||||
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
|
||||
const rep = job.is_ablauf && (job.repeat_total || 1) > 1 ? job.repeat_total + "×" : "";
|
||||
return `<li class="queue-item ${isError ? "error" : ""}">
|
||||
function actionItemHtml(action) {
|
||||
const kind = action.kind || "";
|
||||
const cls = kind === "queue_error" || kind === "bedarf" || kind === "wartung" || kind === "auftrag" || kind === "filament_pause" ? "error" : "";
|
||||
let tools = "";
|
||||
if (kind === "clear" && action.host) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(action.host)}" title="Platte geräumt" aria-label="Platte geräumt">${icon("check")}</button></span>`;
|
||||
} else if (kind === "queue_error" && action.id) {
|
||||
if (action.is_ablauf) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn" data-resume-q="${action.id}" title="Fortsetzen">${icon("play")}</button>
|
||||
<button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
||||
} else {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
||||
}
|
||||
} else if (kind === "waiting" && action.id) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${action.id}" title="Entfernen">${icon("x")}</button></span>`;
|
||||
}
|
||||
const href = action.href || "/warteschlange";
|
||||
const name = action.printer || action.title || "";
|
||||
const detail = action.detail || action.filename || "";
|
||||
return `<li class="queue-item ${cls}">
|
||||
<div class="queue-row">
|
||||
<span class="grow"><span class="queue-name" title="${escapeHtml(job.filename)}">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-tools">
|
||||
${!isError && index > 0 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>` : ""}
|
||||
${!isError && index < total - 1 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>` : ""}
|
||||
${isError && job.is_ablauf ? `<button type="button" class="icon-btn" data-resume-q="${job.id}" title="Ab Schritt ${(Number(job.step_index) || 0) + 1} (${escapeHtml(job.step_label_current || "")}) weitermachen">${icon("play")}</button>` : ""}
|
||||
<button type="button" class="icon-btn danger" ${isError ? `data-dismiss-q="${job.id}" title="Ausblenden"` : `data-cancel-q="${job.id}" title="Entfernen"`}>${icon("x")}</button>
|
||||
</span>
|
||||
<a class="queue-link grow" href="${escapeHtml(href)}"><span class="queue-name">${escapeHtml(action.title || name)}</span></a>
|
||||
${tools}
|
||||
</div>
|
||||
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span>
|
||||
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
|
||||
<span class="queue-msg">${escapeHtml([name !== action.title ? name : "", detail].filter(Boolean).join(" · "))}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function directItemHtml(p) {
|
||||
return runLineHtml({
|
||||
name: p.name || p.host,
|
||||
progress: p.progress,
|
||||
remain: p.remain,
|
||||
filename: p.filename,
|
||||
});
|
||||
}
|
||||
|
||||
let queueCache = [];
|
||||
const lastLiveFilename = Object.create(null);
|
||||
|
||||
function setListHtml(el, html) {
|
||||
if (!el) return;
|
||||
if (el._html === html) return;
|
||||
el._html = html;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function stickyLiveFilename(host, filename, state) {
|
||||
const next = filename || "";
|
||||
if (next) {
|
||||
lastLiveFilename[host] = next;
|
||||
return next;
|
||||
}
|
||||
const prev = lastLiveFilename[host] || "";
|
||||
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
|
||||
return prev;
|
||||
}
|
||||
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
|
||||
delete lastLiveFilename[host];
|
||||
}
|
||||
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
|
||||
}
|
||||
|
||||
async function refreshPrintQueue() {
|
||||
let jobs;
|
||||
let active = [];
|
||||
try {
|
||||
[jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]);
|
||||
} catch (error) { return; }
|
||||
queueCache = jobs || [];
|
||||
const liveByHost = Object.create(null);
|
||||
(active || []).forEach((p) => { if (p.host) liveByHost[p.host] = p; });
|
||||
const running = queueCache.filter((job) => job.status === "running").map((job) => {
|
||||
const live = liveByHost[job.host];
|
||||
if (!live) return job;
|
||||
return {
|
||||
...job,
|
||||
progress: live.progress,
|
||||
remain: live.remain,
|
||||
filename: stickyLiveFilename(job.host, live.filename || job.filename, live.state) || job.filename,
|
||||
};
|
||||
});
|
||||
const queuedHosts = new Set(running.map((job) => job.host));
|
||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
|
||||
...p,
|
||||
filename: stickyLiveFilename(p.host, p.filename, p.state),
|
||||
}));
|
||||
const waiting = queueCache.filter((job) => job.status === "waiting");
|
||||
const errors = queueCache
|
||||
.filter((job) => job.status === "error")
|
||||
.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))
|
||||
.slice(0, 2);
|
||||
|
||||
const runBox = document.getElementById("run-box");
|
||||
runBox.hidden = !(running.length || direct.length);
|
||||
document.getElementById("run-count").textContent = running.length + direct.length;
|
||||
setListHtml(
|
||||
document.getElementById("run-list"),
|
||||
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
|
||||
);
|
||||
|
||||
const waitList = document.getElementById("wait-list");
|
||||
document.getElementById("wait-count").textContent = waiting.length;
|
||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
||||
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
||||
setListHtml(
|
||||
waitList,
|
||||
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
|
||||
);
|
||||
|
||||
const panel = document.getElementById("live-panel");
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const job = queueCache.find((item) => item.id === Number(btn.dataset.cancelQ));
|
||||
if (job) cancelQueueItem(job, btn);
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const job = queueCache.find((item) => item.id === Number(btn.dataset.resumeQ));
|
||||
if (!job) return;
|
||||
const step = job.step_label_current || ("Schritt " + ((job.step_index || 0) + 1));
|
||||
armButton(btn, "Weiter ab „" + step + "“?", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "running", resume: true } });
|
||||
toast("Ablauf geht weiter.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
|
||||
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-prio-q]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const id = Number(btn.dataset.prioQ);
|
||||
const dir = Number(btn.dataset.dir);
|
||||
const idx = waiting.findIndex((job) => job.id === id);
|
||||
const other = waiting[idx + dir];
|
||||
if (!other) return;
|
||||
try {
|
||||
await api("/druck-queue/" + id, { method: "PATCH", json: { priority: Number(other.priority) + dir } });
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSendQueue() {
|
||||
let jobs;
|
||||
try { jobs = await api("/sende-auftraege"); } catch (error) { return; }
|
||||
const active = (jobs || []).filter((job) => job.status === "pending" || job.status === "running");
|
||||
const recent = active.length ? active : (jobs || []).slice(0, 2).filter((job) => job.status === "error");
|
||||
const box = document.getElementById("send-box");
|
||||
box.hidden = !recent.length;
|
||||
document.getElementById("send-count").textContent = active.length;
|
||||
document.getElementById("send-list").innerHTML = recent.map((job) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
||||
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
|
||||
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
|
||||
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
|
||||
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
|
||||
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function refreshEvents() {
|
||||
let data;
|
||||
try { data = await api("/ereignisse?unread=1&limit=5"); } catch (error) { return; }
|
||||
const items = data.items || [];
|
||||
const unread = Number(data.unread != null ? data.unread : items.length);
|
||||
const box = document.getElementById("alert-box");
|
||||
box.hidden = !items.length;
|
||||
document.getElementById("alert-count").textContent = unread;
|
||||
document.getElementById("alert-list").innerHTML = items.map((item) => `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
|
||||
<span class="queue-msg">${escapeHtml(item.message)}</span>
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
async function refreshBedarf() {
|
||||
let data;
|
||||
try { data = await api("/bedarf"); } catch (error) { return; }
|
||||
const shortfall = data.shortfall || [];
|
||||
const box = document.getElementById("bedarf-box");
|
||||
function renderLiveChips({ actionItems, running, activeSends, alertCount, etaText }) {
|
||||
const box = document.getElementById("live-chips");
|
||||
if (!box) return;
|
||||
box.hidden = !shortfall.length;
|
||||
document.getElementById("bedarf-count").textContent = shortfall.length;
|
||||
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => {
|
||||
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament";
|
||||
return `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-sub">${grams(item.shortfall_g)} fehlen</span></div>
|
||||
<span class="queue-msg">${grams(item.weight_g)} Queue · ${grams(item.remaining_g)} Rest</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data);
|
||||
const bits = [];
|
||||
if (etaText) {
|
||||
bits.push(`<span class="live-eta">${escapeHtml(etaText)}</span>`);
|
||||
}
|
||||
if (actionItems.length) {
|
||||
bits.push(`<button type="button" class="live-chip todo" data-live-open="jetzt">${icon("sparkles")}<span>Jetzt</span><span class="count todo">${actionItems.length}</span></button>`);
|
||||
}
|
||||
running.forEach((p) => {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
||||
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
||||
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
|
||||
const label = [
|
||||
shortPrinter(p.name || p.host),
|
||||
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
|
||||
finish,
|
||||
].filter(Boolean).join(" · ");
|
||||
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
|
||||
bits.push(`<button type="button" class="live-chip run${pausedFil ? " warn" : ""}" data-live-open="run" style="--pct:${pct}" title="${escapeHtml(tip || p.name || "")}">
|
||||
<span class="live-chip-fill" aria-hidden="true"></span>
|
||||
<span class="live-chip-text">${escapeHtml(label)}</span>
|
||||
</button>`);
|
||||
});
|
||||
if (activeSends > 0) {
|
||||
bits.push(`<button type="button" class="live-chip" data-live-open="send">${icon("send")}<span>Senden</span><span class="count">${activeSends}</span></button>`);
|
||||
}
|
||||
if (alertCount > 0) {
|
||||
bits.push(`<button type="button" class="live-chip bad" data-live-open="alerts">${icon("bell")}<span>Meldungen</span><span class="count bad">${alertCount}</span></button>`);
|
||||
}
|
||||
setListHtml(box, bits.join(""));
|
||||
box.querySelectorAll("[data-live-open]").forEach((btn) => {
|
||||
btn.onclick = () => openLivePop(btn.dataset.liveOpen);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
let items;
|
||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||
items = items || [];
|
||||
document.getElementById("todo-box").hidden = !items.length;
|
||||
document.getElementById("todo-count").textContent = items.length;
|
||||
const list = document.getElementById("todo-list");
|
||||
list.innerHTML = items.map((p) => `<li class="queue-item">
|
||||
<div class="queue-row">
|
||||
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a>
|
||||
<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(p.host)}" title="Platte geräumt (zweimal klicken)" aria-label="Platte geräumt">${icon("check")}</button></span>
|
||||
</div>
|
||||
<span class="queue-msg">Platte räumen${p.state === "complete" ? " · Druck fertig" : ""}</span>
|
||||
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
|
||||
</li>`).join("");
|
||||
list.querySelectorAll("[data-cleared]").forEach((btn) => {
|
||||
function bindLivePanelActions(panel) {
|
||||
if (!panel) return;
|
||||
panel.querySelectorAll("[data-cleared]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const host = btn.dataset.cleared;
|
||||
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
|
||||
@@ -550,12 +410,146 @@ async function refreshTasks() {
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
|
||||
toast("Platte frei.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshTasks();
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
btn.disabled = false;
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
btn.onclick = () => cancelQueueItem({ id: Number(btn.dataset.cancelQ), status: "waiting" }, btn);
|
||||
});
|
||||
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Fortsetzen?", async () => {
|
||||
try {
|
||||
await api("/druck-queue/" + btn.dataset.resumeQ, { method: "PATCH", json: { status: "running", resume: true } });
|
||||
toast("Ablauf geht weiter.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
|
||||
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
|
||||
refreshJetzt();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshJetzt() {
|
||||
let data;
|
||||
try { data = await api("/jetzt"); } catch (error) { return; }
|
||||
// Wartende ohne Materialgrund nicht in Jetzt — nur echte Handlungen + Material-Wartegründe
|
||||
const actionItems = (data.actions || []).filter((a) => a.kind !== "waiting" || a.reason);
|
||||
const running = data.running || [];
|
||||
const eta = data.eta || {};
|
||||
const multiEta = running.filter((p) => Number(p.remain) > 0).length >= 2;
|
||||
const etaText = multiEta && eta.all_done_in_s > 0 ? "Alles fertig um " + finishClock(eta.all_done_in_s) : "";
|
||||
|
||||
const etaEl = document.getElementById("jetzt-eta");
|
||||
if (etaEl) {
|
||||
if (etaText) {
|
||||
etaEl.hidden = false;
|
||||
etaEl.textContent = etaText;
|
||||
} else {
|
||||
etaEl.hidden = true;
|
||||
etaEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
const jetztBox = document.getElementById("jetzt-box");
|
||||
const jetztCount = document.getElementById("jetzt-count");
|
||||
if (jetztCount) {
|
||||
jetztCount.textContent = actionItems.length;
|
||||
jetztCount.className = "count" + (actionItems.length ? " todo" : "");
|
||||
}
|
||||
if (jetztBox) {
|
||||
jetztBox.classList.toggle("is-empty", !actionItems.length);
|
||||
setListHtml(document.getElementById("jetzt-list"), actionItems.map(actionItemHtml).join(""));
|
||||
}
|
||||
|
||||
const runBox = document.getElementById("run-box");
|
||||
if (runBox) {
|
||||
runBox.hidden = !running.length;
|
||||
setListHtml(
|
||||
document.getElementById("run-list"),
|
||||
running.map((p) => runLineHtml({
|
||||
name: p.name || p.host,
|
||||
host: p.host,
|
||||
progress: p.progress,
|
||||
remain: p.remain,
|
||||
filename: p.filename,
|
||||
cancelId: p.queue_id,
|
||||
})).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
const sends = data.sends || [];
|
||||
const activeSends = sends.filter((j) => j.status === "pending" || j.status === "running");
|
||||
const sendBox = document.getElementById("send-box");
|
||||
if (sendBox) {
|
||||
sendBox.hidden = !activeSends.length && !sends.some((j) => j.status === "error");
|
||||
const sendCount = document.getElementById("send-count");
|
||||
if (sendCount) sendCount.textContent = activeSends.length;
|
||||
setListHtml(
|
||||
document.getElementById("send-list"),
|
||||
sends.map((job) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
||||
const cls = job.status === "error" ? "bad" : "";
|
||||
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
|
||||
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
|
||||
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
|
||||
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
|
||||
</li>`;
|
||||
}).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
const alerts = data.alerts || [];
|
||||
const alertCount = data.unread_alerts || alerts.length;
|
||||
const alertBox = document.getElementById("alert-box");
|
||||
if (alertBox) {
|
||||
alertBox.hidden = !alerts.length;
|
||||
const alertCountEl = document.getElementById("alert-count");
|
||||
if (alertCountEl) alertCountEl.textContent = alertCount;
|
||||
setListHtml(
|
||||
document.getElementById("alert-list"),
|
||||
alerts.map((item) => `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
|
||||
<span class="queue-msg">${escapeHtml(item.message)}</span>
|
||||
</li>`).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
renderLiveChips({
|
||||
actionItems,
|
||||
running,
|
||||
activeSends: activeSends.length,
|
||||
alertCount,
|
||||
etaText,
|
||||
});
|
||||
|
||||
const mobileBadge = document.getElementById("mobile-jetzt-badge");
|
||||
if (mobileBadge) {
|
||||
const n = actionItems.length || running.length || activeSends.length || alertCount;
|
||||
mobileBadge.hidden = !n;
|
||||
mobileBadge.textContent = String(actionItems.length || n);
|
||||
mobileBadge.classList.toggle("todo", !!actionItems.length);
|
||||
}
|
||||
|
||||
if (typeof window.onBedarfChanged === "function") {
|
||||
window.onBedarfChanged({ shortfall: Array(data.bedarf_shortfall || 0).fill({}) });
|
||||
}
|
||||
|
||||
bindLivePanelActions(document.getElementById("live-panel"));
|
||||
}
|
||||
|
||||
function refreshPrintQueue() { return refreshJetzt(); }
|
||||
function refreshSendQueue() { return refreshJetzt(); }
|
||||
function refreshEvents() { return refreshJetzt(); }
|
||||
function refreshTasks() { return refreshJetzt(); }
|
||||
function refreshBedarf() { return refreshJetzt(); }
|
||||
|
||||
(function mobileMoreSheet() {
|
||||
const sheet = document.getElementById("mobile-more-sheet");
|
||||
const btn = document.getElementById("mobile-more");
|
||||
@@ -563,7 +557,6 @@ async function refreshTasks() {
|
||||
btn.addEventListener("click", () => {
|
||||
if (typeof sheet.showModal === "function") sheet.showModal();
|
||||
});
|
||||
// Modal schließt und blockiert sonst die ganze App (Chrome).
|
||||
sheet.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[href]");
|
||||
if (!link) return;
|
||||
@@ -581,36 +574,43 @@ if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
|
||||
}
|
||||
|
||||
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||
document.getElementById("events-clear")?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
|
||||
refreshEvents();
|
||||
refreshJetzt();
|
||||
if (typeof window.onEventsRead === "function") window.onEventsRead();
|
||||
});
|
||||
|
||||
(function livePopClose() {
|
||||
const bar = document.getElementById("live-bar");
|
||||
const pop = document.getElementById("live-pop");
|
||||
if (!bar || !pop) return;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (pop.hidden) return;
|
||||
if (bar.contains(event.target)) return;
|
||||
closeLivePop();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeLivePop();
|
||||
});
|
||||
})();
|
||||
|
||||
window.refreshPrintQueue = refreshPrintQueue;
|
||||
window.refreshSendQueue = refreshSendQueue;
|
||||
window.refreshBedarf = refreshBedarf;
|
||||
refreshSendQueue();
|
||||
refreshPrintQueue();
|
||||
refreshEvents();
|
||||
refreshTasks();
|
||||
refreshBedarf();
|
||||
window.refreshTasks = refreshTasks;
|
||||
window.refreshJetzt = refreshJetzt;
|
||||
refreshJetzt();
|
||||
|
||||
(function liveUpdates() {
|
||||
let streamLive = false;
|
||||
const sseKinds = {
|
||||
queue: () => {
|
||||
refreshPrintQueue();
|
||||
refreshBedarf();
|
||||
},
|
||||
senden: () => refreshSendQueue(),
|
||||
ereignis: () => refreshEvents(),
|
||||
aufgaben: () => refreshTasks(),
|
||||
queue: () => refreshJetzt(),
|
||||
senden: () => refreshJetzt(),
|
||||
ereignis: () => refreshJetzt(),
|
||||
aufgaben: () => refreshJetzt(),
|
||||
drucker: () => {
|
||||
refreshPrintQueue();
|
||||
refreshTasks();
|
||||
refreshJetzt();
|
||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||
},
|
||||
};
|
||||
@@ -663,12 +663,7 @@ window.refreshTasks = refreshTasks;
|
||||
});
|
||||
}
|
||||
|
||||
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
|
||||
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
|
||||
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
|
||||
createPoller(whenStreamDown(refreshEvents), 12000).start();
|
||||
createPoller(whenStreamDown(refreshTasks), 12000).start();
|
||||
createPoller(whenStreamDown(refreshBedarf), 30000).start();
|
||||
createPoller(whenStreamDown(refreshJetzt), 8000).start();
|
||||
})();
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
<title>Anmelden · Grotti</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-card">
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti</h1>
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="44" height="44" decoding="async"><span>Grotti</span></h1>
|
||||
<figure class="auth-quote" id="auth-quote" hidden>
|
||||
<blockquote id="auth-quote-text"></blockquote>
|
||||
<figcaption id="auth-quote-by"></figcaption>
|
||||
|
||||
@@ -34,23 +34,27 @@
|
||||
</div>
|
||||
|
||||
<div id="view-spools">
|
||||
<div class="page-tools mat-tools">
|
||||
<div class="filter-bar" id="spool-filter" role="group" aria-label="Ort">
|
||||
<div class="toolbar page-tools mat-tools">
|
||||
<div class="fchips" id="spool-filter" role="group" aria-label="Ort">
|
||||
<button type="button" class="fchip on" data-filter="all">Alle</button>
|
||||
<button type="button" class="fchip" data-filter="loaded">Am Drucker</button>
|
||||
<button type="button" class="fchip" data-filter="shelf">Regal</button>
|
||||
<button type="button" class="fchip" data-filter="low">Wenig Rest</button>
|
||||
</div>
|
||||
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
|
||||
<button type="button" id="spool-new"><svg class="ico"><use href="#i-spool"/></svg>Neue Spule</button>
|
||||
<div class="search-wrap">
|
||||
<svg class="ico"><use href="#i-search"/></svg>
|
||||
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
|
||||
</div>
|
||||
<div class="tools-end"><button type="button" id="spool-new" class="btn-primary"><svg class="ico"><use href="#i-plus"/></svg>Neue Spule</button></div>
|
||||
</div>
|
||||
<p id="spool-list-note" class="tiny"></p>
|
||||
|
||||
<div class="split mat-split is-idle" id="spool-layout">
|
||||
<section class="card table-wrap mat-list">
|
||||
<div class="lib-head">
|
||||
<div class="card-head lib-head">
|
||||
<svg class="ico head-ico"><use href="#i-spool"/></svg>
|
||||
<h2>Spulen</h2>
|
||||
<span class="count" id="spool-count">0</span>
|
||||
<span class="count-pill" id="spool-count">0</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
||||
@@ -135,7 +139,7 @@
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
|
||||
<button type="button" class="ghost" id="spool-label" hidden>Etikett</button>
|
||||
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
@@ -213,7 +217,7 @@
|
||||
<input id="sorten-note" placeholder="optional">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="ghost" id="sorten-use">Spule damit anlegen</button>
|
||||
<button type="button" class="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Projekte</h1>
|
||||
<p class="tiny">Gruppiere Dateien und Läufe.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<section class="card">
|
||||
<h2>Liste</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th class="num">Dateien</th></tr></thead>
|
||||
<tbody id="project-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 id="project-title">Neu</h2>
|
||||
<form id="project-form">
|
||||
<input id="project-id" type="hidden">
|
||||
<div class="field"><label for="project-name">Name</label><input id="project-name" required></div>
|
||||
<div class="field"><label for="project-notes">Notiz</label><textarea id="project-notes"></textarea></div>
|
||||
<div class="field"><label for="project-status">Status</label>
|
||||
<select id="project-status">
|
||||
<option value="idee">Idee</option>
|
||||
<option value="bereit">Bereit</option>
|
||||
<option value="druckt">Druckt</option>
|
||||
<option value="fertig">Fertig</option>
|
||||
</select></div>
|
||||
<div class="actions">
|
||||
<button type="submit">Merken</button>
|
||||
<button type="button" class="ghost" id="project-new">Neu</button>
|
||||
<button type="button" class="warn" id="project-delete">Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="project-note"></p>
|
||||
<h2 class="section-gap">Kalkulation</h2>
|
||||
<label>Spule</label>
|
||||
<select id="quote-spool"></select>
|
||||
<div class="grid-2">
|
||||
<div><label>Teilegewicht g</label><input id="quote-weight" type="number" min="0" step="1" value="0"></div>
|
||||
<div><label>Stück</label><input id="quote-qty" type="number" min="1" step="1" value="1"></div>
|
||||
<div><label>Ausschuss %</label><input id="quote-waste" type="number" min="0" step="1" value="0"></div>
|
||||
<div><label>Auslastung %</label><input id="quote-util" type="number" min="1" max="100" step="1" value="50"></div>
|
||||
<div><label>Preis €</label><input id="quote-price" type="number" min="0" step="0.01" value="0"></div>
|
||||
<div>
|
||||
<label>Preis gilt</label>
|
||||
<select id="quote-mode">
|
||||
<option value="perPart">pro Stück</option>
|
||||
<option value="perKg">pro kg Teile</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button type="button" id="quote-go">Rechnen</button></div>
|
||||
<div class="nums" id="quote-result"></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const statusLabel = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
|
||||
let projects = [];
|
||||
let spools = [];
|
||||
|
||||
function fill(project) {
|
||||
document.getElementById("project-id").value = project ? project.id : "";
|
||||
document.getElementById("project-name").value = project ? project.name : "";
|
||||
document.getElementById("project-notes").value = project ? project.notes : "";
|
||||
document.getElementById("project-status").value = project ? project.status : "idee";
|
||||
document.getElementById("project-title").textContent = project ? project.name : "Neu";
|
||||
document.getElementById("project-delete").hidden = !project;
|
||||
const quote = project && project.quote && project.quote.inputs;
|
||||
if (quote) {
|
||||
document.getElementById("quote-weight").value = quote.weight_g;
|
||||
document.getElementById("quote-qty").value = quote.qty;
|
||||
document.getElementById("quote-waste").value = quote.waste_percent;
|
||||
document.getElementById("quote-util").value = quote.utilization;
|
||||
document.getElementById("quote-price").value = quote.price;
|
||||
document.getElementById("quote-mode").value = quote.price_mode || "perPart";
|
||||
}
|
||||
if (project && project.result) paint(project.result);
|
||||
else if (project && project.quote && project.quote.result) paint(project.quote.result);
|
||||
else document.getElementById("quote-result").innerHTML = "";
|
||||
}
|
||||
|
||||
function paint(result) {
|
||||
document.getElementById("quote-result").innerHTML = `
|
||||
<div class="num"><span>bleibt gesamt</span><b>${euro(result.margin)}</b></div>
|
||||
<div class="num"><span>bleibt pro Stunde</span><b>${euro(result.per_hour)}</b></div>
|
||||
<div class="num"><span>Filament</span><b>${euro(result.filament_cost)}</b></div>
|
||||
<div class="num"><span>Strom</span><b>${euro(result.energy_cost)}</b></div>
|
||||
<div class="num"><span>Zeit</span><b>${hours(result.hours * 3600) || "0 min"}</b></div>
|
||||
<div class="num"><span>Filamentmenge</span><b>${grams(result.filament_g)}</b></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const body = document.getElementById("project-rows");
|
||||
body.innerHTML = "";
|
||||
if (!projects.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="3"><div class="empty-state">' + icon("folder") + '<p>Noch kein Projekt.</p></div></td></tr>';
|
||||
}
|
||||
projects.forEach((project) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = project.name;
|
||||
tr.children[1].textContent = statusLabel[project.status] || project.status;
|
||||
tr.children[2].textContent = project.file_count || 0;
|
||||
tr.addEventListener("click", async () => {
|
||||
const full = await api("/projekte/" + project.id);
|
||||
fill(full);
|
||||
if (full.files && full.files[0] && full.files[0].weight_g && !full.quote.inputs) {
|
||||
document.getElementById("quote-weight").value = full.files[0].weight_g;
|
||||
}
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
const select = document.getElementById("quote-spool");
|
||||
select.innerHTML = '<option value="">ohne Spule</option>';
|
||||
spools.forEach((spool) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = spool.id;
|
||||
option.textContent = spool.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[projects, spools] = await Promise.all([api("/projekte"), api("/spulen")]);
|
||||
const settings = await api("/einstellungen");
|
||||
document.getElementById("quote-waste").value = settings.energy.waste_percent;
|
||||
document.getElementById("quote-util").value = settings.energy.utilization;
|
||||
render();
|
||||
fill(null);
|
||||
}
|
||||
|
||||
document.getElementById("project-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("project-id").value;
|
||||
const payload = {
|
||||
name: document.getElementById("project-name").value,
|
||||
notes: document.getElementById("project-notes").value,
|
||||
status: document.getElementById("project-status").value,
|
||||
};
|
||||
try {
|
||||
const saved = id
|
||||
? await api("/projekte/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/projekte", { method: "POST", json: payload });
|
||||
showNote("project-note", null);
|
||||
document.getElementById("project-note").textContent = "Gemerkt.";
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("project-new").addEventListener("click", () => fill(null));
|
||||
document.getElementById("project-delete").addEventListener("click", (event) => {
|
||||
const id = document.getElementById("project-id").value;
|
||||
if (!id) return;
|
||||
armButton(event.currentTarget, "Dateien bleiben — löschen?", async () => {
|
||||
try {
|
||||
await api("/projekte/" + id, { method: "DELETE" });
|
||||
toast("Projekt gelöscht.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById("quote-go").addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = await api("/kalkulation", {
|
||||
method: "POST",
|
||||
json: {
|
||||
project_id: document.getElementById("project-id").value || null,
|
||||
spool_id: document.getElementById("quote-spool").value || null,
|
||||
weight_g: Number(document.getElementById("quote-weight").value),
|
||||
qty: Number(document.getElementById("quote-qty").value),
|
||||
waste_percent: Number(document.getElementById("quote-waste").value),
|
||||
utilization: Number(document.getElementById("quote-util").value),
|
||||
price: Number(document.getElementById("quote-price").value),
|
||||
price_mode: document.getElementById("quote-mode").value,
|
||||
},
|
||||
});
|
||||
paint(payload.result);
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
}
|
||||
});
|
||||
load().catch((error) => showNote("project-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,7 @@
|
||||
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-tools">
|
||||
<div class="toolbar page-tools">
|
||||
<select id="run-filter-printer" title="Drucker">
|
||||
<option value="">Alle Drucker</option>
|
||||
</select>
|
||||
@@ -86,16 +86,24 @@ function render() {
|
||||
const fileCell = tr.children[1];
|
||||
const link = document.createElement("a");
|
||||
link.href = "/protokoll/" + run.id;
|
||||
link.className = "cell-file";
|
||||
link.title = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
fileCell.appendChild(link);
|
||||
if (run.chargen_nr) {
|
||||
const code = document.createElement("div");
|
||||
code.className = "tiny";
|
||||
code.className = "mono-badge";
|
||||
code.textContent = run.chargen_nr;
|
||||
fileCell.appendChild(code);
|
||||
}
|
||||
tr.children[2].textContent = states[run.state] || run.state;
|
||||
const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing"
|
||||
: run.state === "completed" ? "complete"
|
||||
: run.state === "error" ? "error"
|
||||
: run.state === "cancelled" || run.state === "interrupted" ? "cancelled"
|
||||
: "idle";
|
||||
tr.children[2].innerHTML = chipHtml(stateKey, states[run.state] || run.state);
|
||||
tr.children[3].textContent = hours(run.duration_s);
|
||||
tr.children[3].className = "num";
|
||||
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
||||
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
||||
tr.children[4].textContent = used ? grams(used) : "";
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
<title>Setup · Grotti</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-card">
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti einrichten</h1>
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="44" height="44" decoding="async"><span>Grotti einrichten</span></h1>
|
||||
<p class="tiny">Lege den ersten Administrator an. Danach ist Login Pflicht.</p>
|
||||
<form id="setup-form" class="stack">
|
||||
<div class="field">
|
||||
|
||||
@@ -9,30 +9,30 @@
|
||||
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-tools">
|
||||
<div class="segmented period-presets" id="period" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-period="all" class="on">Alles</button>
|
||||
<button type="button" data-period="day">Tag</button>
|
||||
<button type="button" data-period="week">Woche</button>
|
||||
<button type="button" data-period="month">Monat</button>
|
||||
<button type="button" data-period="yesterday">Gestern</button>
|
||||
<button type="button" data-period="last_week">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month">Letzter Monat</button>
|
||||
<div class="toolbar page-tools">
|
||||
<div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-period="all" class="fchip on">Alles</button>
|
||||
<button type="button" data-period="day" class="fchip">Tag</button>
|
||||
<button type="button" data-period="week" class="fchip">Woche</button>
|
||||
<button type="button" data-period="month" class="fchip">Monat</button>
|
||||
<button type="button" data-period="yesterday" class="fchip">Gestern</button>
|
||||
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
|
||||
</div>
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="10">
|
||||
<input type="text" id="brand" placeholder="Marke" size="10">
|
||||
<button type="button" id="reload">Aktualisieren</button>
|
||||
<div class="tools-end"><button type="button" id="reload" class="ghost"><svg class="ico"><use href="#i-refresh"/></svg>Aktualisieren</button></div>
|
||||
</div>
|
||||
<p id="stat-note"></p>
|
||||
<div id="stat" class="statistik"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
function kpi(label, value, hint) {
|
||||
return `<div class="kpi"><div class="kpi-val">${value}</div><div class="kpi-label">${escapeHtml(label)}</div>${hint ? '<div class="tiny">' + escapeHtml(hint) + "</div>" : ""}</div>`;
|
||||
function kpi(label, value, hint, ico) {
|
||||
return `<div class="kpi"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
|
||||
}
|
||||
|
||||
function isoDate(date) {
|
||||
@@ -181,13 +181,15 @@ function render(data) {
|
||||
|
||||
document.getElementById("stat").innerHTML = `
|
||||
<div class="kpi-row">
|
||||
${kpi("Läufe", String(jobs))}
|
||||
${kpi("Druckzeit", hours(o.duration_s))}
|
||||
${kpi("Verbrauch", grams(o.weight_g))}
|
||||
${kpi("Kosten", euro(o.cost))}
|
||||
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen")}
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
||||
${kpi("Läufe", String(jobs), "", "layers")}
|
||||
${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
|
||||
${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
|
||||
${kpi("Kosten", euro(o.cost), "", "euro")}
|
||||
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban")}
|
||||
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent")}
|
||||
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge")}
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
|
||||
+83
-47
@@ -15,7 +15,7 @@
|
||||
<section class="desk no-cam" id="desk" hidden data-host="">
|
||||
<header class="desk-bar">
|
||||
<div class="desk-bar-top">
|
||||
<button type="button" class="ghost desk-back" id="desk-back">← Drucker</button>
|
||||
<button type="button" class="ghost desk-back" id="desk-back"><svg class="ico"><use href="#i-chevron-left"/></svg>Drucker</button>
|
||||
<div class="desk-title">
|
||||
<h2 data-role="name"></h2>
|
||||
<span data-role="badge"></span>
|
||||
@@ -27,7 +27,7 @@
|
||||
</header>
|
||||
<div class="desk-stage">
|
||||
<details class="desk-fold desk-cam-fold" data-fold="cam" open>
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Kamera</span><span class="tiny" data-role="cam-sum"></span></summary>
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-camera"/></svg><span class="grow">Kamera</span><span class="tiny" data-role="cam-sum"></span></summary>
|
||||
<div class="desk-cam" data-role="cam-stage">
|
||||
<img class="desk-cam-img" data-role="camera" alt="Kamera" hidden>
|
||||
<p class="desk-cam-empty" data-role="cam-empty"><svg class="ico"><use href="#i-camera"/></svg><span>Keine Kamera</span></p>
|
||||
@@ -49,45 +49,51 @@
|
||||
<div class="desk-facts" data-role="facts"></div>
|
||||
<div data-role="flow"></div>
|
||||
<div data-role="strips"></div>
|
||||
<details class="desk-fold desk-steer-fold" data-fold="steer">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Steuerung</span></summary>
|
||||
<div data-role="steer"></div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="desk-foot">
|
||||
<div class="card-actions" data-role="controls"></div>
|
||||
<button type="button" class="warn" data-role="estop" data-steer-go="emergency_stop" hidden>E-Stop</button>
|
||||
<button type="button" class="btn-danger" data-role="estop" data-steer-go="emergency_stop" hidden>E-Stop</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="desk-tools">
|
||||
<details class="desk-fold desk-tool" data-fold="verlauf" open>
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
|
||||
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
|
||||
<p class="tiny">Lade Verlauf …</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="desk-fold desk-tool" data-fold="path">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
|
||||
<div class="desk-fold-body">
|
||||
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
|
||||
<canvas class="gcode-canvas desk-canvas" data-role="path-canvas"></canvas>
|
||||
<div class="exclude-panel" data-role="exclude-panel" hidden>
|
||||
<div class="exclude-head">
|
||||
<span class="tiny grow">Objekte ausschließen</span>
|
||||
<span class="tiny" data-role="exclude-hint">Tippen wählt · zwei Klicks schließen aus</span>
|
||||
<details class="desk-fold desk-technik" data-fold="technik">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-sliders"/></svg><span class="grow">Technik</span><span class="tiny">Steuerung, Verlauf, 3D, Bettnetz</span></summary>
|
||||
<div class="desk-technik-body">
|
||||
<div data-role="tech-facts" class="desk-facts desk-tech-facts"></div>
|
||||
<details class="desk-fold desk-steer-fold" data-fold="steer" open>
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-gauge"/></svg><span class="grow">Steuerung</span></summary>
|
||||
<div data-role="steer"></div>
|
||||
</details>
|
||||
<div class="desk-tools">
|
||||
<details class="desk-fold desk-tool" data-fold="verlauf" open>
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-activity"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
|
||||
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
|
||||
<p class="tiny">Lade Verlauf …</p>
|
||||
</div>
|
||||
<div class="exclude-list" data-role="exclude-list"></div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="desk-fold desk-tool" data-fold="path">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-cube"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
|
||||
<div class="desk-fold-body">
|
||||
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
|
||||
<canvas class="gcode-canvas desk-canvas" data-role="path-canvas"></canvas>
|
||||
<div class="exclude-panel" data-role="exclude-panel" hidden>
|
||||
<div class="exclude-head">
|
||||
<span class="tiny grow">Objekte ausschließen</span>
|
||||
<span class="tiny" data-role="exclude-hint">Tippen wählt · zwei Klicks schließen aus</span>
|
||||
</div>
|
||||
<div class="exclude-list" data-role="exclude-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-grid"/></svg><span class="grow">Bettnetz</span></summary>
|
||||
<div class="desk-fold-body" data-role="mesh">
|
||||
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bettnetz</span></summary>
|
||||
<div class="desk-fold-body" data-role="mesh">
|
||||
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
@@ -143,7 +149,7 @@ function syncDeskCamCollapsed() {
|
||||
function restoreDeskFolds() {
|
||||
let closed;
|
||||
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; }
|
||||
if (!Array.isArray(closed)) closed = ["steer", "path", "mesh"];
|
||||
if (!Array.isArray(closed)) closed = ["technik", "path", "mesh"];
|
||||
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
|
||||
box.open = !closed.includes(box.dataset.fold);
|
||||
box.addEventListener("toggle", () => {
|
||||
@@ -176,7 +182,11 @@ function setDeskCameraMode(live, note) {
|
||||
}
|
||||
syncDeskCamCollapsed();
|
||||
const sum = deskEl.querySelector('[data-role="cam-sum"]');
|
||||
if (sum) sum.textContent = deskCamLive ? "live" : (note || "keine");
|
||||
if (sum) {
|
||||
if (!deskCamLive) sum.textContent = note || "keine";
|
||||
else if (note === "stream") sum.textContent = "stream";
|
||||
else sum.textContent = "live";
|
||||
}
|
||||
}
|
||||
|
||||
function heatColor(value, low, high) {
|
||||
@@ -501,7 +511,9 @@ function heroHtml(printer, state) {
|
||||
}
|
||||
if (state === "complete") {
|
||||
const took = hours(printer.elapsed);
|
||||
return `<div class="hero idle"><span class="hero-main">fertig</span>${took ? `<span class="hero-side">nach ${escapeHtml(took)}</span>` : ""}</div>`;
|
||||
const last = jobLabel(printer.filename || "");
|
||||
const side = [last ? "zuletzt " + last : "", took].filter(Boolean).join(", ");
|
||||
return `<div class="hero idle"><span class="hero-main">bereit</span>${side ? `<span class="hero-side">${escapeHtml(side)}</span>` : ""}</div>`;
|
||||
}
|
||||
if (state === "cancelled") {
|
||||
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
|
||||
@@ -539,10 +551,6 @@ function patchBar(box, printer, state) {
|
||||
}
|
||||
|
||||
function factsHtml(printer) {
|
||||
const part = fanPct(printer, "part") || fanPct(printer);
|
||||
const chamberFan = fanPct(printer, "chamber");
|
||||
const auxFan = fanPct(printer, "aux");
|
||||
const speed = speedPct(printer);
|
||||
const loaded = wallSpools.filter((s) => s.printer_host === printer.host);
|
||||
const spoolLine = loaded.length
|
||||
? "<span>Spule <b>" + escapeHtml(loaded.map((s) => (s.ace_slot ? s.ace_slot + ": " : "") + s.name + " (" + grams(s.remaining_g) + ")").join(", ")) + "</b></span>"
|
||||
@@ -552,13 +560,22 @@ function factsHtml(printer) {
|
||||
${spoolLine}
|
||||
${printer.material ? "<span>Material <b>" + escapeHtml(String(printer.material).split(";")[0]) + "</b></span>" : ""}
|
||||
${printer.weight ? "<span>Gewicht <b>" + grams(printer.weight) + "</b></span>" : ""}
|
||||
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function techFactsHtml(printer) {
|
||||
const part = fanPct(printer, "part") || fanPct(printer);
|
||||
const chamberFan = fanPct(printer, "chamber");
|
||||
const auxFan = fanPct(printer, "aux");
|
||||
const speed = speedPct(printer);
|
||||
return `
|
||||
${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""}
|
||||
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
|
||||
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
|
||||
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
||||
${anycubicTaskLine(printer)}
|
||||
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
||||
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -774,19 +791,19 @@ function refreshDeskCamera(printer) {
|
||||
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
|
||||
// ganze Bühne inkl. 3D-Viewer alle paar Sekunden.
|
||||
img.alt = "Kamera " + (printer.name || printer.host);
|
||||
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
|
||||
const streaming = typeof cameraMode === "function" && cameraMode() === "stream";
|
||||
const next = typeof cameraUrl === "function" ? cameraUrl(printer, streaming ? 0 : 3000) : (withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000));
|
||||
if (img.dataset.srcKey === next) return;
|
||||
img.dataset.srcKey = next;
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const onOk = () => {
|
||||
if (img.dataset.srcKey !== next) return;
|
||||
deskCamFails = 0;
|
||||
img.src = next;
|
||||
img.hidden = false;
|
||||
empty.hidden = true;
|
||||
setDeskCameraMode(true, "live");
|
||||
setDeskCameraMode(true, streaming ? "stream" : "live");
|
||||
};
|
||||
probe.onerror = () => {
|
||||
const onFail = () => {
|
||||
if (img.dataset.srcKey !== next) return;
|
||||
deskCamFails += 1;
|
||||
if (deskCamFails >= 2) {
|
||||
@@ -803,8 +820,17 @@ function refreshDeskCamera(printer) {
|
||||
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
|
||||
}
|
||||
const sum = deskEl.querySelector('[data-role="cam-sum"]');
|
||||
if (sum && deskCamLive) sum.textContent = "live · kurz weg";
|
||||
if (sum && deskCamLive) sum.textContent = streaming ? "stream · kurz weg" : "live · kurz weg";
|
||||
};
|
||||
if (streaming) {
|
||||
img.onload = onOk;
|
||||
img.onerror = onFail;
|
||||
img.src = next;
|
||||
return;
|
||||
}
|
||||
const probe = new Image();
|
||||
probe.onload = onOk;
|
||||
probe.onerror = onFail;
|
||||
probe.src = next;
|
||||
}
|
||||
|
||||
@@ -816,9 +842,18 @@ function startCameraTimer() {
|
||||
if (printer) refreshDeskCamera(printer);
|
||||
};
|
||||
tick();
|
||||
// Stream braucht keinen Poll — MJPEG läuft im <img>.
|
||||
if (typeof cameraMode === "function" && cameraMode() === "stream") return;
|
||||
cameraTimer = setInterval(tick, 3000);
|
||||
}
|
||||
|
||||
window.addEventListener("grotti-camera-mode", () => {
|
||||
if (!deskHost) return;
|
||||
const img = deskEl.querySelector('[data-role="camera"]');
|
||||
if (img) img.dataset.srcKey = "";
|
||||
startCameraTimer();
|
||||
});
|
||||
|
||||
function stopCameraTimer() {
|
||||
if (cameraTimer) {
|
||||
clearInterval(cameraTimer);
|
||||
@@ -890,6 +925,7 @@ function patchDesk(printer) {
|
||||
}
|
||||
setHtml(role("swatches"), swatchesHtml(printer));
|
||||
setHtml(role("facts"), factsHtml(printer));
|
||||
setHtml(role("tech-facts"), techFactsHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
|
||||
@@ -3,21 +3,23 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Warteschlange</h1>
|
||||
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
||||
<p class="tiny">Wartende und laufende Drucke. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<a class="ghost" id="q-bedarf" href="/material#bedarf" hidden></a>
|
||||
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="q-filter" role="group" aria-label="Status">
|
||||
<button type="button" class="fchip on" data-status="">Alle aktiv</button>
|
||||
<button type="button" class="fchip" data-status="running">Läuft</button>
|
||||
<button type="button" class="fchip" data-status="waiting">Wartet</button>
|
||||
<button type="button" class="fchip" data-status="error">Fehler</button>
|
||||
<button type="button" class="fchip" data-status="history">Historie</button>
|
||||
<div class="toolbar" id="q-filter" role="group" aria-label="Status">
|
||||
<div class="fchips">
|
||||
<button type="button" class="fchip on" data-status="">Alle aktiv</button>
|
||||
<button type="button" class="fchip" data-status="running"><svg class="ico"><use href="#i-play"/></svg>Läuft</button>
|
||||
<button type="button" class="fchip" data-status="waiting"><svg class="ico"><use href="#i-clock"/></svg>Wartet</button>
|
||||
<button type="button" class="fchip" data-status="error"><svg class="ico"><use href="#i-alert"/></svg>Fehler</button>
|
||||
<button type="button" class="fchip" data-status="history"><svg class="ico"><use href="#i-history"/></svg>Historie</button>
|
||||
</div>
|
||||
<div class="tools-end"><span id="q-note" class="tiny"></span></div>
|
||||
</div>
|
||||
<p id="q-note" class="tiny"></p>
|
||||
<div class="card table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -87,8 +89,13 @@ function render() {
|
||||
if (!shown.length) {
|
||||
const hint = jobs.length
|
||||
? "Nichts in dieser Ansicht."
|
||||
: (liveDirect.length ? "Keine Queue-Aufträge — laufende Direktstarts unter „Läuft“." : "Noch kein Eintrag.");
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p></div></td></tr>';
|
||||
: (liveDirect.length
|
||||
? "Keine Einträge in der Warteschlange — laufende Direktstarts unter „Läuft“."
|
||||
: "Noch kein Eintrag.");
|
||||
const cta = (!jobs.length && !liveDirect.length)
|
||||
? '<p><a class="btn-primary" href="/dateien">' + icon("send") + ' Datei senden</a></p>'
|
||||
: "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
|
||||
document.getElementById("q-note").textContent = "";
|
||||
return;
|
||||
}
|
||||
@@ -98,11 +105,11 @@ function render() {
|
||||
if (job.direct) {
|
||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||
<td>${chipHtml("printing", "läuft")}</td>
|
||||
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
|
||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
|
||||
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
|
||||
<td>—</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
<td class="queue-tools"><a class="icon-btn" href="/#${encodeURIComponent(job.host)}" title="Zur Wand">${icon("printer")}</a></td>
|
||||
<td class="actions-cell"><a class="icon-btn" href="/#${encodeURIComponent(job.host)}" title="Zur Wand" aria-label="Zur Wand">${icon("printer")}</a></td>
|
||||
</tr>`;
|
||||
}
|
||||
const waitIdx = waiting.findIndex((j) => j.id === job.id);
|
||||
@@ -123,13 +130,14 @@ function render() {
|
||||
} else if (st === "waiting" || st === "running") {
|
||||
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
|
||||
}
|
||||
const chipState = st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
|
||||
return `<tr data-id="${job.id}">
|
||||
<td>${chipHtml(st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : "complete", STATUS_LABEL[st] || st)}</td>
|
||||
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</td>
|
||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
|
||||
<td>${steps || "—"}</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
<td class="queue-tools">${tools.join("")}</td>
|
||||
<td class="actions-cell">${tools.join("")}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
||||
|
||||
Reference in new issue
Block a user