Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.
This commit is contained in:
1 parent
4ddffbbeb1
commit
67a89d1794
45 files changed
+2856
-320
No files matched your search
@@ -0,0 +1,311 @@
|
||||
{% 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>
|
||||
</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);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const body = document.getElementById("proj-rows");
|
||||
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>";
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
projects.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);
|
||||
});
|
||||
}
|
||||
|
||||
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 %}
|
||||
Reference in new issue
Block a user