Files

612 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Projekte</h1>
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
</div>
<div class="actions">
<div class="seg" id="proj-filter" role="group" aria-label="Filter">
<button type="button" class="on" data-filter="aktiv">Aktiv</button>
<button type="button" data-filter="fertig">Fertig</button>
<button type="button" data-filter="">Alle</button>
</div>
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
</div>
</div>
<div class="split edit-split is-idle" id="proj-layout">
<section class="card">
<div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-folder"/></svg>
<h2>Liste</h2>
<span class="count-pill" id="proj-count">0</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th><th class="num">Kosten</th></tr></thead>
<tbody id="proj-rows"></tbody>
</table>
<div class="pager" id="proj-pager" hidden></div>
</div>
</section>
<section class="card edit-panel" id="proj-panel" hidden>
<div class="mat-form-head">
<h2 id="proj-title">Neues Projekt</h2>
<button type="button" class="ghost tiny-btn" id="proj-edit-btn" hidden>Bearbeiten</button>
<button type="button" class="icon-btn" id="proj-close" title="Schließen" aria-label="Schließen"></button>
</div>
<form id="proj-form" novalidate hidden>
<input id="proj-id" type="hidden">
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
<div class="grid-2">
<div class="field">
<label for="proj-status">Status</label>
<select id="proj-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="field">
<label for="proj-folder">Ordner <span class="tip" title="Relativ unter dem Slice-Ordner, z. B. Projekte/2609_002 — kein D:\…"><svg class="ico"><use href="#i-info"/></svg></span></label>
<input id="proj-folder" placeholder="Projekte/…" autocomplete="off">
<p class="field-help">Leer = automatisch <code>Projekte/&lt;Name&gt;</code>. Nur Relativpfad, kein Laufwerk.</p>
</div>
</div>
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
<div class="actions">
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
<button type="button" class="ghost" id="proj-cancel-edit" hidden>Abbrechen</button>
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="proj-note" class="tiny"></p>
<div id="proj-detail" hidden>
<div class="actions" style="margin-bottom:0.5rem">
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
</div>
<div id="proj-stats" class="proj-stats"></div>
<p id="proj-suggest" class="tiny" hidden></p>
<p id="proj-meta" class="tiny"></p>
<div class="seg" id="proj-tabs" role="tablist" style="margin:0.75rem 0">
<button type="button" class="on" data-tab="files">Druckdateien</button>
<button type="button" data-tab="runs">Läufe</button>
<button type="button" data-tab="assets">Weitere Dateien</button>
</div>
<div id="proj-tab-files">
<div class="actions" style="margin-bottom:0.5rem">
<button type="button" class="btn-primary" id="proj-enqueue-all" hidden>Alle einreihen</button>
</div>
<div id="proj-gcodes" class="proj-file-list"></div>
</div>
<div id="proj-tab-runs" hidden>
<div id="proj-runs" class="proj-file-list"></div>
<div class="pager" id="proj-runs-pager" hidden></div>
</div>
<div id="proj-tab-assets" hidden>
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
<div class="actions" style="margin-bottom:0.5rem">
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="proj-asset-file" type="file" multiple hidden>
</div>
<div id="proj-assets" class="proj-file-list"></div>
</div>
</div>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
let projects = [];
let selectedId = null;
let listFilter = "aktiv";
let detailTab = "files";
let runsPage = 0;
let runsTotal = 0;
const RUNS_PAGE = 20;
/** idle | create | view | edit */
let panelMode = "idle";
let folderTouched = false;
let projPage = 1;
document.getElementById("proj-close").innerHTML = icon("x");
function openPanel() {
document.getElementById("proj-panel").hidden = false;
document.getElementById("proj-layout").classList.remove("is-idle");
document.getElementById("proj-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function closePanel() {
panelMode = "idle";
selectedId = null;
document.getElementById("proj-panel").hidden = true;
document.getElementById("proj-layout").classList.add("is-idle");
document.getElementById("proj-form").hidden = true;
document.getElementById("proj-detail").hidden = true;
document.getElementById("proj-edit-btn").hidden = true;
document.getElementById("proj-cancel-edit").hidden = true;
document.getElementById("proj-delete").hidden = true;
document.getElementById("proj-files-link").hidden = true;
document.getElementById("proj-note").textContent = "";
document.getElementById("proj-enqueue-all").hidden = true;
history.replaceState(null, "", location.pathname);
renderList();
}
function setFormValues(project) {
document.getElementById("proj-id").value = project ? project.id : "";
document.getElementById("proj-name").value = project ? (project.name || "") : "";
document.getElementById("proj-status").value = project ? (project.status || "idee") : "idee";
document.getElementById("proj-folder").value = project ? (project.folder || "") : "";
document.getElementById("proj-notes").value = project ? (project.notes || "") : "";
}
function showCreate() {
panelMode = "create";
selectedId = null;
folderTouched = false;
setFormValues(null);
document.getElementById("proj-title").textContent = "Neues Projekt";
document.getElementById("proj-form").hidden = false;
document.getElementById("proj-detail").hidden = true;
document.getElementById("proj-edit-btn").hidden = true;
document.getElementById("proj-cancel-edit").hidden = true;
document.getElementById("proj-delete").hidden = true;
document.getElementById("proj-files-link").hidden = true;
document.getElementById("proj-note").textContent = "";
document.getElementById("proj-enqueue-all").hidden = true;
openPanel();
renderList();
document.getElementById("proj-name").focus();
}
function showView(project) {
panelMode = "view";
selectedId = project.id;
setFormValues(project);
document.getElementById("proj-title").textContent = project.name || "Projekt";
document.getElementById("proj-form").hidden = true;
document.getElementById("proj-detail").hidden = false;
document.getElementById("proj-edit-btn").hidden = false;
document.getElementById("proj-cancel-edit").hidden = true;
document.getElementById("proj-delete").hidden = true;
const link = document.getElementById("proj-files-link");
link.hidden = false;
link.href = "/dateien?project=" + encodeURIComponent(project.id);
document.getElementById("proj-note").textContent = "";
openPanel();
renderList();
renderDetail(project);
}
function showEdit(project) {
panelMode = "edit";
selectedId = project.id;
folderTouched = true;
setFormValues(project);
document.getElementById("proj-title").textContent = "Bearbeiten";
document.getElementById("proj-form").hidden = false;
document.getElementById("proj-detail").hidden = true;
document.getElementById("proj-edit-btn").hidden = true;
document.getElementById("proj-cancel-edit").hidden = false;
document.getElementById("proj-delete").hidden = false;
document.getElementById("proj-files-link").hidden = true;
openPanel();
renderList();
document.getElementById("proj-name").focus();
}
function renderList() {
const body = document.getElementById("proj-rows");
const pager = document.getElementById("proj-pager");
document.getElementById("proj-count").textContent = String(projects.length);
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="5"><div class="empty-state">' + icon("folder")
+ "<p>Noch kein Projekt.</p>"
+ '<p><button type="button" class="btn-primary" id="proj-empty-new">Erstes Projekt anlegen</button></p>'
+ "</div></td></tr>";
document.getElementById("proj-empty-new")?.addEventListener("click", showCreate);
renderPager(pager, null, () => {});
return;
}
body.innerHTML = "";
const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
projPage = paged.page;
paged.items.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td><td class=\"num\"></td><td class=\"num\"></td>";
tr.children[0].textContent = project.name;
let status = STATUS_LABEL[project.status] || project.status || "";
if ((project.stats || {}).active_count > 0) status = STATUS_LABEL.druckt;
tr.children[1].textContent = status;
tr.children[2].textContent = project.folder || "—";
tr.children[2].title = project.folder || "";
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
const cost = (project.stats || {}).cost;
tr.children[4].textContent = cost ? euro(cost) : "—";
tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
projPage = p;
renderList();
});
}
function setTab(tab) {
detailTab = tab;
document.querySelectorAll("#proj-tabs [data-tab]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.tab === tab);
});
document.getElementById("proj-tab-files").hidden = tab !== "files";
document.getElementById("proj-tab-runs").hidden = tab !== "runs";
document.getElementById("proj-tab-assets").hidden = tab !== "assets";
if (tab === "runs" && selectedId) loadRuns(selectedId, 0);
if (tab === "assets" && selectedId) loadAssets(selectedId);
}
function renderDetail(project) {
const stats = project.stats || {};
const statsEl = document.getElementById("proj-stats");
const chips = [
[stats.run_count || 0, "Läufe"],
[hours(stats.duration_s) || "0 min", "Zeit"],
[grams(stats.weight_g || 0), "Filament"],
[euro(stats.cost || 0), "Kosten"],
];
if (stats.files_total) {
chips.push([stats.files_ok + "/" + stats.files_total, "Dateien ok"]);
}
statsEl.innerHTML = chips.map(([v, l]) =>
`<div class="proj-stat"><strong>${escapeHtml(String(v))}</strong><span class="tiny">${escapeHtml(l)}</span></div>`
).join("");
const meta = [];
if (project.folder) meta.push(project.folder);
if (project.created_at) meta.push("Angelegt " + formatDateTime(project.created_at));
if (stats.last_run) {
meta.push("Zuletzt " + formatDateTime(stats.last_run.started_at) + " · " + (stats.last_run.filename || ""));
}
document.getElementById("proj-meta").textContent = meta.join(" · ");
const suggest = document.getElementById("proj-suggest");
if (stats.suggest_fertig) {
suggest.hidden = false;
suggest.innerHTML = 'Alle Dateien mindestens einmal ok gedruckt. <button type="button" class="ghost" id="proj-mark-fertig">Als fertig markieren?</button>';
document.getElementById("proj-mark-fertig")?.addEventListener("click", async () => {
try {
await api("/projekte/" + project.id, { method: "PATCH", json: { status: "fertig" } });
toast("Projekt als fertig markiert.", "good");
await load();
await openProject(project.id);
} catch (error) {
showNote("proj-note", error);
}
});
} else {
suggest.hidden = true;
suggest.textContent = "";
}
const files = project.files || [];
document.getElementById("proj-enqueue-all").hidden = !files.length;
const gcodes = document.getElementById("proj-gcodes");
if (!files.length) {
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
} else {
gcodes.innerHTML = files.map((file) => {
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
const mat = [...new Set(mats)].join(" / ");
const metaBits = [
mat,
file.weight_g ? Math.round(file.weight_g) + " g" : "",
file.printed ? file.printed + "× gedruckt" : "noch nie",
file.queued ? file.queued + " in Queue" : "",
file.ok_once ? "ok" : "",
].filter(Boolean).join(" · ");
const href = "/dateien?file=" + encodeURIComponent(file.id) + "&project=" + encodeURIComponent(project.id);
return `<div class="proj-file-row">
<a class="proj-file" href="${escapeHtml(href)}">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(metaBits)}</span></a>
<button type="button" class="ghost" data-enqueue="${file.id}">Einreihen</button>
</div>`;
}).join("");
}
setTab(detailTab);
if (detailTab === "assets") loadAssets(project.id);
if (detailTab === "runs") loadRuns(project.id, runsPage);
}
async function loadRuns(projectId, offset) {
const box = document.getElementById("proj-runs");
const pager = document.getElementById("proj-runs-pager");
runsPage = Math.max(0, offset || 0);
try {
const resp = await fetch(withToken("/api/v1/projekte/" + projectId + "/laeufe?limit=" + RUNS_PAGE + "&offset=" + runsPage), {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
if (!resp.ok) throw new Error("Läufe laden fehlgeschlagen.");
const rows = await resp.json();
runsTotal = Number(resp.headers.get("X-Total-Count") || rows.length);
if (!rows.length) {
box.innerHTML = '<p class="tiny">Noch keine Läufe.</p>';
pager.hidden = true;
return;
}
box.innerHTML = rows.map((run) => {
const state = (window.RUN_STATES && RUN_STATES[run.state]) || run.state || "";
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
const meta = [state, hours(run.duration_s), cost ? euro(cost) : "", formatDateTime(run.started_at)].filter(Boolean).join(" · ");
return `<a class="proj-file" href="/protokoll/${run.id}">${escapeHtml(run.filename || "Lauf #" + run.id)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
}).join("");
const pages = Math.max(1, Math.ceil(runsTotal / RUNS_PAGE));
const page = Math.floor(runsPage / RUNS_PAGE) + 1;
renderPager(pager, {
items: rows, page, pages, total: runsTotal, pageSize: RUNS_PAGE,
from: runsTotal ? runsPage + 1 : 0,
to: Math.min(runsPage + rows.length, runsTotal),
}, (p) => loadRuns(projectId, (p - 1) * RUNS_PAGE));
} catch (error) {
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
}
}
async function loadAssets(projectId) {
const box = document.getElementById("proj-assets");
try {
const rows = await api("/projekte/" + projectId + "/assets");
if (!rows.length) {
box.innerHTML = '<p class="tiny">Noch keine weiteren Dateien.</p>';
return;
}
box.innerHTML = rows.map((item) => {
const href = withToken("/api/v1/projekte/" + projectId + "/assets/" + encodeURIComponent(item.rel).replace(/%2F/g, "/"));
return `<div class="proj-asset">
<a href="${escapeHtml(href)}" download>${escapeHtml(item.rel)}</a>
<span class="tiny">${escapeHtml(sizeText(item.size_bytes))}</span>
<button type="button" class="icon-btn" data-asset-del="${escapeHtml(item.rel)}" title="Löschen">${icon("trash")}</button>
</div>`;
}).join("");
} catch (error) {
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
}
}
function sizeText(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
}
async function enqueueFiles(projectId, fileIds) {
const payload = { confirm: true };
if (fileIds && fileIds.length) payload.file_ids = fileIds;
const out = await api("/projekte/" + projectId + "/einreihen", { method: "POST", json: payload });
toast((out.count || 0) + " in die Warteschlange.", "good");
await openProject(projectId);
}
async function openProject(id) {
try {
const row = await api("/projekte/" + id);
showView(row);
history.replaceState(null, "", "#" + id);
} catch (error) {
showNote("proj-note", error);
}
}
async function load() {
const q = listFilter ? ("?status=" + encodeURIComponent(listFilter)) : "";
projects = await api("/projekte" + q);
renderList();
if (selectedId && (panelMode === "view" || panelMode === "edit")) {
const still = projects.find((p) => String(p.id) === String(selectedId));
if (still) {
if (panelMode === "edit") {
const row = await api("/projekte/" + selectedId);
showEdit(row);
} else {
await openProject(selectedId);
}
} else {
closePanel();
}
}
}
document.getElementById("proj-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-filter]");
if (!btn) return;
listFilter = btn.dataset.filter || "";
document.querySelectorAll("#proj-filter [data-filter]").forEach((b) => b.classList.toggle("on", b === btn));
projPage = 1;
load().catch((error) => showNote("proj-note", error));
});
document.getElementById("proj-tabs").addEventListener("click", (event) => {
const btn = event.target.closest("[data-tab]");
if (!btn) return;
setTab(btn.dataset.tab);
});
document.getElementById("proj-new").addEventListener("click", () => {
history.replaceState(null, "", location.pathname);
showCreate();
});
document.getElementById("proj-close").addEventListener("click", closePanel);
document.getElementById("proj-edit-btn").addEventListener("click", async () => {
if (!selectedId) return;
try {
const row = await api("/projekte/" + selectedId);
showEdit(row);
} catch (error) {
showNote("proj-note", error);
}
});
document.getElementById("proj-cancel-edit").addEventListener("click", async () => {
if (!selectedId) {
closePanel();
return;
}
await openProject(selectedId);
});
document.getElementById("proj-folder").addEventListener("input", () => {
folderTouched = true;
});
document.getElementById("proj-name").addEventListener("input", () => {
if (folderTouched || document.getElementById("proj-id").value) return;
const name = document.getElementById("proj-name").value.trim();
const folderEl = document.getElementById("proj-folder");
if (!name) {
folderEl.value = "";
return;
}
const slug = name
.replace(/[^\w\- äöüÄÖÜß.]+/gi, "-")
.replace(/\s+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, 60) || "projekt";
folderEl.value = "Projekte/" + slug;
});
document.getElementById("proj-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("proj-id").value;
const name = document.getElementById("proj-name").value.trim();
if (!name) {
toast("Name fehlt.", "bad");
document.getElementById("proj-name").focus();
return;
}
const folderRaw = document.getElementById("proj-folder").value.trim();
if (/^[a-zA-Z]:[\\/]/.test(folderRaw) || folderRaw.startsWith("\\\\") || folderRaw.includes(":")) {
toast("Ordner nur relativ unter dem Slice-Pfad — z. B. Projekte/2609_002, kein D:\\…", "bad");
document.getElementById("proj-folder").focus();
return;
}
const payload = {
name,
status: document.getElementById("proj-status").value,
notes: document.getElementById("proj-notes").value,
folder: folderRaw,
};
const saveBtn = document.getElementById("proj-save");
saveBtn.disabled = true;
try {
const saved = id
? await api("/projekte/" + id, { method: "PATCH", json: payload })
: await api("/projekte", { method: "POST", json: payload });
toast(id ? "Projekt gespeichert." : "Projekt angelegt.", "good");
folderTouched = true;
await load();
await openProject(saved.id);
} catch (error) {
showNote("proj-note", error);
} finally {
saveBtn.disabled = false;
}
});
document.getElementById("proj-delete").addEventListener("click", () => {
const id = document.getElementById("proj-id").value;
if (!id) return;
armButton(document.getElementById("proj-delete"), "Ordner inkl. Inhalt?", async () => {
try {
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
toast("Projekt gelöscht.");
closePanel();
await load();
} catch (error) {
showNote("proj-note", error);
}
});
});
document.getElementById("proj-enqueue-all").addEventListener("click", () => {
const id = selectedId;
if (!id) return;
armButton(document.getElementById("proj-enqueue-all"), "Alle einreihen?", async () => {
try {
await enqueueFiles(id, null);
} catch (error) {
showNote("proj-note", error);
}
});
});
document.getElementById("proj-gcodes").addEventListener("click", (event) => {
const btn = event.target.closest("[data-enqueue]");
if (!btn) return;
event.preventDefault();
const id = selectedId;
const fileId = Number(btn.dataset.enqueue);
if (!id || !fileId) return;
armButton(btn, "einreihen?", async () => {
try {
await enqueueFiles(id, [fileId]);
} catch (error) {
showNote("proj-note", error);
}
});
});
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
const id = selectedId;
const files = [...(event.target.files || [])];
event.target.value = "";
if (!id || !files.length) return;
try {
for (const file of files) {
const body = new FormData();
body.append("file", file);
await api("/projekte/" + id + "/assets", { method: "POST", body });
}
toast(files.length === 1 ? "Datei hochgeladen." : files.length + " Dateien hochgeladen.");
await loadAssets(id);
} catch (error) {
showNote("proj-note", error);
}
});
document.getElementById("proj-assets").addEventListener("click", (event) => {
const btn = event.target.closest("[data-asset-del]");
if (!btn) return;
const id = selectedId;
const rel = btn.dataset.assetDel;
if (!id || !rel) return;
armButton(btn, "löschen?", async () => {
await api("/projekte/" + id + "/assets/" + encodeURIComponent(rel).replace(/%2F/g, "/"), { method: "DELETE" });
toast("Datei gelöscht.");
await loadAssets(id);
});
});
(async () => {
try {
await load();
const hash = (location.hash || "").replace(/^#/, "");
if (hash && /^\d+$/.test(hash)) await openProject(Number(hash));
} catch (error) {
showNote("proj-note", error);
}
})();
</script>
{% endblock %}