Files
grotti/web/templates/dateien.html
T
Dominic Gundel b277ca48aa Grotti 2.2.6: Wand-Vorschau, dichterer 3D-Pfad, ASCII-Dateinamen.
Kacheln zeigen Moonraker-Thumbnails; Toolpath-Thinning behält ganze Schichten; Nicht-ASCII-Namen vermeiden 10115 beim Senden.
2026-10-17 11:22:43 +02:00

1307 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Dateien</h1>
<p class="tiny" id="watch"></p>
</div>
<div class="page-head-side">
<select id="upload-project" title="Projekt für Upload" style="max-width:10rem">
<option value="">kein Projekt</option>
</select>
<label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden>
<button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button>
</div>
</div>
<div class="send-layout">
<section class="card lib-card" id="lib-card">
<div class="lib-head">
<h2>Bibliothek</h2>
<span class="count" id="lib-count">0</span>
</div>
<div class="lib-tools">
<input id="file-search" type="search" placeholder="Datei suchen…" autocomplete="off">
<select id="file-filter-family">
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
</select>
<select id="file-filter-project">
<option value="">Alle Projekte</option>
</select>
</div>
<p id="file-note" class="tiny"></p>
<div class="lib-list" id="lib-list">
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section>
<section class="card send-panel">
<div class="tabs" role="tablist">
<button type="button" class="on" data-tab="send" role="tab"><svg class="ico"><use href="#i-send"/></svg>Senden</button>
<button type="button" data-tab="ablauf" role="tab"><svg class="ico"><use href="#i-sweep"/></svg>Ablauf <small>Kobra 3</small></button>
</div>
<div class="flow-step" id="step-file">
<h3><span class="num-dot">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>
<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>
<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>
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
</div>
</div>
<div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden>
<h3><span class="num-dot">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>
<canvas id="sweep-canvas" class="sweep-canvas" width="320" height="240" aria-label="Schiebe-Vorschau"></canvas>
<p class="tiny">Draufsicht · orange = Vorderkante</p>
</div>
<div id="ablauf-steps" class="ablauf-steps"></div>
<div class="actions ablauf-add">
<button type="button" class="ghost tiny-btn" id="ablauf-add-print">+ Drucken</button>
<button type="button" class="ghost tiny-btn" id="ablauf-add-cool">+ Abkühlen</button>
<button type="button" class="ghost tiny-btn" id="ablauf-add-sweep">+ Schieben</button>
<button type="button" class="ghost tiny-btn" id="ablauf-add-check">+ Tasten</button>
</div>
<div class="repeat-row">
<label for="ablauf-repeat">Durchläufe</label>
<div class="stepper">
<button type="button" class="ghost" data-repeat="-1" aria-label="weniger">−</button>
<input id="ablauf-repeat" type="number" min="1" max="99" value="1">
<button type="button" class="ghost" data-repeat="1" aria-label="mehr">+</button>
</div>
</div>
</div>
<details class="options" id="options">
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Optionen</span><span class="tiny" id="options-sum"></span></summary>
<div class="opt-body">
<div id="loaded-spools" class="loaded-spools"></div>
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
<label for="send-project">Projekt</label>
<select id="send-project"><option value="">keins</option></select>
<div id="ace-box" class="ace-box" hidden>
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p>
<div id="ace-maps"></div>
</div>
</div>
</details>
<p class="msg" id="spool-warn" hidden></p>
<div class="go-row" data-pane="send">
<button type="button" class="go-btn" id="send-go" disabled><svg class="ico"><use href="#i-play"/></svg><span>Starten</span></button>
<p class="go-hint" id="send-hint"></p>
</div>
<div class="go-row" data-pane="ablauf" hidden>
<button type="button" class="go-btn" id="ablauf-go" disabled><svg class="ico"><use href="#i-queue"/></svg><span>Ablauf einreihen</span></button>
<p class="go-hint" id="ablauf-hint"></p>
<div class="test-row">
<button type="button" class="ghost tiny-btn" id="ablauf-test-sweep"><svg class="ico"><use href="#i-sweep"/></svg>Schieben testen</button>
<label class="check"><input id="ablauf-test-empty" type="checkbox"> Bett ist leer</label>
</div>
<p id="ablauf-note"></p>
</div>
<div class="send-progress" id="send-progress" hidden>
<div id="send-progress-list"></div>
</div>
<p id="send-note"></p>
</section>
</div>
<section class="card viewer-card" id="viewer-card" hidden>
<div class="page-head">
<div>
<h2>3D-Pfad</h2>
<p class="tiny" id="viewer-caption">Datei wählen</p>
</div>
<span class="tiny" id="viewer-meta"></span>
</div>
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
<canvas id="gcode-canvas" class="gcode-canvas"></canvas>
</section>
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
<script>
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1";
const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" };
let files = [];
let printers = [];
let spools = [];
let projects = [];
let sendPoll = null;
let watchedIds = [];
let pathViewer = null;
let viewerFileId = null;
let ablaufDefaults = {
cool: { bed_max: 30, nozzle_max: 45, fan_percent: 100, timeout_min: 25 },
sweep: { z_push: 2, z_travel: 20, x_from: 25, x_to: 225, x_step: 20, y_from: 250, y_to: 5, speed: 25 },
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
};
let ablaufSteps = [];
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" };
let aceHostsKey = "";
/* ---------- 3D-Pfad ---------- */
function ensureViewer() {
if (pathViewer) return pathViewer;
const canvas = document.getElementById("gcode-canvas");
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return null;
pathViewer = GrottiGcodeViewer.create(canvas);
return pathViewer;
}
async function showToolpath(file, progress) {
const card = document.getElementById("viewer-card");
const caption = document.getElementById("viewer-caption");
const meta = document.getElementById("viewer-meta");
if (!file || file.kind === "3mf") {
card.hidden = true;
return;
}
card.hidden = false;
caption.textContent = file.name;
const viewer = ensureViewer();
if (!viewer) {
meta.textContent = "Viewer nicht geladen.";
return;
}
if (viewerFileId !== file.id) {
meta.textContent = "Pfad wird gelesen …";
try {
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
viewer.setPath(data);
viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
} catch (error) {
meta.textContent = error.message || String(error);
return;
}
}
if (progress != null) viewer.setProgress(progress);
else viewer.setProgress(null);
}
/* ---------- Auswahl ---------- */
function selectedHosts() {
if (sel.mode === "next" && sel.tab === "send") return [];
return printers.filter((p) => sel.hosts.has(p.host)).map((p) => p.host);
}
function selectedPrinters() {
const hosts = new Set(selectedHosts());
return printers.filter((printer) => hosts.has(printer.host));
}
function selectedFile() {
return files.find((item) => String(item.id) === String(sel.fileId));
}
function selectedKobra3() {
return printers.filter((p) => sel.hosts.has(p.host) && p.family === "kobra_3");
}
function saveSendMemory() {
try {
localStorage.setItem(SEND_KEY, JSON.stringify({ mode: sel.mode, tab: sel.tab, hosts: [...sel.hosts] }));
} catch (error) { /* ignore */ }
}
function loadSendMemory() {
try {
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode;
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
} catch (error) { /* ignore */ }
const q = new URLSearchParams(location.search);
if (q.get("file")) sel.fileId = q.get("file");
if (q.get("host")) sel.hosts = new Set([q.get("host")]);
if (q.get("tab") === "ablauf") sel.tab = "ablauf";
}
/* ---------- ACE / Spule ---------- */
function slotLabel(slot) {
const place = Number(slot.index) + 1;
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
const kind = slot.type ? " · " + slot.type : "";
return box + kind;
}
function filamentLabel(filament, index) {
const kind = filament.type || "Filament";
const color = filament.color ? " · " + filament.color : "";
return "Farbe " + (index + 1) + " · " + kind + color;
}
function spoolsOnHost(host) {
return spools.filter((s) => s.printer_host === host);
}
function spoolWarnText() {
const file = selectedFile();
const need = file ? Number(file.weight_g) || 0 : 0;
if (!file || need <= 0) return "";
const maps = collectAceMaps();
const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || [];
const onHost = spoolsOnHost(host);
if (mapping.length) {
let left = 0;
let any = false;
const types = [];
mapping.forEach((item) => {
const slot = Number(item.ams_index) + 1;
const spool = onHost.find((s) => Number(s.ace_slot) === slot);
if (spool) {
any = true;
left += Number(spool.remaining_g) || 0;
if (spool.material_type) types.push(spool.material_type.toLowerCase());
}
});
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
}
continue;
}
const external = onHost.filter((s) => !s.ace_slot);
if (!external.length && !onHost.length) {
return name + ": keine Spule eingelegt — Abzug entfällt. Unter Material einlegen.";
}
if (external.length) {
const left = external.reduce((sum, s) => sum + (Number(s.remaining_g) || 0), 0);
if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
}
}
}
return "";
}
function renderLoadedSpools() {
const box = document.getElementById("loaded-spools");
if (!box) return;
const chosen = selectedPrinters();
if (!chosen.length) {
box.innerHTML = '<p class="tiny">Drucker wählen — dann siehst du die eingelegte Rolle.</p>';
return;
}
box.innerHTML = chosen.map((p) => {
const rows = spoolsOnHost(p.host);
if (!rows.length) {
return `<div class="loaded-host"><b>${escapeHtml(p.name)}</b><span class="tiny msg">keine Spule — <a href="/material">einlegen</a></span></div>`;
}
return `<div class="loaded-host"><b>${escapeHtml(p.name)}</b>${rows.map((s) => {
const slot = s.ace_slot ? "Platz " + s.ace_slot + " · " : "";
return `<span class="loaded-spool">${escapeHtml(slot + s.name)} · ${grams(s.remaining_g)}${s.material_type ? " · " + escapeHtml(s.material_type) : ""}</span>`;
}).join("")}</div>`;
}).join("");
}
function updateSpoolWarn() {
const node = document.getElementById("spool-warn");
const text = spoolWarnText();
node.hidden = !text;
node.textContent = text;
}
function filePaints(file) {
const rows = (file && file.filaments) || [];
if (rows.length) return rows;
return [{ type: "PLA", color: "" }];
}
function renderAceMaps(force) {
const box = document.getElementById("ace-box");
const root = document.getElementById("ace-maps");
const file = selectedFile();
const chosen = selectedPrinters().filter((printer) => printer.slots && printer.slots.length);
const key = (file ? file.id : "") + "|" + chosen.map((p) => p.host).join(",");
if (!force && key === aceHostsKey) return;
aceHostsKey = key;
const trailerWarn = document.getElementById("ace-trailer-warn");
if (!file || !chosen.length) {
box.hidden = true;
root.innerHTML = "";
if (trailerWarn) trailerWarn.hidden = true;
return;
}
const paints = filePaints(file);
box.hidden = false;
document.getElementById("options").open = true;
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
root.innerHTML = "";
chosen.forEach((printer) => {
const block = document.createElement("div");
block.className = "ace-printer";
block.dataset.host = printer.host;
const title = document.createElement("p");
title.className = "ace-printer-name";
title.textContent = printer.name;
block.appendChild(title);
paints.forEach((filament, paintIndex) => {
const row = document.createElement("label");
row.className = "ace-row";
const swatch = document.createElement("span");
swatch.className = "ace-swatch";
if (filament.color) swatch.style.background = filament.color;
const text = document.createElement("span");
text.textContent = filamentLabel(filament, paintIndex);
const select = document.createElement("select");
select.dataset.paint = String(paintIndex);
select.innerHTML = '<option value="">Platz wählen</option>';
printer.slots.forEach((slot) => {
const option = document.createElement("option");
option.value = String(slot.index);
option.textContent = slotLabel(slot);
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
option.selected = true;
select.dataset.picked = "1";
}
select.appendChild(option);
});
if (!select.dataset.picked && printer.slots[paintIndex]) {
select.value = String(printer.slots[paintIndex].index);
}
select.addEventListener("change", updateSpoolWarn);
row.appendChild(swatch);
row.appendChild(text);
row.appendChild(select);
block.appendChild(row);
});
root.appendChild(block);
});
}
function collectAceMaps() {
const maps = {};
document.querySelectorAll("#ace-maps .ace-printer").forEach((block) => {
const host = block.dataset.host;
const mapping = [];
block.querySelectorAll("select[data-paint]").forEach((select) => {
if (select.value === "") return;
mapping.push({
paint_index: Number(select.dataset.paint),
ams_index: Number(select.value),
});
});
maps[host] = mapping;
});
return maps;
}
/* ---------- Bibliothek ---------- */
function filteredFiles() {
const q = (document.getElementById("file-search").value || "").trim().toLowerCase();
const family = document.getElementById("file-filter-family").value;
const project = document.getElementById("file-filter-project").value;
return files.filter((file) => {
if (family && file.printer_family !== family) return false;
if (project === "none" && file.project_id) return false;
if (project && project !== "none" && String(file.project_id) !== project) return false;
if (!q) return true;
return String(file.name || "").toLowerCase().includes(q);
});
}
function fillProjectFilter() {
const select = document.getElementById("file-filter-project");
const current = select.value;
select.innerHTML = '<option value="">Alle Projekte</option><option value="none">Ohne Projekt</option>';
projects.forEach((project) => {
const option = document.createElement("option");
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function fillProjects() {
const opts = projects.map((row) =>
`<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>`
).join("");
const send = document.getElementById("send-project");
if (send) {
const keep = send.value;
send.innerHTML = '<option value="">keins</option>' + opts;
if (keep) send.value = keep;
}
const upload = document.getElementById("upload-project");
if (upload) {
const keep = upload.value;
upload.innerHTML = '<option value="">kein Projekt</option>' + opts;
if (keep) upload.value = keep;
}
}
function sizeText(bytes) {
const n = Number(bytes) || 0;
if (!n) return "";
return n >= 1048576 ? (n / 1048576).toFixed(1).replace(".", ",") + " MB" : Math.round(n / 1024) + " kB";
}
function swatchesHtml(file) {
const colors = ((file && file.filaments) || []).map((f) => f.color).filter(Boolean);
if (!colors.length) return "";
return `<span class="mini-swatches">${colors.slice(0, 6).map((c) => `<i style="background:${escapeHtml(c)}"></i>`).join("")}</span>`;
}
function sliceOf(file) {
return (file && file.slice_meta) || {};
}
function materialText(file) {
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))];
return types.join("+");
}
function layerHeightText(file) {
const height = Number(sliceOf(file).layer_height) || 0;
if (!height) return "";
return String(height).replace(".", ",") + " mm";
}
function alwaysMeta(file, project) {
const bits = [
materialText(file),
file.estimated_s ? hours(file.estimated_s) : "",
file.weight_g ? grams(file.weight_g) : "",
layerHeightText(file),
families[file.printer_family] || "",
project ? project.name : "",
];
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
return bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`).join("") + swatchesHtml(file);
}
function detailMeta(file) {
const sm = sliceOf(file);
return [
sm.layers ? sm.layers + " Schichten" : "",
sm.max_z_mm ? "Z max " + String(sm.max_z_mm).replace(".", ",") + " mm" : "",
sm.nozzle_mm ? "Düse Ø " + String(sm.nozzle_mm).replace(".", ",") + " mm" : "",
sm.temp_nozzle ? "Düse " + Math.round(sm.temp_nozzle) + "°" : "",
sm.temp_bed ? "Bett " + Math.round(sm.temp_bed) + "°" : "",
sizeText(file.size_bytes),
file.mtime ? new Date(Number(file.mtime) * 1000).toLocaleString("de-DE") : "",
sm.slicer || "",
file.kind === "3mf" ? "nur 3MF" : "",
file.ace_trailer ? "ACE-Trailer" : "",
].filter(Boolean);
}
function infoPopupHtml(file) {
const rows = detailMeta(file);
if (!rows.length) return "";
return `<details class="lib-info" onclick="event.stopPropagation()">
<summary class="icon-btn" title="Details">${icon("info")}</summary>
<div class="lib-info-pop">${rows.map((row) => `<div>${escapeHtml(row)}</div>`).join("")}</div>
</details>`;
}
function thumbHtml(file) {
return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
: `<span class="ph">${icon("cube")}</span>`;
}
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
return;
}
if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
return;
}
list.innerHTML = shown.map((file) => {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${infoPopupHtml(file)}${del}</span>
</div>`;
}).join("");
}
/* ---------- Flow rechts ---------- */
function renderPicked() {
const file = selectedFile();
const box = document.getElementById("picked");
document.getElementById("step-file").classList.toggle("ok", !!file);
if (!file) {
box.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Datei aus der Bibliothek wählen.</span></div>';
return;
}
const project = projects.find((p) => String(p.id) === String(file.project_id));
const details = detailMeta(file).map((t) => `<span>${escapeHtml(t)}</span>`).join("");
box.innerHTML = `<div class="picked-file">${thumbHtml(file)}<div style="min-width:0"><div class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</div><div class="m">${alwaysMeta(file, project)}</div>${details ? `<div class="m picked-details">${details}</div>` : ""}</div></div>`;
}
function targetState(p) {
const st = String(p.state || "").toLowerCase();
if (p.ok === false || st === "offline") return { st: "offline", text: "offline", bad: true };
if (p.queue_running) {
const job = p.queue_running;
const step = job.step_label_current ? job.step_label_current + " · " : "";
return { st: "printing", text: (job.steps && job.steps.length ? "Ablauf · " : "") + step + (repeatText(job) || "läuft") };
}
if (st === "printing" || st === "paused") {
const pct = Math.round((Number(p.progress) || 0) * 100);
const rest = p.remain > 0 ? " · noch " + hours(p.remain) : "";
return { st, text: (STATE_LABELS[st] || st) + " " + pct + " %" + rest };
}
if (st === "error") return { st, text: "Fehler — auf der Wand löschen", bad: true };
if (p.needs_clear) return { st: "waiting", text: "Platte belegt", bad: true };
return { st: st || "standby", text: STATE_LABELS[st] || "bereit" };
}
function isFree(p) {
const st = String(p.state || "").toLowerCase();
return p.ok !== false && ["standby", "complete", "cancelled"].includes(st) && !p.queue_running;
}
function familyMismatch(p, file) {
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
}
function renderTargets() {
const box = document.getElementById("targets");
const file = selectedFile();
const override = document.getElementById("send-override").checked;
const nextMode = sel.tab === "send" && sel.mode === "next";
box.classList.toggle("dimmed", nextMode);
box.innerHTML = printers.map((p) => {
const s = targetState(p);
const mismatch = familyMismatch(p, file);
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
const off = (mismatch && !override) || notK3;
const on = !nextMode && sel.hosts.has(p.host) && !notK3;
const waiting = p.queue_waiting ? `<span class="count">${p.queue_waiting}</span>` : "";
const why = notK3 ? "Ablauf nur Kobra 3" : mismatch ? "andere Maschine" : s.text;
return `<button type="button" class="target ${on ? "on" : ""} ${off ? "off" : ""}" data-host="${escapeHtml(p.host)}" aria-pressed="${on}">
<span class="row1"><span class="chip pip ${escapeHtml(s.st)}"></span><span class="n">${escapeHtml(p.name)}</span>${waiting}${p.slots && p.slots.length ? '<span class="tiny">ACE</span>' : ""}</span>
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
</button>`;
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
}
function familyOfSelection() {
const file = selectedFile();
return file && families[file.printer_family] ? families[file.printer_family] : "Drucker";
}
function namesText(list) {
if (!list.length) return "";
if (list.length <= 2) return list.map((p) => p.name).join(" und ");
return list.length + " Druckern";
}
function updateSendButton() {
const file = selectedFile();
const chosen = selectedPrinters();
const button = document.getElementById("send-go");
const hint = document.getElementById("send-hint");
const label = button.querySelector("span");
const targetHint = document.getElementById("target-hint");
if (button.dataset.armed === "1") return;
let text = "Starten";
let problem = "";
let note = "";
if (!file) problem = "Erst eine Datei wählen.";
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
if (sel.mode === "now") {
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
const busy = chosen.filter((p) => !isFree(p));
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein.";
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten";
} else if (sel.mode === "free") {
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection();
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt.";
} else {
text = "Einreihen: nächster freier " + familyOfSelection();
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine.";
}
const mismatch = chosen.filter((p) => familyMismatch(p, file));
if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
problem = "Datei passt nicht zu " + namesText(mismatch) + ". Abwählen oder unter Optionen erlauben.";
}
label.textContent = text;
button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
}
function updateAblaufButton() {
const file = selectedFile();
const k3 = selectedKobra3();
const button = document.getElementById("ablauf-go");
const hint = document.getElementById("ablauf-hint");
if (button.dataset.armed === "1") return;
const repeat = Math.max(1, Number(document.getElementById("ablauf-repeat").value) || 1);
let problem = "";
if (!ablaufSteps.length) problem = "Mindestens einen Schritt anlegen.";
else if (!ablaufSteps.some((s) => s.type === "print" && s.file_id)) problem = "Ein Druck-Schritt braucht eine Datei.";
else if (!k3.length) problem = "Einen oder mehrere Kobra 3 wählen.";
button.querySelector("span").textContent = k3.length
? "Ablauf einreihen auf " + namesText(k3) + (repeat > 1 ? " · " + repeat + "×" : "")
: "Ablauf einreihen";
button.disabled = !!problem;
hint.textContent = problem || (file ? "Startet, sobald der Drucker frei ist. In der Seitenleiste abbrechbar." : "");
hint.classList.toggle("bad", !!problem);
}
function updateOptionsSummary() {
const parts = [];
const hosts = selectedHosts();
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
if (project) parts.push(project.name);
if (!document.getElementById("ace-box").hidden) parts.push("ACE");
document.getElementById("options-sum").textContent = parts.join(" · ");
}
function renderTabs() {
document.querySelectorAll(".tabs [data-tab]").forEach((b) => b.classList.toggle("on", b.dataset.tab === sel.tab));
document.querySelectorAll("[data-pane]").forEach((node) => { node.hidden = node.dataset.pane !== sel.tab; });
document.querySelectorAll("#mode [data-mode]").forEach((b) => b.classList.toggle("on", b.dataset.mode === sel.mode));
if (sel.tab === "ablauf") {
ensureAblaufDefaults();
renderAblauf();
}
}
function refreshFlow() {
renderPicked();
renderTargets();
renderAceMaps();
renderLoadedSpools();
updateSpoolWarn();
updateOptionsSummary();
updateSendButton();
updateAblaufButton();
}
function selectFile(id) {
sel.fileId = id;
const file = selectedFile();
if (file && file.project_id && document.getElementById("send-project")) {
document.getElementById("send-project").value = String(file.project_id);
}
if (file && ablaufSteps.length && ablaufSteps[0].type === "print") {
ablaufSteps[0].file_id = file.id;
saveAblaufMemory();
if (sel.tab === "ablauf") renderAblauf();
}
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow();
showToolpath(file, 0);
if (window.matchMedia("(max-width: 1080px)").matches) {
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
}
}
/* ---------- Laden ---------- */
async function load() {
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults] = await Promise.all([
api("/einstellungen"),
api("/dateien"),
api("/drucker?live=1"),
api("/spulen"),
api("/projekte").catch(() => []),
api("/abraeumen-defaults").catch(() => null),
]);
document.getElementById("watch").textContent = settings.watch_dir
? "Slice-Ordner: " + settings.watch_dir
: "Kein Slice-Ordner — Dateien hier hochladen.";
files = fileRows;
printers = printerRows;
spools = spoolRows;
projects = projectRows || [];
if (defaults && defaults.cool) ablaufDefaults.cool = defaults.cool;
if (defaults && defaults.sweep) ablaufDefaults.sweep = defaults.sweep;
if (defaults && defaults.check) ablaufDefaults.check = defaults.check;
if (sel.fileId && !selectedFile()) sel.fileId = null;
fillProjects();
fillProjectFilter();
renderLibrary();
renderTabs();
refreshFlow();
const file = selectedFile();
if (file) showToolpath(file, 0);
}
async function refreshPrinters() {
if (document.hidden) return;
try {
printers = await api("/drucker?live=1");
renderTargets();
updateSendButton();
updateAblaufButton();
} catch (error) { /* nächster Versuch */ }
}
/* ---------- Senden ---------- */
function showJobsProgress(jobs) {
const box = document.getElementById("send-progress");
const list = document.getElementById("send-progress-list");
const note = document.getElementById("send-note");
box.hidden = false;
list.innerHTML = jobs.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 `<div class="send-job">
<div class="send-job-head"><strong>${escapeHtml(job.printer || "")}</strong><span>${escapeHtml(job.message || "")}</span></div>
<div class="send-progress-track"><div class="send-progress-bar ${cls}" style="width:${pct}%"></div></div>
</div>`;
}).join("");
const errors = jobs.filter((job) => job.status === "error");
const pending = jobs.filter((job) => job.status === "pending" || job.status === "running");
const done = jobs.filter((job) => job.status === "done");
note.className = "";
note.textContent = "";
if (pending.length) return;
if (errors.length && !done.length) {
toast(errors.map((job) => job.printer + ": " + job.message).join(" · "), "bad");
} else if (errors.length) {
toast(done.length + " gestartet, " + errors.length + " fehlgeschlagen.", "bad");
} else {
const warn = done.some((job) => (job.result && job.result.warnings && job.result.warnings.length) || (job.message || "").includes("ACE-Nachschub"));
toast(done.length === 1 ? (done[0].printer + ": " + (done[0].message || "gestartet")) : done.length + " Drucker gestartet.", "good");
if (warn) {
note.textContent = "ACE-Nachschub braucht Slicer-Metadaten (Trailer).";
note.className = "msg";
}
}
}
function stopSendPoll() {
if (sendPoll) {
clearInterval(sendPoll);
sendPoll = null;
}
}
async function watchJobs(ids) {
stopSendPoll();
watchedIds = ids.slice();
const tick = async () => {
try {
const jobs = await Promise.all(watchedIds.map((id) => api("/senden/" + id)));
showJobsProgress(jobs);
const busy = jobs.some((job) => job.status === "pending" || job.status === "running");
if (!busy) {
stopSendPoll();
updateSendButton();
refreshPrinters();
if (typeof refreshSendQueue === "function") refreshSendQueue();
}
} catch (error) {
stopSendPoll();
updateSendButton();
showNote("send-note", error);
}
};
sendPoll = setInterval(tick, 500);
updateSendButton();
await tick();
}
async function doSend() {
const button = document.getElementById("send-go");
const whenFree = sel.mode !== "now";
button.disabled = true;
document.getElementById("send-note").textContent = "";
try {
const payload = await api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts: selectedHosts(),
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
const projectId = document.getElementById("send-project")?.value;
if (projectId && sel.fileId) {
try {
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
} catch (error) { /* Projektbindung optional */ }
}
const jobs = payload.jobs || [];
if (whenFree || payload.mode === "when_free") {
document.getElementById("send-progress").hidden = true;
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
return;
}
showJobsProgress(jobs);
if (typeof refreshSendQueue === "function") refreshSendQueue();
await watchJobs(jobs.map((job) => job.id));
} catch (error) {
updateSendButton();
toast(error.message || String(error), "bad");
}
}
/* ---------- Ablauf ---------- */
function loadAblaufMemory() {
try {
const raw = localStorage.getItem(ABLAUF_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (Array.isArray(data.steps) && data.steps.length) {
// altes 3×3 (probe_count/mesh_*) streichen; Mitte-Tasten behalten/ergänzen
ablaufSteps = data.steps.filter((s) => !(s.type === "check" && (s.probe_count != null || s.mesh_min_x != null)));
ablaufSteps = ablaufSteps.map((s) => {
if (s.type === "check" && (Number(s.max_delta_mm) === 2.5 || Number(s.max_delta_mm) === 1.0))
return { ...s, max_delta_mm: 3.0 };
return s;
});
const types = ablaufSteps.map((s) => s.type).join(",");
if (types === "print,cool,sweep") ablaufSteps = [...ablaufSteps, defaultCheckStep()];
}
if (data.repeat) document.getElementById("ablauf-repeat").value = String(data.repeat);
} catch (error) { /* ignore */ }
}
function saveAblaufMemory() {
try {
localStorage.setItem(ABLAUF_KEY, JSON.stringify({
steps: ablaufSteps,
repeat: Number(document.getElementById("ablauf-repeat").value) || 1,
}));
} catch (error) { /* ignore */ }
}
function defaultPrintStep() {
const file = selectedFile();
return {
type: "print",
file_id: file ? file.id : (files[0] && files[0].id) || null,
bed_leveling: true,
};
}
function defaultCoolStep() { return { type: "cool", ...ablaufDefaults.cool }; }
function defaultSweepStep() { return { type: "sweep", ...ablaufDefaults.sweep }; }
function defaultCheckStep() { return { type: "check", ...ablaufDefaults.check }; }
function ensureAblaufDefaults() {
if (!ablaufSteps.length) {
ablaufSteps = [defaultPrintStep(), defaultCoolStep(), defaultSweepStep(), defaultCheckStep()];
}
}
function numField(label, key, value, step) {
return `<label class="ablauf-field">${escapeHtml(label)}<input type="number" data-key="${escapeHtml(key)}" value="${escapeHtml(String(value))}" step="${step || "1"}"></label>`;
}
function stepSummary(step) {
if (step.type === "print") {
const file = files.find((f) => String(f.id) === String(step.file_id));
return file ? file.name : "keine Datei";
}
if (step.type === "cool") return "Bett ≤ " + step.bed_max + " °C · Düse ≤ " + step.nozzle_max + " °C";
if (step.type === "sweep") return "X " + step.x_from + "–" + step.x_to + " · Z " + step.z_push + " mm";
if (step.type === "check") {
return "Tast " + (step.x != null ? step.x : 125) + "/" + (step.y != null ? step.y : 125)
+ " · max Z " + step.max_delta_mm + " mm";
}
return "";
}
let sweepViewer = null;
function syncSweepPreview() {
const wrap = document.getElementById("sweep-preview-wrap");
const canvas = document.getElementById("sweep-canvas");
if (!wrap || !canvas || typeof SweepViewer !== "function") return;
const sweep = ablaufSteps.find((step) => step.type === "sweep");
wrap.hidden = !sweep;
if (!sweep) return;
if (!sweepViewer) sweepViewer = new SweepViewer(canvas);
sweepViewer.setSweep(sweep);
}
function renderAblauf() {
const root = document.getElementById("ablauf-steps");
if (!root) return;
document.getElementById("ablauf-preview").innerHTML = stepsHtml(ablaufSteps.map((s) => STEP_TITLES[s.type] || s.type), -1);
root.innerHTML = ablaufSteps.map((step, index) => {
const kind = step.type;
const title = STEP_TITLES[kind] || kind;
let body = "";
if (kind === "print") {
const options = files.map((file) =>
`<option value="${file.id}" ${String(file.id) === String(step.file_id) ? "selected" : ""}>${escapeHtml(file.name)}</option>`
).join("");
body = `<label class="ablauf-field wide">Datei<select data-key="file_id">${options}</select></label>
<label class="check"><input type="checkbox" data-key="bed_leveling" ${step.bed_leveling ? "checked" : ""}> Bettnetz messen</label>`;
} else if (kind === "cool") {
body = [
numField("Bett max °C", "bed_max", step.bed_max, "1"),
numField("Düse max °C", "nozzle_max", step.nozzle_max, "1"),
numField("Lüfter %", "fan_percent", step.fan_percent, "1"),
numField("Timeout min", "timeout_min", step.timeout_min, "1"),
].join("");
} else if (kind === "check") {
body = [
numField("Max Z mm", "max_delta_mm", step.max_delta_mm, "0.1"),
numField("Tast X", "x", step.x != null ? step.x : 125, "1"),
numField("Tast Y", "y", step.y != null ? step.y : 125, "1"),
numField("Bezug X", "ref_x", step.ref_x != null ? step.ref_x : 25, "1"),
numField("Bezug Y", "ref_y", step.ref_y != null ? step.ref_y : 125, "1"),
numField("Timeout min", "timeout_min", step.timeout_min, "1"),
].join("");
} else {
body = [
numField("Z schieben mm", "z_push", step.z_push, "0.1"),
numField("Z umsetzen mm", "z_travel", step.z_travel, "0.1"),
numField("X von", "x_from", step.x_from, "1"),
numField("X bis", "x_to", step.x_to, "1"),
numField("X Schritt", "x_step", step.x_step, "1"),
numField("Y hinten", "y_from", step.y_from, "1"),
numField("Y vorn", "y_to", step.y_to, "1"),
numField("Tempo mm/s", "speed", step.speed, "1"),
].join("");
}
return `<details class="ablauf-step" data-index="${index}" ${kind === "print" ? "open" : ""}>
<summary class="ablauf-step-head">
<span class="num-dot">${index + 1}</span>
<strong>${title}</strong>
<span class="ablauf-step-sum">${escapeHtml(stepSummary(step))}</span>
<span class="ablauf-step-tools">
<button type="button" class="icon-btn" data-move="-1" ${index === 0 ? "disabled" : ""} title="nach oben">${icon("up")}</button>
<button type="button" class="icon-btn" data-move="1" ${index === ablaufSteps.length - 1 ? "disabled" : ""} title="nach unten">${icon("down")}</button>
<button type="button" class="icon-btn" data-remove="1" title="entfernen">${icon("x")}</button>
</span>
</summary>
<div class="ablauf-step-body">${body}</div>
</details>`;
}).join("");
root.querySelectorAll(".ablauf-step").forEach((node) => {
const index = Number(node.dataset.index);
node.querySelectorAll("[data-key]").forEach((input) => {
const apply = () => {
const key = input.dataset.key;
if (input.type === "checkbox") ablaufSteps[index][key] = input.checked;
else ablaufSteps[index][key] = Number(input.value);
node.querySelector(".ablauf-step-sum").textContent = stepSummary(ablaufSteps[index]);
saveAblaufMemory();
updateAblaufButton();
if (ablaufSteps[index].type === "sweep") syncSweepPreview();
};
input.addEventListener("change", apply);
input.addEventListener("input", apply);
});
node.querySelectorAll("[data-move]").forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
const target = index + Number(btn.dataset.move);
if (target < 0 || target >= ablaufSteps.length) return;
[ablaufSteps[index], ablaufSteps[target]] = [ablaufSteps[target], ablaufSteps[index]];
saveAblaufMemory();
renderAblauf();
});
});
node.querySelector("[data-remove]").addEventListener("click", (event) => {
event.preventDefault();
ablaufSteps.splice(index, 1);
saveAblaufMemory();
renderAblauf();
});
});
syncSweepPreview();
updateAblaufButton();
}
function addStep(step) {
ablaufSteps.push(step);
saveAblaufMemory();
renderAblauf();
}
function collectAblaufPayload() {
const steps = ablaufSteps.map((step) => {
if (step.type !== "print") return { ...step };
return {
type: "print",
file_id: Number(step.file_id),
bed_leveling: !!step.bed_leveling,
override_machine: document.getElementById("send-override").checked,
ace_map: [],
};
});
return {
hosts: selectedKobra3().map((printer) => printer.host),
steps,
ace_maps: collectAceMaps(),
repeat_total: Math.max(1, Math.min(99, Number(document.getElementById("ablauf-repeat").value) || 1)),
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
file_id: Number(sel.fileId) || null,
};
}
async function doAblauf() {
const note = document.getElementById("ablauf-note");
note.textContent = "";
try {
const payload = await api("/ablauf", { method: "POST", json: collectAblaufPayload() });
toast((payload.count || 0) > 1 ? payload.count + " Abläufe eingereiht." : "Ablauf eingereiht. Steht unter „Wartet“.", "good");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
} catch (error) {
toast(error.message || String(error), "bad");
}
updateAblaufButton();
}
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("click", (event) => {
const del = event.target.closest("[data-delete]");
if (del) {
event.stopPropagation();
armButton(del, "löschen?", async () => {
try {
await api("/dateien/" + del.dataset.delete, { method: "DELETE" });
if (String(sel.fileId) === del.dataset.delete) sel.fileId = null;
toast("Datei gelöscht.", "good");
load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
return;
}
const item = event.target.closest("[data-file]");
if (item) selectFile(item.dataset.file);
});
document.getElementById("lib-list").addEventListener("keydown", (event) => {
const item = event.target.closest("[data-file]");
if (item && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
selectFile(item.dataset.file);
}
});
["file-search", "file-filter-family", "file-filter-project"].forEach((id) => {
document.getElementById(id).addEventListener("input", renderLibrary);
document.getElementById(id).addEventListener("change", renderLibrary);
});
document.getElementById("targets").addEventListener("click", (event) => {
const button = event.target.closest("[data-host]");
if (!button) return;
const host = button.dataset.host;
const p = printers.find((x) => x.host === host);
if (sel.tab === "ablauf" && p && p.family !== "kobra_3") {
toast("Abläufe gehen nur auf Kobra 3.", "bad");
return;
}
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free";
if (sel.hosts.has(host)) sel.hosts.delete(host);
else sel.hosts.add(host);
saveSendMemory();
renderTabs();
refreshFlow();
});
document.getElementById("mode").addEventListener("click", (event) => {
const button = event.target.closest("[data-mode]");
if (!button) return;
sel.mode = button.dataset.mode;
saveSendMemory();
renderTabs();
refreshFlow();
});
document.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("[data-tab]");
if (!button) return;
sel.tab = button.dataset.tab;
if (sel.tab === "ablauf" && !selectedKobra3().length) {
const firstK3 = printers.find((p) => p.family === "kobra_3" && isFree(p)) || printers.find((p) => p.family === "kobra_3");
if (firstK3) sel.hosts.add(firstK3.host);
}
saveSendMemory();
renderTabs();
refreshFlow();
});
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-go").addEventListener("click", (event) => {
const button = event.currentTarget;
const warn = spoolWarnText();
if (sel.mode === "now") {
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
} else if (warn) {
armButton(button, "Warnung — trotzdem einreihen?", doSend);
} else {
doSend();
}
});
document.getElementById("ablauf-go").addEventListener("click", (event) => {
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf);
});
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
document.getElementById("ablauf-add-cool").addEventListener("click", () => addStep(defaultCoolStep()));
document.getElementById("ablauf-add-sweep").addEventListener("click", () => addStep(defaultSweepStep()));
document.getElementById("ablauf-add-check").addEventListener("click", () => addStep(defaultCheckStep()));
document.getElementById("ablauf-repeat").addEventListener("change", () => { saveAblaufMemory(); updateAblaufButton(); });
document.querySelectorAll("[data-repeat]").forEach((button) => {
button.addEventListener("click", () => {
const input = document.getElementById("ablauf-repeat");
input.value = String(Math.max(1, Math.min(99, (Number(input.value) || 1) + Number(button.dataset.repeat))));
saveAblaufMemory();
updateAblaufButton();
});
});
document.getElementById("ablauf-test-sweep").addEventListener("click", async () => {
const k3 = selectedKobra3();
if (!k3.length) {
toast("Einen Kobra 3 wählen.", "bad");
return;
}
if (!document.getElementById("ablauf-test-empty").checked) {
toast("Erst „Bett ist leer“ anhaken. Die Düse fährt 2 mm über dem Bett.", "bad");
return;
}
const sweepStep = ablaufSteps.find((step) => step.type === "sweep") || defaultSweepStep();
try {
const result = await api("/schieben-testen", {
method: "POST",
json: { host: k3[0].host, confirm_empty: true, sweep: sweepStep },
});
toast(k3[0].name + ": " + (result.message || "Bahn gefahren."), "good");
} catch (error) {
toast(error.message || String(error), "bad");
}
});
async function uploadFiles(list) {
const items = Array.from(list || []);
if (!items.length) return;
const note = document.getElementById("file-note");
const projectId = document.getElementById("upload-project")?.value || "";
try {
for (const [i, file] of items.entries()) {
note.textContent = "Lade hoch " + (i + 1) + "/" + items.length + ": " + file.name;
const form = new FormData();
form.append("file", file);
if (projectId) form.append("project_id", projectId);
await api("/dateien", { method: "POST", body: form });
}
note.textContent = "";
toast(items.length === 1 ? items[0].name + " hochgeladen." : items.length + " Dateien hochgeladen.", "good");
await load();
} catch (error) {
note.textContent = "";
toast(error.message || String(error), "bad");
}
}
document.getElementById("upload-file").addEventListener("change", async (event) => {
await uploadFiles(event.target.files);
event.target.value = "";
});
const libCard = document.getElementById("lib-card");
["dragenter", "dragover"].forEach((name) => libCard.addEventListener(name, (event) => {
event.preventDefault();
libCard.classList.add("drop");
}));
["dragleave", "drop"].forEach((name) => libCard.addEventListener(name, (event) => {
event.preventDefault();
if (name === "dragleave" && libCard.contains(event.relatedTarget)) return;
libCard.classList.remove("drop");
}));
libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files));
document.getElementById("rescan").addEventListener("click", async () => {
try {
const result = await api("/dateien/neu-lesen", { method: "POST" });
toast(result.hinweis || (result.gelesen + " Dateien gelesen."), "good");
load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
window.onQueueChanged = refreshPrinters;
loadSendMemory();
loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad"));
setInterval(refreshPrinters, 5000);
</script>
{% endblock %}