const TOKEN = ""; // Legacy: Auth über Session-Cookie oder Bearer von außen const CSRF = document.querySelector('meta[name="csrf"]')?.content || ""; const STATE_LABELS = { printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit", heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline", waiting: "wartet", running: "läuft", }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN); const method = (options.method || "GET").toUpperCase(); if (CSRF && method !== "GET" && method !== "HEAD") { headers.set("X-CSRF-Token", CSRF); } let body = options.body; if (options.json !== undefined) { headers.set("Content-Type", "application/json"); body = JSON.stringify(options.json); } const response = await fetch("/api/v1" + path, { method, headers, body, cache: "no-store", credentials: "same-origin", }); if (response.status === 401) { const here = location.pathname + location.search; if (!here.startsWith("/login") && !here.startsWith("/setup")) { location.href = "/login?next=" + encodeURIComponent(here); } throw new Error("Anmeldung nötig."); } if (!response.ok) { let message = response.statusText; try { const payload = await response.json(); message = payload.error || message; } catch (error) { message = response.statusText; } throw new Error(message); } if (response.status === 204) return null; const type = response.headers.get("content-type") || ""; if (type.includes("json")) return response.json(); return response; } function withToken(url) { // Session-Cookie reicht für same-origin Kameras; kein Token in der URL. return url; } function euro(value) { return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" }); } function grams(value) { return Number(value || 0).toLocaleString("de-DE", { maximumFractionDigits: 0 }) + " g"; } function hours(seconds) { seconds = Math.round(Number(seconds) || 0); if (seconds <= 0) return ""; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); return h > 0 ? h + " h " + String(m).padStart(2, "0") + " min" : m + " min"; } function finishClock(remainSeconds) { const sec = Number(remainSeconds) || 0; if (sec <= 0) return ""; const at = new Date(Date.now() + sec * 1000); return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); } function clockOf(iso) { if (!iso) return ""; const at = new Date(iso); if (Number.isNaN(at.getTime())) return ""; return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone() }); } const TZ_KEY = "grotti-timezone"; function appTimeZone() { try { const value = localStorage.getItem(TZ_KEY); if (value && value !== "auto") return value; } catch (error) { /* Browser */ } return undefined; } function formatDateTime(iso, opts) { if (!iso) return "—"; const at = new Date(iso); if (Number.isNaN(at.getTime())) return "—"; const options = { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: appTimeZone(), ...(opts || {}), }; return at.toLocaleString("de-DE", options); } function formatDateRange(startIso, endIso) { const start = startIso ? new Date(startIso) : null; const end = endIso ? new Date(endIso) : null; if (start && Number.isNaN(start.getTime())) return "—"; if (!start && !end) return "—"; if (start && !end) return formatDateTime(startIso) + " → …"; if (!start && end) return "… → " + formatDateTime(endIso); const sameDay = start.toDateString() === end.toDateString(); const left = formatDateTime(startIso); if (sameDay) { const right = end.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone(), }); return left + " – " + right; } return left + " – " + formatDateTime(endIso); } function tempChartHtml(samples) { if (!samples || samples.length < 2) { return '

Noch zu wenig Proben für den Verlauf.

'; } const times = samples.map((s) => new Date(s.sampled_at).getTime()); const t0 = times[0]; const t1 = times[times.length - 1]; const span = Math.max(1, t1 - t0); const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null)); const minT = Math.min(...temps, 0); const maxT = Math.max(...temps, 100); const hasEd = samples.some((s) => s.nozzle_power != null || s.bed_power != null); const w = 640; const h = 210; const padL = 44; const padR = hasEd ? 44 : 28; const padT = 22; const padB = 30; const x = (ms) => padL + ((ms - t0) / span) * (w - padL - padR); const yT = (v) => padT + (1 - (Number(v) - minT) / Math.max(1, maxT - minT)) * (h - padT - padB); const yEd = (v) => padT + (1 - Math.max(0, Math.min(1, Number(v) || 0))) * (h - padT - padB); function poly(key, color, dashed, scaleEd) { const pts = []; samples.forEach((s, i) => { const v = s[key]; if (v == null) return; const yy = scaleEd ? yEd(Number(v)) : yT(v); pts.push(`${x(times[i]).toFixed(1)},${yy.toFixed(1)}`); }); if (pts.length < 2) return ""; const dash = dashed ? ' stroke-dasharray="5 4"' : ""; return ``; } const tickCount = Math.min(5, samples.length); const timeTicks = []; for (let i = 0; i < tickCount; i++) { const ms = t0 + (span * i) / Math.max(1, tickCount - 1); const label = new Date(ms).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone(), }); timeTicks.push(`${escapeHtml(label)}`); } const midY = (padT + h - padB) / 2; return ` ${poly("nozzle_target", "#fdba74", true, false)} ${poly("bed_target", "#93c5fd", true, false)} ${poly("nozzle", "#ea580c", false, false)} ${poly("bed", "#2563eb", false, false)} ${poly("chamber", "#16a34a", false, false)} ${hasEd ? poly("nozzle_power", "#c084fc", false, true) : ""} ${hasEd ? poly("bed_power", "#22d3ee", false, true) : ""} Temp °C ${Math.round(maxT)} ${Math.round(minT)} ${hasEd ? `ED %` : ""} ${hasEd ? `100` : ""} ${hasEd ? `0` : ""} ${timeTicks.join("")}

Düse Bett Kammer ${hasEd ? 'ED DüseED Bett' : ""} · links °C · rechts % · gestrichelt ≈ Soll

`; } function showNote(id, error, ok) { const node = document.getElementById(id); if (node) { if (error) { node.textContent = error.message || String(error); node.className = "msg"; } else { node.textContent = ok || ""; node.className = ok ? "ok" : "ok"; } } if (error) toast(error.message || String(error), "bad"); else if (ok) toast(ok, "good"); } function icon(name, cls) { return ``; } function chipHtml(state, label, small) { const text = label || STATE_LABELS[state] || state || ""; return `${escapeHtml(text)}`; } function shownState(printer) { const state = (printer && printer.state) || "offline"; const hot = ((printer && printer.nozzle_target) || 0) > 40 || ((printer && printer.bed_target) || 0) > 30; if ((state === "standby" || state === "complete") && hot) return "heating"; return state; } function stepsHtml(labels, index, compact) { if (!labels || !labels.length) return ""; const parts = labels.map((label, i) => { const cls = i < index ? "done" : i === index ? "now" : ""; return `${escapeHtml(label)}`; }); return `${parts.join('')}`; } function repeatText(job) { const total = Math.max(1, Number(job.repeat_total) || 1); if (total <= 1) return ""; return Math.min(total, (Number(job.repeat_done) || 0) + 1) + "/" + total; } function phaseText(job) { const message = (job && job.message) || ""; if (!job || !job.is_ablauf || job.status !== "running") return message; const parts = message.split(" · "); return parts.length > 1 ? parts.slice(-1)[0] : message; } function jobLabel(filename) { return (filename || "").replace(/\.gcode(\.3mf)?$/i, "").replace(/\.3mf$/i, "").replace(/^(\d+-)+/, ""); } function toast(message, kind) { 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 text = String(message ?? ""); host.querySelectorAll(".toast").forEach((node) => { const body = node.querySelector("div"); if (body && body.textContent === text) node.remove(); }); while (host.children.length >= 3) host.firstElementChild?.remove(); const node = document.createElement("div"); node.className = "toast " + (kind || "good"); node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "
"; node.querySelector("div").textContent = text; host.appendChild(node); const ttl = kind === "bad" ? 7000 : 4000; setTimeout(() => { node.classList.add("out"); setTimeout(() => node.remove(), 260); }, ttl); } function armButton(button, armedText, onConfirm, timeoutMs) { if (button.dataset.armed === "1") { clearTimeout(Number(button.dataset.armTimer || 0)); button.dataset.armed = ""; button.classList.remove("confirm-armed"); if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml; onConfirm(); return; } button.dataset.idleHtml = button.innerHTML; button.dataset.armed = "1"; button.classList.add("confirm-armed"); button.textContent = armedText; const timer = setTimeout(() => { button.dataset.armed = ""; button.classList.remove("confirm-armed"); if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml; }, timeoutMs || 5000); button.dataset.armTimer = String(timer); }