Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

This commit is contained in:
Dominic Gundel committed 2026-10-15 08:45:56 +02:00
commit b2e6443c68
69 files changed
+18719

No files matched your search

View File
Whitespace-only changes.
+1328
View File
File diff suppressed because it is too large. Load diff
+172
View File
@@ -0,0 +1,172 @@
const TOKEN = document.querySelector('meta[name="token"]')?.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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
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: options.method || "GET",
headers,
body,
cache: "no-store",
});
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) {
if (!url || !TOKEN) return url;
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
}
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" });
}
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 `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
}
function chipHtml(state, label, small) {
const text = label || STATE_LABELS[state] || state || "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
}
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 `<span class="step ${cls}">${escapeHtml(label)}</span>`;
});
return `<span class="steps${compact ? " compact" : ""}">${parts.join('<span class="step-sep"></span>')}</span>`;
}
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 node = document.createElement("div");
node.className = "toast " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message;
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);
}
+723
View File
@@ -0,0 +1,723 @@
/* global THREE */
window.GrottiGcodeViewer = (function () {
const PHI_MIN = 0.08;
const PHI_MAX = Math.PI - 0.08;
const COLOR_DONE = 0x5dffb0;
const COLOR_TODO = 0x6b7280;
const COLOR_PREVIEW = 0xf97316;
const COLOR_OBJ = 0x60a5fa;
const COLOR_OBJ_EX = 0xef4444;
const COLOR_OBJ_CUR = 0xfbbf24;
const GRID_STEP = 10;
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern
const FAT_OFFSETS = [0, -0.22, 0.22];
function create(canvas) {
const wrap = document.createElement("div");
wrap.className = "gcode-viewer-wrap";
if (canvas.parentNode) {
canvas.parentNode.insertBefore(wrap, canvas);
wrap.appendChild(canvas);
}
const legend = document.createElement("div");
legend.className = "gcode-legend";
legend.innerHTML =
'<span class="gcode-leg-item live"><span class="gcode-leg-swatch done thick"></span><span>gedruckt</span></span>' +
'<span class="gcode-leg-item live"><span class="gcode-leg-swatch todo"></span><span>offen</span></span>' +
'<span class="gcode-leg-item preview-only"><span class="gcode-leg-swatch done"></span><span>Pfad</span></span>' +
'<span class="gcode-leg-scale">Gitter ' + GRID_STEP + " mm</span>";
wrap.appendChild(legend);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x121212);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 5000);
camera.up.set(0, 0, 1);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const light = new THREE.DirectionalLight(0xffffff, 0.9);
light.position.set(1, -2, 3);
scene.add(light);
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const bedGroup = new THREE.Group();
scene.add(bedGroup);
const pathGroup = new THREE.Group();
scene.add(pathGroup);
const excludeGroup = new THREE.Group();
scene.add(excludeGroup);
let points = [];
let layerBreaks = [0];
let progress = null;
let layer = null;
let layers = 0;
let center = { x: 0, y: 0, z: 0 };
let span = 80;
let excludeData = { objects: [], excluded: [], current: null };
let selectedName = null;
let onExcludeSelect = null;
let excludeMeshes = [];
let theta = 0.55;
let phi = 1.05;
let distance = 320;
let target = new THREE.Vector3(0, 0, 20);
let defaultView = { theta: 0.55, phi: 1.05, distance: 320, tx: 0, ty: 0, tz: 20 };
let mode = null;
let lastX = 0;
let lastY = 0;
let downX = 0;
let downY = 0;
let pointers = new Map();
let pinchDist = 0;
let alive = true;
let raf = 0;
let dirty = true;
const raycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2();
canvas.style.cursor = "grab";
canvas.style.touchAction = "none";
function resize() {
const width = canvas.clientWidth || canvas.parentElement.clientWidth || 480;
const height = canvas.clientHeight || 320;
if (canvas.width !== width || canvas.height !== height) {
renderer.setSize(width, height, false);
camera.aspect = width / Math.max(1, height);
camera.updateProjectionMatrix();
dirty = true;
}
}
function orbit() {
const sinPhi = Math.sin(phi);
camera.position.set(
target.x + distance * sinPhi * Math.sin(theta),
target.y - distance * sinPhi * Math.cos(theta),
target.z + distance * Math.cos(phi)
);
camera.lookAt(target);
}
function clearGroup(group) {
while (group.children.length) {
const child = group.children[0];
group.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) {
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
}
}
}
function clearLines() {
clearGroup(pathGroup);
}
function clearBed() {
clearGroup(bedGroup);
}
function clearExclude() {
clearGroup(excludeGroup);
excludeMeshes = [];
}
function boundsOf(pts) {
let minX = Infinity;
let minY = Infinity;
let minZ = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let maxZ = -Infinity;
for (const point of pts) {
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
minZ = Math.min(minZ, point[2]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
maxZ = Math.max(maxZ, point[2]);
}
return { minX, minY, minZ, maxX, maxY, maxZ };
}
function niceFloor(value, step) {
return Math.floor(value / step) * step;
}
function niceCeil(value, step) {
return Math.ceil(value / step) * step;
}
function makeLabelSprite(text) {
const c = document.createElement("canvas");
c.width = 256;
c.height = 64;
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = "rgba(18,18,18,0.72)";
roundRect(ctx, 8, 8, c.width - 16, c.height - 16, 10);
ctx.fill();
ctx.fillStyle = "#e8e8e8";
ctx.font = "700 36px ui-sans-serif, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, c.width / 2, c.height / 2 + 1);
const tex = new THREE.CanvasTexture(c);
tex.needsUpdate = true;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(18, 4.5, 1);
sprite.renderOrder = 10;
return sprite;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function buildBed(b) {
clearBed();
const pad = GRID_STEP * 3;
const minX = niceFloor(b.minX - pad, GRID_STEP) - center.x;
const maxX = niceCeil(b.maxX + pad, GRID_STEP) - center.x;
const minY = niceFloor(b.minY - pad, GRID_STEP) - center.y;
const maxY = niceCeil(b.maxY + pad, GRID_STEP) - center.y;
const w = Math.max(GRID_STEP, maxX - minX);
const h = Math.max(GRID_STEP, maxY - minY);
const plate = new THREE.Mesh(
new THREE.PlaneGeometry(w, h),
new THREE.MeshBasicMaterial({
color: 0x1a1a1a,
transparent: true,
opacity: 0.88,
side: THREE.DoubleSide,
})
);
plate.position.set((minX + maxX) / 2, (minY + maxY) / 2, -0.08);
bedGroup.add(plate);
const gridLines = [];
for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) {
const major = Math.round(x) % 50 === 0;
gridLines.push(x, minY, 0, x, maxY, 0);
if (major) gridLines.push(x + 0.2, minY, 0, x + 0.2, maxY, 0);
}
for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) {
const major = Math.round(y) % 50 === 0;
gridLines.push(minX, y, 0, maxX, y, 0);
if (major) gridLines.push(minX, y + 0.2, 0, maxX, y + 0.2, 0);
}
const gridGeo = new THREE.BufferGeometry();
gridGeo.setAttribute("position", new THREE.Float32BufferAttribute(gridLines, 3));
bedGroup.add(
new THREE.LineSegments(
gridGeo,
new THREE.LineBasicMaterial({ color: 0x3f3f3f })
)
);
const tick = [];
for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) {
const len = Math.round(x) % 50 === 0 ? 7 : 3.5;
tick.push(x, minY, 0.02, x, minY - len, 0.02);
}
for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) {
const len = Math.round(y) % 50 === 0 ? 7 : 3.5;
tick.push(minX, y, 0.02, minX - len, y, 0.02);
}
tick.push(minX, minY, 0.02, maxX, minY, 0.02);
tick.push(minX, minY, 0.02, minX, maxY, 0.02);
tick.push(maxX, minY, 0.02, maxX, maxY, 0.02);
tick.push(minX, maxY, 0.02, maxX, maxY, 0.02);
// Achsen-Hinweis
tick.push(minX, minY, 0.05, minX + 25, minY, 0.05);
tick.push(minX, minY, 0.05, minX, minY + 25, 0.05);
const tickGeo = new THREE.BufferGeometry();
tickGeo.setAttribute("position", new THREE.Float32BufferAttribute(tick, 3));
bedGroup.add(
new THREE.LineSegments(
tickGeo,
new THREE.LineBasicMaterial({ color: 0xb0b0b0 })
)
);
for (let x = minX; x <= maxX + 0.01; x += 50) {
const mm = Math.round(x + center.x);
const sprite = makeLabelSprite(String(mm) + " mm");
sprite.position.set(x, minY - 14, 1.5);
bedGroup.add(sprite);
}
for (let y = minY + 50; y <= maxY + 0.01; y += 50) {
const mm = Math.round(y + center.y);
const sprite = makeLabelSprite(String(mm));
sprite.position.set(minX - 16, y, 1.5);
bedGroup.add(sprite);
}
const zero = makeLabelSprite("0");
zero.position.set(minX - 14, minY - 14, 1.5);
bedGroup.add(zero);
const xAxis = makeLabelSprite("X →");
xAxis.position.set(minX + 32, minY - 14, 1.5);
bedGroup.add(xAxis);
const yAxis = makeLabelSprite("Y →");
yAxis.position.set(minX - 16, minY + 32, 1.5);
bedGroup.add(yAxis);
}
function resetView() {
theta = defaultView.theta;
phi = defaultView.phi;
distance = defaultView.distance;
target.set(defaultView.tx, defaultView.ty, defaultView.tz);
dirty = true;
}
function isExtrude(point) {
return !point || point.length < 4 || point[3];
}
function consecutiveMove(prev, next) {
// Original-seq (Index 4): nach Thinning keine Sehnen über Lücken
if (!prev || !next || prev.length < 5 || next.length < 5) return true;
return Number(next[4]) === Number(prev[4]) + 1;
}
function fillSegments(from, to, out, fat) {
const end = Math.min(to, points.length - 1);
const start = Math.max(0, from);
const offsets = fat ? FAT_OFFSETS : [0];
for (let i = start + 1; i <= end; i++) {
if (!isExtrude(points[i])) continue;
if (!consecutiveMove(points[i - 1], points[i])) continue;
const ax = points[i - 1][0] - center.x;
const ay = points[i - 1][1] - center.y;
const az = points[i - 1][2] - center.z;
const bx = points[i][0] - center.x;
const by = points[i][1] - center.y;
const bz = points[i][2] - center.z;
const dx = bx - ax;
const dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
for (const off of offsets) {
out.push(
ax + nx * off, ay + ny * off, az,
bx + nx * off, by + ny * off, bz
);
}
}
}
function makeSegLine(from, to, color, opacity, fat) {
const segs = [];
fillSegments(from, to, segs, fat);
if (segs.length < 6) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(segs, 3));
const mat = new THREE.LineBasicMaterial({
color,
transparent: opacity < 0.99,
opacity,
depthWrite: opacity > 0.9,
});
return new THREE.LineSegments(geo, mat);
}
function cutIndex() {
if (!points.length) return 0;
if (progress == null) return points.length - 1;
if (progress >= 0.995 || (layers > 0 && layer != null && Number(layer) >= layers)) {
return points.length - 1;
}
// Primär Datei-Fortschritt. layer_breaks zählt oft Z-Hops mit —
// Index === current_layer schnitt viel zu früh ab.
let cut = Math.floor(points.length * progress);
if (layerBreaks.length > 1 && layers > 0 && layer != null && Number(layer) > 0) {
const ratio = Math.min(1, Number(layer) / Number(layers));
const idx = Math.floor(ratio * (layerBreaks.length - 1));
const byLayer = layerBreaks[Math.max(0, Math.min(layerBreaks.length - 1, idx))];
cut = Math.max(cut, byLayer);
}
return Math.max(0, Math.min(points.length - 1, cut));
}
function rebuildProgress() {
clearLines();
if (!points.length) {
legend.classList.add("preview");
dirty = true;
return;
}
if (progress == null) {
legend.classList.add("preview");
const line = makeSegLine(0, points.length - 1, COLOR_PREVIEW, 0.95, true);
if (line) pathGroup.add(line);
dirty = true;
return;
}
legend.classList.remove("preview");
const cut = cutIndex();
const todo = makeSegLine(Math.max(0, cut - 1), points.length - 1, COLOR_TODO, 0.42, false);
const done = makeSegLine(0, cut, COLOR_DONE, 1, true);
if (todo) pathGroup.add(todo);
if (done) pathGroup.add(done);
dirty = true;
}
function polygonHitMesh(polyLocal, name) {
// unsichtbare Fläche für Raycast (Dreiecke fan)
if (!polyLocal || polyLocal.length < 3) return null;
const positions = [];
const c = { x: 0, y: 0 };
for (const p of polyLocal) {
c.x += p[0];
c.y += p[1];
}
c.x /= polyLocal.length;
c.y /= polyLocal.length;
for (let i = 0; i < polyLocal.length; i++) {
const a = polyLocal[i];
const b = polyLocal[(i + 1) % polyLocal.length];
positions.push(c.x, c.y, 0.15, a[0], a[1], 0.15, b[0], b[1], 0.15);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
const mesh = new THREE.Mesh(
geo,
new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.01,
side: THREE.DoubleSide,
depthWrite: false,
})
);
mesh.userData.excludeName = name;
return mesh;
}
function rebuildExclude() {
clearExclude();
const objects = excludeData.objects || [];
const excluded = new Set(excludeData.excluded || []);
const current = excludeData.current || null;
for (const obj of objects) {
const name = obj.name || "";
const poly = obj.polygon || [];
if (!name || poly.length < 2) continue;
const local = poly.map((p) => [p[0] - center.x, p[1] - center.y]);
const isEx = excluded.has(name);
const isCur = current === name;
const isSel = selectedName === name;
let color = COLOR_OBJ;
if (isEx) color = COLOR_OBJ_EX;
else if (isCur) color = COLOR_OBJ_CUR;
if (isSel && !isEx) color = 0xffffff;
const loop = [];
for (let i = 0; i < local.length; i++) {
const a = local[i];
const b = local[(i + 1) % local.length];
loop.push(a[0], a[1], 0.4, b[0], b[1], 0.4);
if (isEx || isSel) {
loop.push(a[0], a[1], 0.55, b[0], b[1], 0.55);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(loop, 3));
const line = new THREE.LineSegments(
geo,
new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: isEx ? 0.45 : 0.95,
})
);
excludeGroup.add(line);
const hit = polygonHitMesh(local, name);
if (hit) {
excludeGroup.add(hit);
excludeMeshes.push(hit);
}
if (obj.center && obj.center.length >= 2) {
const mark = new THREE.Mesh(
new THREE.CircleGeometry(1.8, 12),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: isEx ? 0.35 : 0.9,
side: THREE.DoubleSide,
depthWrite: false,
})
);
mark.position.set(obj.center[0] - center.x, obj.center[1] - center.y, 0.5);
mark.userData.excludeName = name;
excludeGroup.add(mark);
excludeMeshes.push(mark);
}
}
dirty = true;
}
function setPath(data, opts) {
const keepView = !!(opts && opts.keepView);
clearLines();
points = (data && data.points) || [];
layerBreaks = (data && data.layer_breaks && data.layer_breaks.length)
? data.layer_breaks
: [0];
if (!points.length) {
clearBed();
clearExclude();
rebuildProgress();
return;
}
const b = boundsOf(points);
center = {
x: (b.minX + b.maxX) / 2,
y: (b.minY + b.maxY) / 2,
z: b.minZ,
};
span = Math.max(b.maxX - b.minX, b.maxY - b.minY, b.maxZ - b.minZ, 40);
const midZ = (b.maxZ - b.minZ) * 0.35;
const fitDist = Math.max(60, span * 1.85);
defaultView = {
theta: 0.55,
phi: 1.05,
distance: fitDist,
tx: 0,
ty: 0,
tz: midZ,
};
if (!keepView) {
distance = fitDist;
target.set(0, 0, midZ);
theta = defaultView.theta;
phi = defaultView.phi;
}
buildBed(b);
rebuildProgress();
rebuildExclude();
dirty = true;
}
function setProgress(value, meta) {
if (value == null || value === "") {
progress = null;
} else {
progress = Math.max(0, Math.min(1, Number(value) || 0));
}
if (meta && typeof meta === "object") {
layer = meta.layer != null ? Number(meta.layer) : layer;
layers = meta.layers != null ? Number(meta.layers) : layers;
}
rebuildProgress();
}
function setExcludeObjects(data, opts) {
excludeData = data && typeof data === "object"
? {
objects: data.objects || [],
excluded: data.excluded || data.excluded_objects || [],
current: data.current || data.current_object || null,
}
: { objects: [], excluded: [], current: null };
if (opts && opts.selected != null) selectedName = opts.selected;
if (opts && typeof opts.onSelect === "function") onExcludeSelect = opts.onSelect;
rebuildExclude();
}
function setSelectedExclude(name) {
selectedName = name || null;
rebuildExclude();
}
function pickExclude(clientX, clientY) {
if (!excludeMeshes.length) return null;
const rect = canvas.getBoundingClientRect();
pointerNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointerNdc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointerNdc, camera);
const hits = raycaster.intersectObjects(excludeMeshes, false);
if (!hits.length) return null;
return hits[0].object.userData.excludeName || null;
}
function panBy(dx, dy) {
const right = new THREE.Vector3();
const up = new THREE.Vector3();
right.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
up.setFromMatrixColumn(camera.matrixWorld, 1).normalize();
const scale = distance * 0.0018;
target.addScaledVector(right, -dx * scale);
target.addScaledVector(up, dy * scale);
dirty = true;
}
function frame() {
if (!alive) return;
resize();
if (dirty || mode) {
orbit();
renderer.render(scene, camera);
dirty = false;
}
raf = requestAnimationFrame(frame);
}
function destroy() {
alive = false;
if (raf) cancelAnimationFrame(raf);
clearLines();
clearBed();
clearExclude();
renderer.dispose();
pointers.clear();
if (legend.parentNode) legend.parentNode.removeChild(legend);
if (wrap.parentNode && canvas.parentNode === wrap) {
wrap.parentNode.insertBefore(canvas, wrap);
wrap.parentNode.removeChild(wrap);
}
}
function pointerCount() {
return pointers.size;
}
function pinchDistance() {
const pts = [...pointers.values()];
if (pts.length < 2) return 0;
const dx = pts[0].x - pts[1].x;
const dy = pts[0].y - pts[1].y;
return Math.hypot(dx, dy);
}
canvas.addEventListener("pointerdown", (event) => {
canvas.setPointerCapture(event.pointerId);
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
downX = event.clientX;
downY = event.clientY;
if (pointerCount() === 2) {
mode = "pinch";
pinchDist = pinchDistance();
canvas.style.cursor = "grabbing";
return;
}
if (event.button === 1 || event.button === 2 || event.shiftKey || event.altKey) {
mode = "pan";
} else if (event.button === 0) {
mode = "orbit";
} else {
mode = null;
return;
}
lastX = event.clientX;
lastY = event.clientY;
canvas.style.cursor = "grabbing";
});
function endPointer(event) {
const moved = Math.hypot(event.clientX - downX, event.clientY - downY);
if (mode === "orbit" && moved < 6 && event.button === 0) {
const name = pickExclude(event.clientX, event.clientY);
if (name) {
selectedName = name;
rebuildExclude();
if (onExcludeSelect) onExcludeSelect(name);
}
}
pointers.delete(event.pointerId);
if (pointerCount() === 0) {
mode = null;
canvas.style.cursor = "grab";
} else if (pointerCount() === 1) {
const remaining = [...pointers.values()][0];
mode = "orbit";
lastX = remaining.x;
lastY = remaining.y;
}
}
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("pointermove", (event) => {
if (!pointers.has(event.pointerId)) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (mode === "pinch" && pointerCount() >= 2) {
const next = pinchDistance();
if (pinchDist > 0 && next > 0) {
distance = Math.max(25, Math.min(2500, distance * (pinchDist / next)));
dirty = true;
}
pinchDist = next;
return;
}
if (!mode) return;
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
if (mode === "pan") {
panBy(dx, dy);
return;
}
theta -= dx * 0.008;
phi = Math.max(PHI_MIN, Math.min(PHI_MAX, phi + dy * 0.008));
dirty = true;
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
// Chrome (Pixel-Deltas / Trackpad) feuert aggressiver als z. B. Embedded-Browser.
let dy = event.deltaY;
if (event.deltaMode === 1) dy *= 16; // Zeilen
else if (event.deltaMode === 2) dy *= canvas.clientHeight || 320; // Seiten
// sanfte Exponential-Kurve, gekappt gegen Ausreißer
const factor = Math.exp(Math.max(-80, Math.min(80, dy)) * 0.0012);
distance = Math.max(25, Math.min(2500, distance * factor));
dirty = true;
}, { passive: false });
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
canvas.addEventListener("dblclick", (event) => {
event.preventDefault();
resetView();
});
frame();
return {
setPath,
setProgress,
setExcludeObjects,
setSelectedExclude,
resize,
destroy,
resetView,
};
}
return { create };
})();
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
/* Draufsicht: Schiebe-Bahn auf dem Kobra-3-Bett. */
(function (global) {
const BED_X = 250;
const BED_Y = 235;
function SweepViewer(canvas) {
this.canvas = canvas;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
this.camera.position.set(0, 0, 10);
this.camera.lookAt(0, 0, 0);
this.root = new THREE.Group();
this.scene.add(this.root);
this._bed = null;
this._paths = null;
this._front = null;
this._resize();
}
SweepViewer.prototype._resize = function () {
const width = this.canvas.clientWidth || 280;
const height = this.canvas.clientHeight || 220;
this.renderer.setSize(width, height, false);
const aspect = width / height;
const worldW = BED_X * 1.12;
const worldH = BED_Y * 1.12;
let halfW = worldW / 2;
let halfH = worldH / 2;
if (halfW / halfH < aspect) halfW = halfH * aspect;
else halfH = halfW / aspect;
this.camera.left = -halfW;
this.camera.right = halfW;
this.camera.top = halfH;
this.camera.bottom = -halfH;
this.camera.updateProjectionMatrix();
this.renderer.render(this.scene, this.camera);
};
SweepViewer.prototype._clearGroup = function (name) {
const old = this[name];
if (!old) return;
this.root.remove(old);
old.traverse(function (obj) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
else obj.material.dispose();
}
});
this[name] = null;
};
SweepViewer.prototype.setSweep = function (step) {
const params = step || {};
const xFrom = Number(params.x_from);
const xTo = Number(params.x_to);
const xStep = Math.abs(Number(params.x_step) || 20);
const yFrom = Number(params.y_from);
const yTo = Number(params.y_to);
this._clearGroup("_bed");
this._clearGroup("_paths");
this._clearGroup("_front");
const bed = new THREE.Group();
const plate = new THREE.Mesh(
new THREE.PlaneGeometry(BED_X, BED_Y),
new THREE.MeshBasicMaterial({ color: 0x1a1a22, transparent: true, opacity: 0.95 })
);
bed.add(plate);
const edge = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.PlaneGeometry(BED_X, BED_Y)),
new THREE.LineBasicMaterial({ color: 0x555566 })
);
bed.add(edge);
bed.position.set(BED_X / 2, BED_Y / 2, 0);
this.root.add(bed);
this._bed = bed;
const front = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0.02),
new THREE.Vector3(BED_X, 0, 0.02),
]),
new THREE.LineBasicMaterial({ color: 0xf97316 })
);
this.root.add(front);
this._front = front;
const paths = new THREE.Group();
if (Number.isFinite(xFrom) && Number.isFinite(xTo) && Number.isFinite(yFrom) && Number.isFinite(yTo) && xStep > 0) {
const dir = xTo >= xFrom ? 1 : -1;
const points = [];
for (let x = xFrom, i = 0; dir > 0 ? x <= xTo + 0.01 : x >= xTo - 0.01; x += dir * xStep, i += 1) {
const yA = i % 2 === 0 ? yFrom : yTo;
const yB = i % 2 === 0 ? yTo : yFrom;
points.push(new THREE.Vector3(x, yA, 0.05), new THREE.Vector3(x, yB, 0.05));
if (dir > 0 ? x + dir * xStep <= xTo + 0.01 : x + dir * xStep >= xTo - 0.01) {
points.push(new THREE.Vector3(x, yB, 0.05), new THREE.Vector3(x + dir * xStep, yB, 0.05));
}
}
if (points.length >= 2) {
const geo = new THREE.BufferGeometry().setFromPoints(points);
paths.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: 0x3dce7c })));
}
}
this.root.add(paths);
this._paths = paths;
this._resize();
};
SweepViewer.prototype.dispose = function () {
this._clearGroup("_bed");
this._clearGroup("_paths");
this._clearGroup("_front");
this.renderer.dispose();
};
global.SweepViewer = SweepViewer;
})(window);
+7
View File
@@ -0,0 +1,7 @@
console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'),
/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
*/
function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const ee=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let ne=1234567;const ie=Math.PI/180,re=180/Math.PI;function se(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(ee[255&t]+ee[t>>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatLine truncated
+17
View File
@@ -0,0 +1,17 @@
{% macro doc_toc(current) %}
<nav class="doc-toc" aria-label="Doku">
<p>Handbuch</p>
<a href="/doku#grotti" data-sec="grotti" class="{{ 'on' if current == 'start' else '' }}">Was Grotti ist</a>
<a href="/doku#druck" data-sec="druck">Ein Druck von A bis Z</a>
<a href="/doku#ablauf" data-sec="ablauf">Ablauf (Kobra 3)</a>
<a href="/doku#seiten" data-sec="seiten">Seiten</a>
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
<p>Versionen</p>
<a href="/doku/changelog" class="{{ 'on' if current == 'changelog' else '' }}">Changelog · v{{ version }}</a>
<p>Technik</p>
{% for name in docs %}
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ name | replace('-', ' ') }}</a>
{% endfor %}
</nav>
{% endmacro %}
+183
View File
@@ -0,0 +1,183 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Aktivität</h1>
<p class="tiny">Ereignisse von Grotti und den Druckern. Neueste oben.</p>
</div>
<div class="page-head-side">
<button type="button" class="ghost" id="tl-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
<button type="button" id="tl-read"><svg class="ico"><use href="#i-check"/></svg>Alle gelesen</button>
</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>
<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>
<p id="tl-note" class="tiny"></p>
<div class="timeline" id="timeline">
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
{% endblock %}
{% block script %}
<script>
const KINDS = [
["queue", "Warteschlange"],
["ablauf", "Ablauf"],
["start", "Start / Pause"],
["fertig", "Fertig"],
["fehler", "Fehler"],
["abbruch", "Abbruch"],
["offline", "Offline"],
["spule", "Spule"],
];
const KIND_LABEL = Object.fromEntries(KINDS);
const params = new URLSearchParams(location.search);
const filter = {
range: params.get("range") || (params.get("host") ? "week" : "day"),
kinds: new Set((params.get("kind") || "").split(",").filter(Boolean)),
host: params.get("host") || "",
};
let printers = [];
function syncUrl() {
const q = new URLSearchParams();
if (filter.range !== "day") q.set("range", filter.range);
if (filter.kinds.size) q.set("kind", [...filter.kinds].join(","));
if (filter.host) q.set("host", filter.host);
const s = q.toString();
history.replaceState(null, "", s ? "?" + s : location.pathname);
}
function renderFilters() {
document.querySelectorAll("#f-range .fchip").forEach((b) => b.classList.toggle("on", b.dataset.range === filter.range));
document.getElementById("f-kind").innerHTML =
`<button type="button" class="fchip ${filter.kinds.size ? "" : "on"}" data-kind="">Alle Arten</button>` +
KINDS.map(([k, label]) =>
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
).join("");
document.getElementById("f-host").innerHTML =
`<button type="button" class="fchip ${filter.host ? "" : "on"}" data-host="">Alle Drucker</button>` +
printers.map((p) =>
`<button type="button" class="fchip ${filter.host === p.host ? "on" : ""}" data-host="${escapeHtml(p.host)}">${escapeHtml(p.name)}</button>`
).join("");
}
function sinceIso() {
if (filter.range === "all") return "";
const d = new Date();
if (filter.range === "day") d.setHours(0, 0, 0, 0);
else d.setDate(d.getDate() - 7);
return d.toISOString();
}
function dayLabel(date) {
const today = new Date(); today.setHours(0, 0, 0, 0);
const that = new Date(date); that.setHours(0, 0, 0, 0);
const diff = Math.round((today - that) / 86400000);
if (diff === 0) return "Heute";
if (diff === 1) return "Gestern";
return that.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" });
}
function itemHtml(ev) {
const t = new Date(ev.created_at);
const time = t.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
let msg = ev.message || "";
if (ev.printer && msg.startsWith(ev.printer)) msg = msg.slice(ev.printer.length).replace(/^[\s:·—-]+/, "");
const who = ev.printer
? `<a href="/#${encodeURIComponent(ev.host)}"><b>${escapeHtml(ev.printer)}</b></a>`
: `<b>Grotti</b>`;
const file = ev.filename && !msg.includes(ev.filename)
? `<div class="tl-file">${icon("file", "ico-sm")} ${escapeHtml(ev.filename)}</div>` : "";
return `<li class="tl-item ${ev.unread ? "unread" : ""}">
<span class="tl-time" title="${escapeHtml(t.toLocaleString("de-DE"))}">${time}</span>
<span class="tl-dot ${escapeHtml(ev.kind)}"></span>
<div class="tl-body">
<div class="tl-head">${who}<span class="tl-kind">${escapeHtml(KIND_LABEL[ev.kind] || ev.kind)}</span></div>
<div class="tl-msg">${escapeHtml(msg)}</div>${file}
</div>
</li>`;
}
function render(items) {
const box = document.getElementById("timeline");
const note = document.getElementById("tl-note");
if (!items.length) {
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = "";
return;
}
const groups = [];
for (const ev of items) {
const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev);
}
box.innerHTML = groups.map((g) =>
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
).join("");
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
}
async function load() {
const q = new URLSearchParams({ limit: "300" });
const since = sinceIso();
if (since) q.set("since", since);
if (filter.kinds.size) q.set("kind", [...filter.kinds].join(","));
if (filter.host) q.set("host", filter.host);
try {
const data = await api("/ereignisse?" + q.toString());
render(data.items || []);
} catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
}
}
document.addEventListener("click", (e) => {
const range = e.target.closest("[data-range], [data-range-jump]");
if (range) {
filter.range = range.dataset.range || range.dataset.rangeJump;
} else if (e.target.closest("#f-kind [data-kind]")) {
const k = e.target.closest("[data-kind]").dataset.kind;
if (!k) filter.kinds.clear();
else if (filter.kinds.has(k)) filter.kinds.delete(k);
else filter.kinds.add(k);
} else if (e.target.closest("#f-host [data-host]")) {
filter.host = e.target.closest("[data-host]").dataset.host;
} else {
return;
}
syncUrl();
renderFilters();
load();
});
document.getElementById("tl-refresh").addEventListener("click", load);
document.getElementById("tl-read").addEventListener("click", async () => {
await api("/ereignisse/gelesen", { method: "POST", json: {} });
toast("Alles als gelesen markiert.", "good");
if (typeof refreshEvents === "function") refreshEvents();
load();
});
window.onEventsRead = load;
(async () => {
try {
printers = await api("/drucker?live=0");
} catch (_) {
printers = [];
}
renderFilters();
load();
setInterval(load, 15000);
})();
</script>
{% endblock %}
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
{% extends "layout.html" %}
{% from "_doc_toc.html" import doc_toc with context %}
{% block body %}
<div class="doc-layout">
{{ doc_toc('start') }}
<article class="prose">
<h1 id="grotti">Grotti <span class="badge">v{{ version }}</span></h1>
<p class="lede">Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.</p>
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker über Moonraker an (Rinkhals auf Kobra 3 und Kobra S1). Daten liegen in PostgreSQL. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code>.</p>
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein <code>FIRMWARE_RESTART</code>.</blockquote>
<h2 id="druck">Ein Druck von A bis Z</h2>
<div class="flow-diagram">
<span class="step done">Datei</span><span class="step-sep"></span>
<span class="step done">Drucker</span><span class="step-sep"></span>
<span class="step now">Jetzt · Wenn frei · Nächster freier</span><span class="step-sep"></span>
<span class="step">Druck</span><span class="step-sep"></span>
<span class="step">Platte räumen</span>
</div>
<ol>
<li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li>
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
</ol>
<h2 id="ablauf">Ablauf (Kobra 3)</h2>
<p>Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:</p>
<div class="flow-diagram">
<span class="step">Drucken</span><span class="step-sep"></span>
<span class="step">Abkühlen</span><span class="step-sep"></span>
<span class="step">Schieben</span><span class="step-sep"></span>
<span class="step">Tasten</span><span class="step-sep"></span>
<span class="step">nochmal</span>
</div>
<ul>
<li><strong>Abkühlen</strong> wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.</li>
<li><strong>Schieben</strong> fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.</li>
<li><strong>Tasten</strong> tippt mit aktivem LeviQ-Netz (ohne <code>BED_MESH_CLEAR</code>, ohne <code>G28 Z</code>). Tastpunkt einstellbar. Maß: <code>Result is z=</code> über der lokalen Platte. Über 1 mm stoppt den Ablauf.</li>
</ul>
<p>Einreihen unter <a href="/dateien?tab=ablauf">Dateien → Ablauf</a>. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter <a href="/aktivitaet">Aktivität</a>.</p>
<h2 id="seiten">Seiten</h2>
<dl class="terms">
<dt><a href="/">Drucker</a></dt><dd>Die Wand. Eine Karte je Drucker mit Zustand, Fertig-Uhrzeit, Temperaturen und Ablauf-Schritten. Klick öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad.</dd>
<dt><a href="/dateien">Dateien</a></dt><dd>Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.</dd>
<dt><a href="/kameras">Kameras</a></dt><dd>Alle Kamerabilder nebeneinander.</dd>
<dt><a href="/aktivitaet">Aktivität</a></dt><dd>Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.</dd>
<dt><a href="/protokoll">Protokoll</a></dt><dd>Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.</dd>
<dt><a href="/material">Material</a></dt><dd>Sorten, Spulen, Farben, ACE-Plätze, Etiketten.</dd>
<dt><a href="/projekte">Projekte</a></dt><dd>Aufträge mit Notiz und Kalkulation.</dd>
<dt><a href="/einstellungen">Einstellungen</a></dt><dd>Drucker, Telegram-Alerts, Energie.</dd>
</dl>
<h2 id="haengt">Wenn etwas hängt</h2>
<dl class="terms">
<dt>Auftrag wartet und startet nicht</dt><dd>Auf der Karte steht <em>Platte belegt</em>. Platte räumen, dann <em>Platte geräumt</em> drücken.</dd>
<dt>Drucker im Fehlerzustand</dt><dd>Auf der Karte <em>Fehler löschen</em>. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.</dd>
<dt>Ablauf gestoppt</dt><dd>Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
<dt>Drucker offline</dt><dd>Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.</dd>
<dt>ACE startet nicht</dt><dd>Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.</dd>
</dl>
<h2 id="begriffe">Begriffe</h2>
<dl class="terms">
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
<dt>Wenn frei</dt><dd>Startet, sobald der Drucker bereit und die Platte geräumt ist.</dd>
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
</dl>
<h2>Technik</h2>
<p>Die Notizen zu Aufbau und Schnittstellen:</p>
<ul>
{% for name in docs %}<li><a href="/doku/{{ name }}">{{ name | replace('-', ' ') }}</a></li>{% endfor %}
</ul>
<p>Was sich je Version geändert hat, steht im <a href="/doku/changelog">Changelog</a>.</p>
</article>
</div>
{% endblock %}
{% block script %}
<script>
(() => {
const links = [...document.querySelectorAll(".doc-toc a[data-sec]")];
const sections = links.map((a) => document.getElementById(a.dataset.sec)).filter(Boolean);
if (!sections.length || !("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
links.forEach((a) => a.classList.toggle("on", a.dataset.sec === entry.target.id));
});
}, { rootMargin: "0px 0px -70% 0px" });
sections.forEach((s) => observer.observe(s));
})();
</script>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "layout.html" %}
{% from "_doc_toc.html" import doc_toc with context %}
{% block body %}
<div class="doc-layout">
{{ doc_toc(current) }}
<article class="prose">
<p class="tiny"><a href="/doku">Doku</a> · {{ title }}</p>
{{ content }}
</article>
</div>
{% endblock %}
+349
View File
@@ -0,0 +1,349 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Einstellungen</h1>
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
</div>
</div>
<section class="card">
<h2>Dieser Browser</h2>
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
<div class="field" style="max-width:16rem">
<label for="display-mode">Anzeige</label>
<select id="display-mode">
<option value="desktop">Desktop</option>
<option value="shelf">Regal (Touch)</option>
</select>
</div>
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
<section class="card">
<h2>Automatik</h2>
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.</p>
<div class="actions"><button type="button" id="auto-save">Speichern</button></div>
<p id="auto-note"></p>
</section>
<section class="card">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem">
<label for="fqdn">FQDN</label>
<input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off">
</div>
<p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p>
<div class="actions"><button type="button" id="fqdn-save">Speichern</button></div>
<p id="fqdn-note"></p>
</section>
<section class="card">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
<tbody id="printer-rows"></tbody>
</table>
</div>
<form id="printer-add" class="grid-2" style="margin-top:1rem">
<div class="field">
<label for="add-name">Name</label>
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
</div>
<div class="field">
<label for="add-host">Host / IP</label>
<input id="add-host" required placeholder="10.10.1.60">
</div>
<div class="field">
<label for="add-family">Familie</label>
<select id="add-family">
<option value="">automatisch</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
</select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
</form>
<p id="printer-note"></p>
</section>
<section class="card">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="tg-token">Bot-Token</label>
<input id="tg-token" type="password" autocomplete="off" placeholder="123456:ABC…">
</div>
<div class="field">
<label for="tg-chat">Chat-ID</label>
<input id="tg-chat" placeholder="z. B. -100… oder User-ID">
</div>
</div>
<div class="grid-2" style="margin-top:0.75rem">
<label class="check"><input type="checkbox" id="al-fertig"> Fertig</label>
<label class="check"><input type="checkbox" id="al-fehler"> Fehler und Abbrüche</label>
<label class="check"><input type="checkbox" id="al-offline"> Offline</label>
<label class="check"><input type="checkbox" id="al-queue"> Queue (Fehler, Ablauf gestoppt)</label>
<label class="check"><input type="checkbox" id="al-ablauf"> Ablauf-Schritte</label>
<label class="check"><input type="checkbox" id="al-start"> Druckstart und Pause</label>
</div>
<p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p>
<div class="actions">
<button type="button" id="tg-save">Speichern</button>
<button type="button" class="ghost" id="tg-test">Test senden</button>
</div>
<p id="tg-note"></p>
</section>
<section class="card">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<div class="grid-2">
<div class="field"><label for="en-watts">Drucker W</label><input id="en-watts" type="number" step="1"></div>
<div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div>
<div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div>
<div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div>
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div>
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
{% endblock %}
{% block script %}
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
/* showNote aus api.js: Inline-Hinweis + Toast */
function renderPrinters(rows) {
const body = document.getElementById("printer-rows");
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="">—</option>
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
tr.querySelector('[data-f="name"]').value = row.name || "";
tr.querySelector('[data-f="host"]').value = row.host || "";
tr.querySelector('[data-f="family"]').value = row.family || "";
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
try {
await api("/drucker/id/" + row.id, {
method: "PATCH",
json: {
name: tr.querySelector('[data-f="name"]').value,
host: tr.querySelector('[data-f="host"]').value,
family: tr.querySelector('[data-f="family"]').value,
locked: tr.querySelector('[data-f="locked"]').checked,
},
});
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
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);
}
});
});
tr.children[4].appendChild(save);
tr.children[4].appendChild(del);
body.appendChild(tr);
});
}
async function load() {
const [settings, printers] = await Promise.all([
api("/einstellungen"),
api("/drucker?live=0"),
]);
renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
document.getElementById("fqdn").value = settings.fqdn || "";
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
}
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
method: "PATCH",
json: { auto_queue: document.getElementById("auto-queue").checked },
});
showNote(
"auto-note",
null,
document.getElementById("auto-queue").checked ? "Automatik an." : "Automatik aus — nichts Neues startet."
);
} catch (error) {
showNote("auto-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { fqdn: document.getElementById("fqdn").value.trim() },
});
document.getElementById("fqdn").value = out.fqdn || "";
showNote(
"fqdn-note",
null,
out.fqdn
? "Gespeichert — Etiketten-QR: " + out.fqdn + "/material?spool=…"
: "Leer — Etiketten nutzen den Request-Host."
);
} catch (error) {
showNote("fqdn-note", error);
}
});
document.getElementById("printer-add").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/drucker", {
method: "POST",
json: {
name: document.getElementById("add-name").value,
host: document.getElementById("add-host").value,
family: document.getElementById("add-family").value,
},
});
document.getElementById("printer-add").reset();
showNote("printer-note", null, "Drucker angelegt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
document.getElementById("tg-save").addEventListener("click", async () => {
const token = document.getElementById("tg-token").value.trim();
const payload = {
telegram: {
enabled: document.getElementById("tg-enabled").checked,
chat_id: document.getElementById("tg-chat").value.trim(),
},
alerts: {
fertig: document.getElementById("al-fertig").checked,
fehler: document.getElementById("al-fehler").checked,
offline: document.getElementById("al-offline").checked,
queue: document.getElementById("al-queue").checked,
ablauf: document.getElementById("al-ablauf").checked,
start: document.getElementById("al-start").checked,
},
};
if (token) payload.telegram.bot_token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("tg-note", null, "Telegram gespeichert.");
load();
} catch (error) {
showNote("tg-note", error);
}
});
document.getElementById("tg-test").addEventListener("click", async () => {
try {
await api("/telegram/test", { method: "POST", json: {} });
showNote("tg-note", null, "Testnachricht gesendet.");
} catch (error) {
showNote("tg-note", error);
}
});
document.getElementById("en-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
method: "PATCH",
json: {
energy: {
watts: Number(document.getElementById("en-watts").value),
euro_per_kwh: Number(document.getElementById("en-kwh").value),
dryer_watts: Number(document.getElementById("en-dryer").value),
dry_hours: Number(document.getElementById("en-dryh").value),
waste_percent: Number(document.getElementById("en-waste").value),
utilization: Number(document.getElementById("en-util").value),
},
},
});
showNote("en-note", null, "Energie gespeichert.");
} catch (error) {
showNote("en-note", error);
}
});
(function initDisplayMode() {
const sel = document.getElementById("display-mode");
if (!sel) return;
try {
sel.value = localStorage.getItem("grotti-display") === "shelf" ? "shelf" : "desktop";
} catch (error) {
sel.value = "desktop";
}
sel.addEventListener("change", () => {
if (typeof setDisplayMode === "function") setDisplayMode(sel.value);
else {
try { localStorage.setItem("grotti-display", sel.value === "shelf" ? "shelf" : "desktop"); } catch (error) {}
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
document.body.classList.toggle("shelf", sel.value === "shelf");
}
const note = document.getElementById("display-note");
if (note) {
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
});
})();
load().catch((error) => showNote("printer-note", error));
</script>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 22mm;
gap: 3mm;
align-items: center;
}
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
.dot {
width: 3.2mm;
height: 3.2mm;
border-radius: 50%;
border: 0.2mm solid rgba(0,0,0,0.25);
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
#qr { width: 20mm; height: 20mm; }
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/material?spool={{ spool.id }}">Zurück</a>
</div>
<div class="sheet">
<div class="meta">
<h1>{{ spool.name }}</h1>
<p class="code">{{ spool.chargen_nr or spool.code }}</p>
<p>
{% if spool.colors %}
<span class="dots">
{% for color in spool.colors %}
<span class="dot" style="background:{{ color.hex }}"></span>
{% endfor %}
</span>
{% endif %}
{{ spool.color or spool.material_type }}
</p>
<p>{{ spool.material_type }}{% if spool.brand %} · {{ spool.brand }}{% endif %}</p>
<p>{{ '%.0f'|format(spool.remaining_g) }} g{% if spool.ace_slot %} · ACE {{ spool.ace_slot }}{% endif %}</p>
{% if spool.hersteller_charge %}
<p>Hersteller: {{ spool.hersteller_charge }}</p>
{% endif %}
</div>
<canvas id="qr" width="120" height="120"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 120,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
});
</script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
{% 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 %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Kameras</h1>
<p class="tiny">Livebilder aller Drucker.</p>
</div>
<span id="when"></span>
</div>
<div class="camera-grid" id="grid"></div>
{% endblock %}
{% block script %}
<script>
const labels = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
};
const tiles = new Map();
function shownState(printer) {
const state = printer.state || "offline";
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
if ((state === "standby" || state === "complete") && hot) return "heating";
return state;
}
function metaLine(printer) {
const state = shownState(printer);
const busy = state === "printing" || state === "paused";
const bits = [labels[state] || state];
if (busy) {
const pct = Math.round((printer.progress || 0) * 100);
bits.push(pct + " %");
const eta = finishClock(printer.remain);
if (eta) bits.push("fertig " + eta);
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);
return bits.join(" · ");
}
function ensureTile(printer) {
let tile = tiles.get(printer.host);
if (tile) return tile;
tile = document.createElement("figure");
tile.className = "camera-tile";
tile.dataset.host = printer.host;
tile.innerHTML = `
<figcaption>
<span class="cam-name"></span>
<span class="host"></span>
</figcaption>
<p class="cam-meta tiny"></p>
<img alt="" data-role="cam">
<a class="cam-url" href="#"></a>`;
tiles.set(printer.host, tile);
document.getElementById("grid").appendChild(tile);
return tile;
}
async function refresh() {
const printers = await api("/drucker?live=1");
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const grid = document.getElementById("grid");
if (!printers.length) {
tiles.clear();
grid.innerHTML = '<p class="empty">Keine Drucker eingetragen.</p>';
return;
}
if (grid.querySelector(".empty")) grid.innerHTML = "";
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.querySelector(".cam-name").textContent = printer.name;
tile.querySelector(".host").textContent = printer.host;
tile.querySelector(".cam-meta").textContent = metaLine(printer);
const path = "/api/v1/kamera?host=" + encodeURIComponent(printer.host);
const shown = "/api/v1/kamera?host=" + printer.host;
const img = tile.querySelector('[data-role="cam"]');
img.alt = "Kamera " + printer.name;
img.src = withToken(path) + "&t=" + Math.floor(Date.now() / 2000);
img.classList.remove("broken");
img.onerror = () => img.classList.add("broken");
const link = tile.querySelector(".cam-url");
link.href = withToken(path);
link.textContent = shown;
}
for (const [host, tile] of [...tiles.entries()]) {
if (!seen.has(host)) {
tile.remove();
tiles.delete(host);
}
}
}
refresh().catch((error) => console.error(error));
setInterval(() => refresh().catch(() => {}), 5000);
</script>
{% endblock %}
+431
View File
@@ -0,0 +1,431 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="token" content="{{ token }}">
<title>{{ title }} · Grotti</title>
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=36">
<script>
(function () {
try {
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
}
} catch (e) {}
try {
var q = new URLSearchParams(location.search);
if (q.has("shelf")) {
var on = q.get("shelf") === "1" || q.get("shelf") === "true";
localStorage.setItem("grotti-display", on ? "shelf" : "desktop");
q.delete("shelf");
var next = location.pathname + (q.toString() ? "?" + q : "") + location.hash;
history.replaceState(null, "", next);
}
} catch (e) {}
try {
if (localStorage.getItem("grotti-display") === "shelf") document.documentElement.classList.add("shelf");
var nav = localStorage.getItem("grotti-nav-collapsed");
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
document.documentElement.classList.add("nav-collapsed");
}
} catch (e) {}
})();
</script>
</head>
<body class="app-shell">
<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>
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">Grotti</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>
<nav class="nav-main" aria-label="Hauptmenü">
<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-send"/></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="/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>
<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="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<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="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</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>&nbsp;<a href="/aktivitaet">alle&nbsp;→</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="/ideen" class="{{ 'on' if page == 'ideen' else '' }}" title="Ideen"><svg class="ico"><use href="#i-bulb"/></svg><span>Ideen</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">
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
<a href="/doku/changelog" title="Changelog">v{{ version }}</a>
</div>
</aside>
<div class="app-main">
<main>
{% block body %}{% endblock %}
</main>
</div>
<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 '' }} nav-desk"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }} nav-shelf"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
</nav>
<script src="{{ url_for('static', filename='api.js') }}?v=23"></script>
<script>
(function applyShellClasses() {
const shelf = document.documentElement.classList.contains("shelf");
const collapsed = document.documentElement.classList.contains("nav-collapsed");
document.body.classList.toggle("shelf", shelf);
document.body.classList.toggle("nav-collapsed", collapsed);
const toggle = document.getElementById("nav-toggle");
if (toggle) {
toggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
toggle.title = collapsed ? "Menü ausklappen" : "Menü einklappen";
}
})();
const LIVE_KEY = "grotti-live-closed";
const NAV_KEY = "grotti-nav-collapsed";
const DISPLAY_KEY = "grotti-display";
function setNavCollapsed(on) {
document.documentElement.classList.toggle("nav-collapsed", on);
document.body.classList.toggle("nav-collapsed", on);
try { localStorage.setItem(NAV_KEY, on ? "1" : "0"); } catch (error) {}
const toggle = document.getElementById("nav-toggle");
if (toggle) {
toggle.setAttribute("aria-pressed", on ? "true" : "false");
toggle.title = on ? "Menü ausklappen" : "Menü einklappen";
}
}
function setDisplayMode(mode) {
const shelf = mode === "shelf";
document.documentElement.classList.toggle("shelf", shelf);
document.body.classList.toggle("shelf", shelf);
try { localStorage.setItem(DISPLAY_KEY, shelf ? "shelf" : "desktop"); } catch (error) {}
if (shelf && localStorage.getItem(NAV_KEY) == null) setNavCollapsed(true);
}
document.getElementById("nav-toggle")?.addEventListener("click", () => {
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
});
(function restoreBoxes() {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(LIVE_KEY) || "[]"); } catch (error) { closed = []; }
if (window.matchMedia("(max-width: 760px)").matches && !localStorage.getItem(LIVE_KEY)) closed = ["wait", "send"];
document.querySelectorAll("#live-panel details[data-box]").forEach((box) => {
if (closed.includes(box.dataset.box)) box.open = false;
box.addEventListener("toggle", () => {
const now = [...document.querySelectorAll("#live-panel details[data-box]")].filter((d) => !d.open).map((d) => d.dataset.box);
localStorage.setItem(LIVE_KEY, JSON.stringify(now));
});
});
})();
function queueTarget(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
}
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;
try {
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
toast(running ? "Abgebrochen." : "Aus der Warteschlange genommen.");
} catch (error) {
toast(error.message, "bad");
}
refreshPrintQueue();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
if (button) button.disabled = false;
};
if (button) armButton(button, armed, go);
else go();
}
window.cancelQueueItem = cancelQueueItem;
function runItemHtml(job) {
const rep = repeatText(job);
const steps = job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true) : "";
return `<li class="queue-item running">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(job.host || "")}"><span class="queue-name">${escapeHtml(queueTarget(job))}</span></a>
${rep ? `<span class="queue-sub">${escapeHtml(rep)}</span>` : ""}
<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${job.id}" title="Abbrechen">${icon("x")}</button></span>
</div>
${steps}
<span class="queue-msg">${escapeHtml(phaseText(job))}</span>
<span class="queue-sub">${escapeHtml(jobLabel(job.filename))}</span>
</li>`;
}
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" : ""}">
<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>
</div>
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span>
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
</li>`;
}
function directItemHtml(p) {
const pct = Math.round((Number(p.progress) || 0) * 100);
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : "";
return `<li class="queue-item running">
<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-sub">${pct} %</span>
</div>
<span class="bar slim live"><span style="width:${pct}%"></span></span>
<span class="queue-msg">${escapeHtml(STATE_LABELS[p.state] || p.state)}${eta ? " · " + escapeHtml(eta) : ""}</span>
<span class="queue-sub">${escapeHtml(jobLabel(p.filename))}</span>
</li>`;
}
let queueCache = [];
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 running = queueCache.filter((job) => job.status === "running");
const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
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;
document.getElementById("run-list").innerHTML = 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));
waitList.innerHTML = 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 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) => {
btn.onclick = () => armButton(btn, "leer?", async () => {
btn.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(btn.dataset.cleared) + "/abraeumen", { method: "POST", json: { confirm: true } });
toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); }
refreshTasks();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
});
}
document.getElementById("events-clear").addEventListener("click", async (event) => {
event.preventDefault();
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
refreshEvents();
if (typeof window.onEventsRead === "function") window.onEventsRead();
});
window.refreshPrintQueue = refreshPrintQueue;
window.refreshSendQueue = refreshSendQueue;
refreshSendQueue();
refreshPrintQueue();
refreshEvents();
refreshTasks();
window.refreshTasks = refreshTasks;
setInterval(refreshTasks, 8000);
setInterval(refreshSendQueue, 2000);
setInterval(refreshPrintQueue, 4000);
setInterval(refreshEvents, 8000);
</script>
{% block script %}{% endblock %}
</body>
</html>
+838
View File
@@ -0,0 +1,838 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Material</h1>
<p class="tiny">Spulen am Drucker und im Regal. Sorten sind die Profile dazu.</p>
</div>
<div class="page-head-side">
<label class="scan-inline" for="scan-code">
<svg class="ico" aria-hidden="true"><use href="#i-scan"/></svg>
<input id="scan-code" autocomplete="off" placeholder="Code oder Charge · Enter" title="USB-Scanner oder Charge tippen">
</label>
<p id="scan-note" class="scan-note" hidden></p>
</div>
</div>
<div class="tabs mat-tabs" role="tablist">
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
</div>
<div id="view-spools">
<div class="page-tools mat-tools">
<div class="filter-bar" 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>
<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">
<h2>Spulen</h2>
<span class="count" 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>
<tbody id="spool-rows"></tbody>
</table>
</section>
<section class="card mat-form" id="spool-panel" hidden>
<div class="mat-form-head">
<h2 id="spool-title">Neue Spule</h2>
<button type="button" class="icon-btn" id="spool-close" title="Schließen" aria-label="Schließen"></button>
</div>
<form id="spool-form">
<input id="spool-id" type="hidden">
<div class="field">
<label for="spool-material">Sorte <span class="tip" title="Profil mit Dichte, Preis und Volumen. Verwalten unter Reiter Sorten."><svg class="ico"><use href="#i-info"/></svg></span></label>
<select id="spool-material"></select>
<p id="spool-hint" class="field-help"></p>
</div>
<div class="field">
<label for="spool-name">Name</label>
<input id="spool-name" required>
</div>
<div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
</div>
<div class="grid-2">
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<div class="grid-2" style="margin-top:0.55rem">
<div class="field">
<label for="spool-printer">Drucker</label>
<select id="spool-printer"></select>
</div>
<div class="field">
<label for="spool-slot">ACE-Platz <span class="tip" title="ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal."><svg class="ico"><use href="#i-info"/></svg></span></label>
<select id="spool-slot">
<option value="">nicht im ACE</option>
<option value="1">ACE 1 · Platz 1</option>
<option value="2">ACE 1 · Platz 2</option>
<option value="3">ACE 1 · Platz 3</option>
<option value="4">ACE 1 · Platz 4</option>
<option value="5">ACE 2 · Platz 1</option>
<option value="6">ACE 2 · Platz 2</option>
<option value="7">ACE 2 · Platz 3</option>
<option value="8">ACE 2 · Platz 4</option>
</select>
</div>
<div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="1" value="1000"></div>
<div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
</div>
<div class="actions">
<button type="submit">Merken</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>
</div>
</form>
<p id="spool-note"></p>
</section>
</div>
</div>
<div id="view-sorten" hidden>
<div class="page-tools mat-tools">
<input id="sorten-search" type="search" placeholder="Sorte suchen…" autocomplete="off">
<button type="button" id="sorten-new"><svg class="ico"><use href="#i-layers"/></svg>Neue Sorte</button>
</div>
<p id="sorten-list-note" class="tiny"></p>
<div class="split mat-split is-idle" id="sorten-layout">
<section class="card table-wrap mat-list">
<div class="lib-head">
<h2>Sorten</h2>
<span class="count" id="sorten-count">0</span>
</div>
<table>
<thead>
<tr>
<th>Sorte</th>
<th>Typ</th>
<th>Drucker</th>
<th class="num">Dichte</th>
<th class="num">mm³/s</th>
<th class="num">€/kg</th>
</tr>
</thead>
<tbody id="sorten-rows"></tbody>
</table>
</section>
<section class="card mat-form" id="sorten-panel" hidden>
<div class="mat-form-head">
<h2 id="sorten-title">Neue Sorte</h2>
<button type="button" class="icon-btn" id="sorten-close" title="Schließen"></button>
</div>
<form id="sorten-form">
<input id="sorten-id" type="hidden">
<div class="field">
<label for="sorten-name">Name</label>
<input id="sorten-name" required placeholder="z. B. Sunlu PLA+">
</div>
<div class="grid-2">
<div class="field"><label for="sorten-brand">Marke</label><input id="sorten-brand"></div>
<div class="field"><label for="sorten-type">Typ</label><input id="sorten-type" placeholder="PLA"></div>
</div>
<div class="field">
<label for="sorten-machines">Maschinen</label>
<select id="sorten-machines">
<option value="beide">beide</option>
<option value="kobra_3">nur Kobra 3</option>
<option value="kobra_s1">nur Kobra S1</option>
</select>
</div>
<div class="grid-2">
<div class="field"><label for="sorten-density">Dichte g/cm³</label><input id="sorten-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="sorten-price">€ pro kg</label><input id="sorten-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="sorten-vol">Volumen mm³/s</label><input id="sorten-vol" type="number" min="0.1" step="0.1" value="12"></div>
<div class="field"><label for="sorten-vol-s1">Volumen S1 <span class="tip" title="Leer = wie oben. Nur setzen, wenn S1 anders ist."><svg class="ico"><use href="#i-info"/></svg></span></label><input id="sorten-vol-s1" type="number" min="0.1" step="0.1" placeholder="wie oben"></div>
</div>
<div class="field">
<label for="sorten-note">Notiz</label>
<input id="sorten-note" placeholder="optional">
</div>
<div class="actions">
<button type="submit">Merken</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>
</form>
<p id="sorten-note-msg"></p>
</section>
</div>
</div>
{% endblock %}
{% block script %}
<script>
let spools = [];
let printers = [];
let materials = [];
let palette = { solids: [], multi: [] };
let picked = [];
let colorLabel = "";
let autoName = "";
let spoolFilter = "all";
let spoolSearch = "";
let sortenSearch = "";
let view = "spools";
let spoolPanelOpen = false;
let sortenPanelOpen = false;
document.getElementById("spool-close").innerHTML = icon("x");
document.getElementById("sorten-close").innerHTML = icon("x");
function dots(colors) {
const wrap = document.createElement("span");
wrap.className = "dots";
(colors || []).forEach((color) => {
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = color.hex;
dot.title = color.name;
wrap.appendChild(dot);
});
return wrap;
}
function slotLabel(slot) {
if (!slot) return "";
if (slot <= 4) return "ACE 1 · " + slot;
return "ACE 2 · " + (slot - 4);
}
function printerName(host) {
return (printers.find((p) => p.host === host) || {}).name || host || "";
}
function ortLabel(spool) {
if (!spool.printer_host) return "Regal";
const name = printerName(spool.printer_host);
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
return name + " · extern";
}
function selectedMaterial() {
const id = document.getElementById("spool-material").value;
return materials.find((row) => String(row.id) === id) || null;
}
function volumeOf(material) {
const host = document.getElementById("spool-printer").value;
const printer = printers.find((row) => row.host === host);
if (printer && printer.family === "kobra_s1" && material.volumetric_s1 != null) return material.volumetric_s1;
return material.volumetric;
}
function showHint() {
const material = selectedMaterial();
const node = document.getElementById("spool-hint");
if (!material) {
node.textContent = "";
return;
}
const host = document.getElementById("spool-printer").value;
const printer = printers.find((row) => row.host === host);
const parts = [];
if (printer && material.machines !== "beide" && material.machines !== printer.family) {
parts.push("Diese Sorte läuft bei dir " + material.machines_label + ".");
} else if (material.machines !== "beide") {
parts.push(material.machines_label.charAt(0).toUpperCase() + material.machines_label.slice(1) + ".");
}
if (material.note) parts.push(material.note);
node.textContent = parts.join(" ");
}
function applyMaterial(material, rename) {
if (!material) return;
document.getElementById("spool-material").value = String(material.id);
document.getElementById("spool-brand").value = material.brand;
document.getElementById("spool-type").value = material.material_type;
document.getElementById("spool-density").value = material.density;
document.getElementById("spool-price").value = material.price_per_kg;
document.getElementById("spool-vol").value = volumeOf(material);
const name = document.getElementById("spool-name");
if (rename && (!name.value || name.value === autoName)) {
autoName = material.name;
name.value = material.name;
}
showHint();
}
function colorSummary() {
if (!picked.length) return "noch keine";
return colorLabel || picked.map((c) => c.name).join(" / ");
}
function renderPicked() {
const node = document.getElementById("color-picked");
node.innerHTML = "";
const sum = document.getElementById("color-sum");
if (!picked.length) {
node.textContent = "Farbe tippen — einfarbig oder Mehrton.";
sum.textContent = "noch keine";
return;
}
node.appendChild(dots(picked));
const label = document.createElement("span");
label.textContent = colorLabel || picked.map((color) => color.name).join(" / ");
node.appendChild(label);
sum.textContent = colorSummary();
}
function renderPalette() {
const mix = document.getElementById("color-mix").checked;
const solids = document.getElementById("color-solids");
const multi = document.getElementById("color-multi");
solids.innerHTML = "";
multi.innerHTML = "";
palette.solids.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "swatch";
const on = mix
? picked.some((color) => color.hex === item.colors[0].hex)
: picked.length === 1 && picked[0].hex === item.colors[0].hex && colorLabel === item.name;
if (on) button.classList.add("on");
button.appendChild(dots(item.colors));
button.appendChild(document.createTextNode(item.name));
button.addEventListener("click", () => chooseSolid(item));
solids.appendChild(button);
});
palette.multi.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "swatch";
if (colorLabel === item.name) button.classList.add("on");
button.appendChild(dots(item.colors));
button.appendChild(document.createTextNode(item.name));
button.addEventListener("click", () => chooseMulti(item));
multi.appendChild(button);
});
renderPicked();
}
function chooseSolid(item) {
const color = item.colors[0];
if (document.getElementById("color-mix").checked) {
const index = picked.findIndex((row) => row.hex === color.hex);
if (index >= 0) picked.splice(index, 1);
else if (picked.length < 8) picked.push(color);
colorLabel = picked.map((row) => row.name).join(" / ");
} else {
picked = [color];
colorLabel = item.name;
}
renderPalette();
}
function chooseMulti(item) {
picked = item.colors.map((color) => ({ name: color.name, hex: color.hex }));
colorLabel = item.name;
document.getElementById("color-mix").checked = false;
renderPalette();
}
function setView(next) {
view = next;
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.view === view);
});
document.getElementById("view-spools").hidden = view !== "spools";
document.getElementById("view-sorten").hidden = view !== "sorten";
}
function openSpoolPanel() {
spoolPanelOpen = true;
document.getElementById("spool-panel").hidden = false;
document.getElementById("spool-layout").classList.remove("is-idle");
}
function closeSpoolPanel() {
spoolPanelOpen = false;
document.getElementById("spool-panel").hidden = true;
document.getElementById("spool-layout").classList.add("is-idle");
document.getElementById("spool-id").value = "";
document.getElementById("spool-note").textContent = "";
renderSpools();
}
function openSortenPanel() {
sortenPanelOpen = true;
document.getElementById("sorten-panel").hidden = false;
document.getElementById("sorten-layout").classList.remove("is-idle");
}
function closeSortenPanel() {
sortenPanelOpen = false;
document.getElementById("sorten-panel").hidden = true;
document.getElementById("sorten-layout").classList.add("is-idle");
document.getElementById("sorten-id").value = "";
document.getElementById("sorten-note-msg").textContent = "";
renderSorten();
}
function payload() {
return {
name: document.getElementById("spool-name").value,
brand: document.getElementById("spool-brand").value,
material_type: document.getElementById("spool-type").value,
material_id: document.getElementById("spool-material").value || null,
hersteller_charge: document.getElementById("spool-hersteller").value,
color: colorLabel,
colors: picked,
printer_host: document.getElementById("spool-printer").value,
ace_slot: document.getElementById("spool-slot").value || null,
price_per_kg: Number(document.getElementById("spool-price").value),
remaining_g: Number(document.getElementById("spool-left").value),
density: Number(document.getElementById("spool-density").value),
volumetric: Number(document.getElementById("spool-vol").value),
};
}
function fill(spool) {
openSpoolPanel();
document.getElementById("spool-id").value = spool ? spool.id : "";
document.getElementById("spool-name").value = spool ? spool.name : "";
document.getElementById("spool-brand").value = spool ? spool.brand : "";
document.getElementById("spool-type").value = spool ? spool.material_type : "";
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
document.getElementById("spool-left").value = spool ? spool.remaining_g : 1000;
document.getElementById("spool-density").value = spool ? spool.density : 1.24;
document.getElementById("spool-vol").value = spool ? spool.volumetric : 12;
document.getElementById("spool-material").value = spool && spool.material_id ? String(spool.material_id) : "";
picked = spool && spool.colors ? spool.colors.map((color) => ({ name: color.name, hex: color.hex })) : [];
colorLabel = spool ? spool.color : "";
document.getElementById("color-mix").checked = false;
autoName = "";
document.getElementById("spool-title").textContent = spool ? spool.name : "Neue Spule";
document.getElementById("spool-delete").hidden = !spool;
document.getElementById("spool-label").hidden = !spool;
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
document.getElementById("color-box").open = !spool || !picked.length;
showHint();
renderPalette();
renderSpools();
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function fillSorten(material) {
openSortenPanel();
document.getElementById("sorten-id").value = material ? material.id : "";
document.getElementById("sorten-name").value = material ? material.name : "";
document.getElementById("sorten-brand").value = material ? material.brand : "";
document.getElementById("sorten-type").value = material ? material.material_type : "PLA";
document.getElementById("sorten-machines").value = material ? material.machines : "beide";
document.getElementById("sorten-density").value = material ? material.density : 1.24;
document.getElementById("sorten-price").value = material ? material.price_per_kg : 0;
document.getElementById("sorten-vol").value = material ? material.volumetric : 12;
document.getElementById("sorten-vol-s1").value =
material && material.volumetric_s1 != null ? material.volumetric_s1 : "";
document.getElementById("sorten-note").value = material ? (material.note || "") : "";
document.getElementById("sorten-title").textContent = material ? material.name : "Neue Sorte";
document.getElementById("sorten-delete").hidden = !material;
document.getElementById("sorten-use").hidden = !material;
renderSorten();
}
function shownSpools() {
const q = spoolSearch.trim().toLowerCase();
return spools.filter((spool) => {
if (spoolFilter === "loaded" && !spool.printer_host) return false;
if (spoolFilter === "shelf" && spool.printer_host) return false;
if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false;
if (!q) return true;
const hay = [
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
spool.brand, ortLabel(spool),
].join(" ").toLowerCase();
return hay.includes(q);
});
}
function shownSorten() {
const q = sortenSearch.trim().toLowerCase();
if (!q) return materials;
return materials.filter((m) =>
[m.name, m.brand, m.material_type, m.machines_label, m.note].join(" ").toLowerCase().includes(q)
);
}
function fillSelects() {
const select = document.getElementById("spool-printer");
const current = select.value;
select.innerHTML = '<option value="">Regal</option>';
printers.forEach((printer) => {
const option = document.createElement("option");
option.value = printer.host;
option.textContent = printer.name;
select.appendChild(option);
});
select.value = current;
const materialSelect = document.getElementById("spool-material");
const currentMaterial = materialSelect.value;
materialSelect.innerHTML = '<option value="">Sorte wählen</option>';
materials.forEach((material) => {
const option = document.createElement("option");
option.value = material.id;
option.textContent = material.name;
materialSelect.appendChild(option);
});
materialSelect.value = currentMaterial;
}
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
});
fillSelects();
const body = document.getElementById("spool-rows");
body.innerHTML = "";
const shown = shownSpools();
document.getElementById("spool-count").textContent = String(shown.length);
const note = document.getElementById("spool-list-note");
if (!spools.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === spools.length
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + spools.length;
const ordered = [...shown].sort((a, b) => {
const ah = a.printer_host || "zzz";
const bh = b.printer_host || "zzz";
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
});
let lastPlace = null;
ordered.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) {
lastPlace = group;
const head = document.createElement("tr");
head.className = "mat-group";
head.innerHTML = `<td colspan="6">${escapeHtml(group)}</td>`;
body.appendChild(head);
}
const tr = document.createElement("tr");
tr.className = "pick";
if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = spool.name;
tr.children[1].textContent = spool.chargen_nr || spool.code || "";
tr.children[1].classList.add("nowrap");
tr.children[2].textContent = spool.material_type;
const colorCell = tr.children[3];
if (spool.colors && spool.colors.length) colorCell.appendChild(dots(spool.colors));
const caption = document.createElement("span");
caption.className = "mat-color-cap";
caption.textContent = spool.color ? " " + spool.color : "";
colorCell.appendChild(caption);
tr.children[4].textContent = ortLabel(spool);
tr.children[4].classList.add("nowrap");
tr.children[4].title = ortLabel(spool);
const rest = Number(spool.remaining_g) || 0;
tr.children[5].textContent = grams(rest);
if (rest < 100) tr.children[5].classList.add("msg");
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
}
function renderSorten() {
fillSelects();
const body = document.getElementById("sorten-rows");
body.innerHTML = "";
const shown = shownSorten();
document.getElementById("sorten-count").textContent = String(shown.length);
const note = document.getElementById("sorten-list-note");
if (!materials.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("layers") + '<p>Noch keine Sorte.</p></div></td></tr>';
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Sorte passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === 1 ? "1 Sorte" : shown.length + " Sorten";
shown.forEach((material) => {
const tr = document.createElement("tr");
tr.className = "pick";
if (sortenPanelOpen && String(material.id) === document.getElementById("sorten-id").value) tr.classList.add("on");
const speeds = material.volumetric_s1 != null && material.volumetric_s1 !== material.volumetric
? material.volumetric + " · S1 " + material.volumetric_s1
: String(material.volumetric);
tr.innerHTML = "<td></td><td></td><td class=\"nowrap\"></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"num\"></td>";
tr.children[0].textContent = material.name;
tr.children[1].textContent = material.material_type;
tr.children[2].textContent = material.machines_label;
tr.children[3].textContent = String(material.density).replace(".", ",");
tr.children[4].textContent = speeds;
tr.children[5].textContent = euro(material.price_per_kg);
tr.addEventListener("click", () => fillSorten(material));
body.appendChild(tr);
});
}
function render() {
renderSpools();
renderSorten();
}
async function load() {
[spools, printers, materials, palette] = await Promise.all([
api("/spulen"),
api("/drucker?live=0"),
api("/materialien"),
api("/farben"),
]);
render();
renderPalette();
}
function setScanNote(text, ok) {
const note = document.getElementById("scan-note");
if (!text) {
note.hidden = true;
note.textContent = "";
return;
}
note.hidden = false;
note.textContent = text;
note.className = "scan-note " + (ok ? "ok" : "msg");
}
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.dataset.view));
});
document.getElementById("spool-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-filter]");
if (!btn) return;
spoolFilter = btn.dataset.filter || "all";
renderSpools();
});
document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || "";
renderSpools();
});
document.getElementById("sorten-search").addEventListener("input", (event) => {
sortenSearch = event.target.value || "";
renderSorten();
});
document.getElementById("spool-close").addEventListener("click", closeSpoolPanel);
document.getElementById("sorten-close").addEventListener("click", closeSortenPanel);
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
document.getElementById("spool-printer").addEventListener("change", () => {
const material = selectedMaterial();
if (material) document.getElementById("spool-vol").value = volumeOf(material);
showHint();
});
document.getElementById("color-mix").addEventListener("change", renderPalette);
document.getElementById("spool-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("spool-id").value;
const data = payload();
const host = data.printer_host;
const slot = data.ace_slot;
try {
const base = { ...data, printer_host: "", ace_slot: null };
let saved = id
? await api("/spulen/" + id, { method: "PATCH", json: base })
: await api("/spulen", { method: "POST", json: base });
if (host) {
saved = await api("/spulen/" + saved.id + "/einlegen", {
method: "POST",
json: { host, ace_slot: slot },
});
}
toast("Spule gemerkt.");
document.getElementById("spool-note").textContent = "Gemerkt.";
document.getElementById("spool-note").className = "ok";
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-new").addEventListener("click", () => {
fill(null);
document.getElementById("spool-material").focus();
});
document.getElementById("spool-shelf").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const saved = await api("/spulen/" + id, { method: "PATCH", json: { printer_host: "", ace_slot: null } });
toast("Spule liegt im Regal.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-label").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (id) window.open("/etikett/" + id, "_blank", "noopener");
});
document.getElementById("spool-delete").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
armButton(document.getElementById("spool-delete"), "Löschen?", async () => {
await api("/spulen/" + id, { method: "DELETE" });
toast("Spule gelöscht.");
await load();
closeSpoolPanel();
});
});
document.getElementById("sorten-new").addEventListener("click", () => {
fillSorten(null);
document.getElementById("sorten-name").focus();
});
document.getElementById("sorten-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("sorten-id").value;
const volS1 = document.getElementById("sorten-vol-s1").value;
const data = {
name: document.getElementById("sorten-name").value.trim(),
brand: document.getElementById("sorten-brand").value.trim(),
material_type: document.getElementById("sorten-type").value.trim() || "PLA",
machines: document.getElementById("sorten-machines").value,
density: Number(document.getElementById("sorten-density").value) || 1.24,
price_per_kg: Number(document.getElementById("sorten-price").value) || 0,
volumetric: Number(document.getElementById("sorten-vol").value) || 12,
volumetric_s1: volS1 === "" ? null : Number(volS1),
note: document.getElementById("sorten-note").value.trim(),
};
try {
const saved = id
? await api("/materialien/" + id, { method: "PATCH", json: data })
: await api("/materialien", { method: "POST", json: data });
toast("Sorte gemerkt.");
showNote("sorten-note-msg", null, "Gemerkt.");
await load();
fillSorten(saved);
} catch (error) {
showNote("sorten-note-msg", error);
}
});
document.getElementById("sorten-use").addEventListener("click", () => {
const id = document.getElementById("sorten-id").value;
const material = materials.find((row) => String(row.id) === id);
if (!material) return;
setView("spools");
fill(null);
applyMaterial(material, true);
document.getElementById("color-box").open = true;
});
document.getElementById("sorten-delete").addEventListener("click", () => {
const id = document.getElementById("sorten-id").value;
if (!id) return;
armButton(document.getElementById("sorten-delete"), "Löschen?", async () => {
try {
await api("/materialien/" + id, { method: "DELETE" });
toast("Sorte gelöscht.");
await load();
closeSortenPanel();
} catch (error) {
showNote("sorten-note-msg", error);
}
});
});
async function openByCode(raw) {
const code = (raw || "").trim();
if (!code) return;
try {
let spool = null;
if (/^\d+$/.test(code)) {
spool = spools.find((row) => String(row.id) === code) || null;
}
if (!spool && code.includes("spool=")) {
const match = code.match(/[?&]spool=(\d+)/);
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
}
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
setView("spools");
fill(spool);
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
document.getElementById("scan-code").select();
} catch (error) {
setScanNote(error.message || String(error), false);
}
}
document.getElementById("scan-code").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
openByCode(event.target.value);
}
});
load().then(() => {
closeSpoolPanel();
closeSortenPanel();
const params = new URLSearchParams(location.search);
const spoolId = params.get("spool");
if (spoolId) {
const spool = spools.find((row) => String(row.id) === spoolId);
if (spool) fill(spool);
else openByCode(spoolId);
}
}).catch((error) => showNote("spool-note", error));
</script>
{% endblock %}
+195
View File
@@ -0,0 +1,195 @@
{% 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 %}
+221
View File
@@ -0,0 +1,221 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Protokoll</h1>
<p class="tiny">Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.</p>
</div>
<div class="page-head-side">
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
</div>
</div>
<div class="page-tools">
<select id="run-filter-printer" title="Drucker">
<option value="">Alle Drucker</option>
</select>
<select id="run-filter-verdict" title="Urteil">
<option value="">Alle Urteile</option>
<option value="offen">offen</option>
<option value="gut">gut</option>
<option value="ausschuss">Ausschuss</option>
</select>
<label class="check"><input type="checkbox" id="run-filter-spool"> Spule fehlt</label>
</div>
<p id="run-note"></p>
<div class="card table-wrap">
<table>
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
const states = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", interrupted: "unterbrochen",
};
let runs = [];
let spools = [];
function fillPrinterFilter() {
const select = document.getElementById("run-filter-printer");
const current = select.value;
const names = [...new Set(runs.map((run) => run.printer_name).filter(Boolean))].sort((a, b) => a.localeCompare(b, "de"));
select.innerHTML = '<option value="">Alle Drucker</option>';
names.forEach((name) => {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function filteredRuns() {
const printer = document.getElementById("run-filter-printer").value;
const verdict = document.getElementById("run-filter-verdict").value;
const needSpool = document.getElementById("run-filter-spool").checked;
return runs.filter((run) => {
if (printer && run.printer_name !== printer) return false;
if (verdict === "offen" && run.verdict) return false;
if (verdict && verdict !== "offen" && run.verdict !== verdict) return false;
if (needSpool && !(run.needs_spool || (!run.deducted && run.weight_g > 0))) return false;
return true;
});
}
function render() {
fillPrinterFilter();
const body = document.getElementById("run-rows");
body.innerHTML = "";
const shown = filteredRuns();
if (!runs.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
return;
}
if (!shown.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
return;
}
shown.forEach((run) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
tr.children[0].textContent = run.printer_name;
tr.children[1].textContent = (run.filename || "").split("/").pop();
tr.children[2].textContent = states[run.state] || run.state;
tr.children[3].textContent = hours(run.duration_s);
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
tr.children[5].textContent = cost ? euro(cost) : "";
const verdict = document.createElement("select");
[["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
verdict.appendChild(option);
});
verdict.value = run.verdict || "";
verdict.addEventListener("change", async () => {
try {
await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } });
} catch (error) {
showNote("run-note", error);
}
});
tr.children[6].appendChild(verdict);
if (run.needs_spool || !run.deducted) {
const select = document.createElement("select");
const blank = document.createElement("option");
blank.value = "";
blank.textContent = run.needs_spool ? "Spule wählen" : "keine";
select.appendChild(blank);
const mine = spools.filter((s) => s.printer_host === run.printer_host);
const others = spools.filter((s) => s.printer_host !== run.printer_host);
const addOpt = (spool, prefix) => {
const option = document.createElement("option");
option.value = spool.id;
option.textContent = (prefix ? prefix + " " : "") + spool.name;
select.appendChild(option);
};
mine.forEach((s) => addOpt(s, ""));
if (mine.length && others.length) {
const sep = document.createElement("option");
sep.disabled = true;
sep.textContent = "— andere —";
select.appendChild(sep);
}
others.forEach((s) => addOpt(s, s.printer_host ? "·" : "Regal ·"));
if (run.spool_id) select.value = String(run.spool_id);
select.addEventListener("change", async () => {
if (!select.value) return;
try {
await api("/laeufe/" + run.id, { method: "PATCH", json: { spool_id: Number(select.value) } });
load();
} catch (error) {
showNote("run-note", error);
}
});
tr.children[7].appendChild(select);
} else {
const spool = spools.find((item) => item.id === run.spool_id);
tr.children[7].textContent = spool ? spool.name : "abgezogen";
}
if (run.file_id) {
const again = document.createElement("button");
again.type = "button";
again.className = "ghost tiny-btn";
again.textContent = "Nochmal";
again.title = "Denselben Druck in „wenn frei“ einreihen";
again.addEventListener("click", () => {
armButton(again, "Nochmal einreihen?", async () => {
again.disabled = true;
try {
let aceMap = [];
let bedLevel = false;
try {
const last = await api(
"/senden-zuletzt?host=" + encodeURIComponent(run.printer_host || "")
+ "&file_id=" + encodeURIComponent(run.file_id)
);
if (last) {
aceMap = last.ace_map || [];
bedLevel = !!last.bed_leveling;
}
} catch (error) { /* ohne Map weiter */ }
const payload = await api("/senden", {
method: "POST",
json: {
file_id: run.file_id,
hosts: run.printer_host ? [run.printer_host] : [],
spool_id: run.spool_id || null,
confirm: true,
when_free: true,
ace_maps: run.printer_host && aceMap.length
? { [run.printer_host]: aceMap }
: {},
bed_leveling: bedLevel,
},
});
toast((payload.count || (payload.jobs || []).length || 1) + " in Warteschlange „wenn frei“.");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) {
toast(error.message, "bad");
} finally {
again.disabled = false;
}
});
});
tr.children[8].appendChild(again);
}
body.appendChild(tr);
});
}
async function load() {
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
render();
}
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", render);
document.getElementById(id).addEventListener("input", render);
});
document.getElementById("sync").addEventListener("click", async () => {
const button = document.getElementById("sync");
button.disabled = true;
try {
const result = await api("/laeufe/abgleich", { method: "POST" });
document.getElementById("run-note").textContent = result.gelesen + " Läufe, " + result.abgezogen + " abgezogen.";
document.getElementById("run-note").className = "ok";
await load();
} catch (error) {
showNote("run-note", error);
} finally {
button.disabled = false;
}
});
load().catch((error) => showNote("run-note", error));
</script>
{% endblock %}
File diff suppressed because it is too large. Load diff
+218
View File
@@ -0,0 +1,218 @@
{% extends "layout.html" %}
{% block body %}
<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>
</div>
<div class="page-head-side">
<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>
<p id="q-note" class="tiny"></p>
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>Status</th>
<th>Datei</th>
<th>Ziel</th>
<th>Schritte</th>
<th>Meldung</th>
<th></th>
</tr>
</thead>
<tbody id="q-rows"></tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = {
running: "läuft", waiting: "wartet", error: "Fehler",
done: "fertig", cancelled: "abgebrochen", dismissed: "verworfen",
};
let jobs = [];
let liveDirect = [];
let filter = "";
function targetOf(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
}
function directRows() {
return liveDirect.map((p) => {
const pct = Math.round((Number(p.progress) || 0) * 100);
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : "";
const state = STATE_LABELS[p.state] || p.state || "läuft";
return {
id: "live:" + p.host,
status: "running",
direct: true,
host: p.host,
printer_name: p.name || p.host,
filename: p.filename || "",
message: state + (pct ? " · " + pct + " %" : "") + (eta ? " · " + eta : ""),
progress: pct,
};
});
}
function shownJobs() {
const direct = directRows();
if (filter === "history") {
return jobs.filter((j) => ["done", "cancelled", "dismissed"].includes(j.status));
}
if (filter === "running") {
return jobs.filter((j) => j.status === "running").concat(direct);
}
if (filter) return jobs.filter((j) => j.status === filter);
return jobs.filter((j) => ["running", "waiting", "error"].includes(j.status)).concat(direct);
}
function render() {
document.querySelectorAll("#q-filter .fchip").forEach((b) => {
b.classList.toggle("on", (b.dataset.status || "") === filter);
});
const body = document.getElementById("q-rows");
const shown = shownJobs();
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>';
document.getElementById("q-note").textContent = "";
return;
}
const waiting = jobs.filter((j) => j.status === "waiting");
body.innerHTML = shown.map((job) => {
const st = job.status;
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><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>
</tr>`;
}
const waitIdx = waiting.findIndex((j) => j.id === job.id);
const steps = job.is_ablauf
? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true)
: "";
const rep = repeatText(job);
const tools = [];
if (st === "waiting" && waitIdx > 0) {
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>`);
}
if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) {
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>`);
}
if (st === "error" && job.is_ablauf) {
tools.push(`<button type="button" class="icon-btn" data-resume="${job.id}" title="Fortsetzen">${icon("play")}</button>`);
tools.push(`<button type="button" class="icon-btn danger" data-dismiss="${job.id}" title="Verwerfen">${icon("x")}</button>`);
} else if (st === "waiting" || st === "running") {
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
}
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>${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>
</tr>`;
}).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
}
async function load() {
try {
const [queue, active] = await Promise.all([
api("/druck-queue?limit=120"),
api("/laeuft").catch(() => []),
]);
jobs = queue || [];
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
render();
} catch (error) {
document.getElementById("q-rows").innerHTML =
`<tr><td class="empty" colspan="6">${escapeHtml(error.message)}</td></tr>`;
}
}
document.getElementById("q-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-status]");
if (!btn) return;
filter = btn.dataset.status || "";
render();
});
document.getElementById("q-refresh").addEventListener("click", load);
document.getElementById("q-rows").addEventListener("click", async (event) => {
const cancel = event.target.closest("[data-cancel]");
if (cancel) {
const job = jobs.find((j) => j.id === Number(cancel.dataset.cancel));
if (job) await window.cancelQueueItem(job, cancel);
load();
return;
}
const resume = event.target.closest("[data-resume]");
if (resume) {
armButton(resume, "Fortsetzen?", async () => {
try {
await api("/druck-queue/" + resume.dataset.resume, {
method: "PATCH", json: { status: "running", resume: true },
});
toast("Ablauf geht weiter.");
load();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) { toast(error.message, "bad"); }
});
return;
}
const dismiss = event.target.closest("[data-dismiss]");
if (dismiss) {
armButton(dismiss, "Verwerfen?", async () => {
try {
await api("/druck-queue/" + dismiss.dataset.dismiss, {
method: "PATCH", json: { status: "dismissed" },
});
load();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) { toast(error.message, "bad"); }
});
return;
}
const prio = event.target.closest("[data-prio]");
if (prio) {
const id = Number(prio.dataset.prio);
const dir = Number(prio.dataset.dir);
const waiting = jobs.filter((j) => j.status === "waiting");
const idx = waiting.findIndex((j) => j.id === id);
const other = waiting[idx + dir];
if (!other) return;
try {
await api("/druck-queue/" + id, {
method: "PATCH", json: { priority: Number(other.priority) + dir },
});
load();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) { toast(error.message, "bad"); }
}
});
window.onQueueChanged = load;
load();
setInterval(load, 5000);
</script>
{% endblock %}