323 lines
12 KiB
HTML
323 lines
12 KiB
HTML
{% 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">
|
|
<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">
|
|
<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></tr></thead>
|
|
<tbody id="proj-rows"></tbody>
|
|
</table>
|
|
<div class="pager" id="proj-pager" hidden></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card" id="proj-panel">
|
|
<h2 id="proj-title">Neues Projekt</h2>
|
|
<form id="proj-form" novalidate>
|
|
<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/<Name></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>
|
|
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
|
|
<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>
|
|
<h3 class="section-gap">Druckdateien</h3>
|
|
<div id="proj-gcodes" class="proj-file-list"></div>
|
|
|
|
<h3 class="section-gap">Weitere Dateien</h3>
|
|
<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>
|
|
</section>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block script %}
|
|
<script>
|
|
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
|
|
let projects = [];
|
|
let selectedId = null;
|
|
|
|
function fillBlank() {
|
|
selectedId = null;
|
|
document.getElementById("proj-id").value = "";
|
|
document.getElementById("proj-name").value = "";
|
|
document.getElementById("proj-status").value = "idee";
|
|
document.getElementById("proj-folder").value = "";
|
|
document.getElementById("proj-notes").value = "";
|
|
document.getElementById("proj-title").textContent = "Neues Projekt";
|
|
document.getElementById("proj-delete").hidden = true;
|
|
document.getElementById("proj-files-link").hidden = true;
|
|
document.getElementById("proj-detail").hidden = true;
|
|
document.getElementById("proj-note").textContent = "";
|
|
renderList();
|
|
}
|
|
|
|
function fill(project) {
|
|
selectedId = project.id;
|
|
document.getElementById("proj-id").value = project.id;
|
|
document.getElementById("proj-name").value = project.name || "";
|
|
document.getElementById("proj-status").value = project.status || "idee";
|
|
document.getElementById("proj-folder").value = project.folder || "";
|
|
document.getElementById("proj-notes").value = project.notes || "";
|
|
document.getElementById("proj-title").textContent = project.name || "Projekt";
|
|
document.getElementById("proj-delete").hidden = false;
|
|
const link = document.getElementById("proj-files-link");
|
|
link.hidden = false;
|
|
link.href = "/dateien";
|
|
document.getElementById("proj-detail").hidden = false;
|
|
document.getElementById("proj-note").textContent = "";
|
|
renderList();
|
|
renderDetail(project);
|
|
}
|
|
|
|
let projPage = 1;
|
|
|
|
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="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
|
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></td><td class=\"num\"></td>";
|
|
tr.children[0].textContent = project.name;
|
|
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
|
|
tr.children[2].textContent = project.folder || "—";
|
|
tr.children[2].title = project.folder || "";
|
|
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
|
|
tr.addEventListener("click", () => openProject(project.id));
|
|
body.appendChild(tr);
|
|
});
|
|
renderPager(pager, paged, (p) => {
|
|
projPage = p;
|
|
renderList();
|
|
});
|
|
}
|
|
|
|
function renderDetail(project) {
|
|
const gcodes = document.getElementById("proj-gcodes");
|
|
const files = project.files || [];
|
|
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 meta = [mat, file.weight_g ? Math.round(file.weight_g) + " g" : ""].filter(Boolean).join(" · ");
|
|
return `<a class="proj-file" href="/dateien">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
|
|
}).join("");
|
|
}
|
|
loadAssets(project.id);
|
|
}
|
|
|
|
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 openProject(id) {
|
|
try {
|
|
const row = await api("/projekte/" + id);
|
|
fill(row);
|
|
} catch (error) {
|
|
showNote("proj-note", error);
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
projects = await api("/projekte");
|
|
renderList();
|
|
if (selectedId) {
|
|
const still = projects.find((p) => String(p.id) === String(selectedId));
|
|
if (still) await openProject(selectedId);
|
|
else fillBlank();
|
|
}
|
|
}
|
|
|
|
let folderTouched = false;
|
|
|
|
document.getElementById("proj-new").addEventListener("click", () => {
|
|
folderTouched = false;
|
|
fillBlank();
|
|
document.getElementById("proj-name").focus();
|
|
});
|
|
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.");
|
|
fillBlank();
|
|
await load();
|
|
} catch (error) {
|
|
showNote("proj-note", error);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
|
|
const id = document.getElementById("proj-id").value;
|
|
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 = document.getElementById("proj-id").value;
|
|
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);
|
|
});
|
|
});
|
|
|
|
load().catch((error) => showNote("proj-note", error));
|
|
</script>
|
|
{% endblock %}
|