Files

553 lines
17 KiB
JavaScript
Raw Permalink Normal View History

2026-09-07 08:19:55 +02:00
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
const view = document.getElementById("view");
const crumbs = document.getElementById("crumbs");
const homeLink = document.getElementById("home-link");
2026-09-07 11:39:39 +02:00
const logoutBtn = document.getElementById("logout-btn");
2026-09-07 08:19:55 +02:00
const stlLoader = new STLLoader();
const mfLoader = new ThreeMFLoader();
2026-09-07 11:53:19 +02:00
// Files above this size are not auto-loaded into a 3D preview, since
// parsing a huge mesh can freeze the browser tab. The user can opt in
// via a button instead.
const PREVIEW_SIZE_LIMIT = 150 * 1024 * 1024; // 150 MB
2026-09-07 08:19:55 +02:00
let currentProject = null;
const activeViewers = []; // track for cleanup / resize
// ---------- Routing ----------
homeLink.addEventListener("click", (e) => {
e.preventDefault();
navigateHome();
});
function navigateHome() {
currentProject = null;
crumbs.innerHTML = "";
renderDashboard();
}
function navigateToProject(name) {
currentProject = name;
crumbs.innerHTML = `<span class="sep">/</span><span class="current"></span>`;
crumbs.querySelector(".current").textContent = name;
renderProject(name);
}
// ---------- API helpers ----------
async function api(path, options = {}) {
const res = await fetch(path, options);
2026-09-07 11:39:39 +02:00
if (res.status === 401) {
window.location.href = "/login.html";
throw new Error("Nicht angemeldet");
}
2026-09-07 08:19:55 +02:00
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail || detail;
} catch (_) {}
throw new Error(detail);
}
if (res.status === 204) return null;
return res.json();
}
function showToast(message, isError = false) {
const el = document.createElement("div");
el.className = "toast" + (isError ? " toast--error" : "");
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ---------- Dashboard ----------
async function renderDashboard() {
view.innerHTML = "";
const header = document.createElement("div");
header.className = "view-header";
header.innerHTML = `
<div>
<h1 class="view-title">Projekte</h1>
<div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div>
</div>
`;
view.appendChild(header);
let projects = [];
try {
projects = await api("/api/projects");
} catch (err) {
showToast("Projekte konnten nicht geladen werden: " + err.message, true);
}
const grid = document.createElement("div");
grid.className = "project-grid";
const newCardTpl = document.getElementById("tpl-new-project-card");
const newCard = newCardTpl.content.firstElementChild.cloneNode(true);
newCard.addEventListener("click", () => showNewProjectForm(grid, newCard));
grid.appendChild(newCard);
const cardTpl = document.getElementById("tpl-project-card");
for (const project of projects) {
const card = cardTpl.content.firstElementChild.cloneNode(true);
card.querySelector(".project-card-name").textContent = project.name;
card.querySelector(".project-card-meta").textContent =
project.file_count === 1 ? "1 Datei" : `${project.file_count} Dateien`;
card.addEventListener("click", () => navigateToProject(project.name));
grid.appendChild(card);
}
view.appendChild(grid);
}
function showNewProjectForm(grid, anchorCard) {
if (document.querySelector(".new-project-form")) return;
const form = document.createElement("div");
form.className = "new-project-form";
form.innerHTML = `
<input type="text" placeholder="Projektname" maxlength="80" />
<button class="btn" type="button" data-action="create">Anlegen</button>
<button class="btn" type="button" data-action="cancel">Abbrechen</button>
`;
view.insertBefore(form, grid);
const input = form.querySelector("input");
input.focus();
const cleanup = () => form.remove();
form.querySelector('[data-action="cancel"]').addEventListener("click", cleanup);
const submit = async () => {
const name = input.value.trim();
if (!name) return;
try {
await api("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
cleanup();
renderDashboard();
} catch (err) {
showToast("Projekt konnte nicht angelegt werden: " + err.message, true);
}
};
form.querySelector('[data-action="create"]').addEventListener("click", submit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
if (e.key === "Escape") cleanup();
});
}
// ---------- Project view ----------
async function renderProject(name) {
disposeViewers();
view.innerHTML = "";
const header = document.createElement("div");
header.className = "view-header";
header.innerHTML = `
<div>
<h1 class="view-title"></h1>
<div class="view-subtitle">STL- und 3MF-Dateien dieses Projekts.</div>
</div>
2026-09-07 08:29:52 +02:00
<div class="header-actions">
<a class="btn" data-action="download-project" download>Alle herunterladen</a>
<button class="btn btn-danger" type="button" data-action="delete-project">Projekt löschen</button>
</div>
2026-09-07 08:19:55 +02:00
`;
header.querySelector(".view-title").textContent = name;
2026-09-07 08:29:52 +02:00
header.querySelector('[data-action="download-project"]').href =
`/api/projects/${encodeURIComponent(name)}/download`;
2026-09-07 08:19:55 +02:00
header.querySelector('[data-action="delete-project"]').addEventListener("click", async () => {
if (!confirm(`Projekt "${name}" inklusive aller Dateien löschen?`)) return;
try {
await api(`/api/projects/${encodeURIComponent(name)}`, { method: "DELETE" });
navigateHome();
} catch (err) {
showToast("Löschen fehlgeschlagen: " + err.message, true);
}
});
view.appendChild(header);
const dropzone = document.createElement("div");
dropzone.className = "dropzone";
dropzone.innerHTML = `<strong>STL- oder 3MF-Dateien hierher ziehen</strong> oder klicken zum Auswählen`;
view.appendChild(dropzone);
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.multiple = true;
fileInput.accept = ".stl,.3mf";
fileInput.style.display = "none";
view.appendChild(fileInput);
dropzone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files.length) uploadFiles(name, fileInput.files);
fileInput.value = "";
});
["dragenter", "dragover"].forEach((evt) =>
dropzone.addEventListener(evt, (e) => {
e.preventDefault();
dropzone.classList.add("is-dragover");
})
);
["dragleave", "drop"].forEach((evt) =>
dropzone.addEventListener(evt, (e) => {
e.preventDefault();
dropzone.classList.remove("is-dragover");
})
);
dropzone.addEventListener("drop", (e) => {
const files = e.dataTransfer.files;
if (files.length) uploadFiles(name, files);
});
const gridContainer = document.createElement("div");
gridContainer.id = "file-grid-container";
view.appendChild(gridContainer);
await loadFileGrid(name, gridContainer);
}
2026-09-07 11:39:39 +02:00
function createProgressBar(afterNode, label) {
const wrap = document.createElement("div");
wrap.className = "upload-progress";
wrap.innerHTML = `
<div class="upload-progress-row">
<span class="upload-progress-name"></span>
<div class="upload-progress-track"><div class="upload-progress-fill"></div></div>
<span class="upload-progress-pct">0%</span>
</div>
`;
wrap.querySelector(".upload-progress-name").textContent = label;
afterNode.insertAdjacentElement("afterend", wrap);
const fill = wrap.querySelector(".upload-progress-fill");
const pct = wrap.querySelector(".upload-progress-pct");
return {
update(percent) {
fill.style.width = `${percent}%`;
pct.textContent = `${percent}%`;
},
done() {
fill.style.width = "100%";
fill.classList.add("is-done");
pct.textContent = "100%";
},
remove() {
wrap.remove();
},
};
}
2026-09-07 08:19:55 +02:00
async function uploadFiles(projectName, fileList) {
const formData = new FormData();
2026-09-07 11:39:39 +02:00
const names = [];
2026-09-07 08:19:55 +02:00
for (const file of fileList) {
const ext = "." + file.name.split(".").pop().toLowerCase();
if (ext === ".stl" || ext === ".3mf") {
formData.append("files", file);
2026-09-07 11:39:39 +02:00
names.push(file.name);
2026-09-07 08:19:55 +02:00
}
}
2026-09-07 11:39:39 +02:00
if (!names.length) {
2026-09-07 08:19:55 +02:00
showToast("Nur .stl und .3mf Dateien werden unterstützt.", true);
return;
}
2026-09-07 11:39:39 +02:00
const dropzone = document.querySelector(".dropzone");
const label = names.length === 1 ? names[0] : `${names.length} Dateien`;
const progress = createProgressBar(dropzone, label);
2026-09-07 08:19:55 +02:00
try {
2026-09-07 11:39:39 +02:00
const result = await uploadWithProgress(projectName, formData, (percent) => progress.update(percent));
progress.done();
2026-09-07 08:19:55 +02:00
showToast(`${result.saved.length} Datei(en) hochgeladen`);
2026-09-07 11:39:39 +02:00
setTimeout(() => progress.remove(), 600);
2026-09-07 08:19:55 +02:00
const container = document.getElementById("file-grid-container");
if (container) await loadFileGrid(projectName, container);
} catch (err) {
2026-09-07 11:39:39 +02:00
progress.remove();
2026-09-07 08:19:55 +02:00
showToast("Upload fehlgeschlagen: " + err.message, true);
}
}
2026-09-07 11:39:39 +02:00
function uploadWithProgress(projectName, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/projects/${encodeURIComponent(projectName)}/upload`);
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (err) {
reject(new Error("Ungültige Antwort vom Server"));
}
} else {
let message = xhr.statusText;
try {
message = JSON.parse(xhr.responseText).detail || message;
} catch (_) {}
reject(new Error(message));
}
});
xhr.addEventListener("error", () => reject(new Error("Netzwerkfehler")));
xhr.send(formData);
});
}
2026-09-07 08:19:55 +02:00
async function loadFileGrid(projectName, container) {
disposeViewers();
container.innerHTML = "";
let files = [];
try {
files = await api(`/api/projects/${encodeURIComponent(projectName)}/files`);
} catch (err) {
showToast("Dateien konnten nicht geladen werden: " + err.message, true);
return;
}
if (!files.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "Noch keine Dateien in diesem Projekt.";
container.appendChild(empty);
return;
}
const grid = document.createElement("div");
grid.className = "file-grid";
container.appendChild(grid);
const tpl = document.getElementById("tpl-file-card");
for (const file of files) {
const card = tpl.content.firstElementChild.cloneNode(true);
card.querySelector(".file-card-name").textContent = file.name;
card.querySelector(".file-card-meta").textContent =
`${file.type.toUpperCase()} · ${formatSize(file.size)}`;
2026-09-07 08:29:52 +02:00
const fileUrl = `/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`;
const downloadLink = card.querySelector(".file-card-download");
downloadLink.href = fileUrl;
downloadLink.download = file.name;
2026-09-07 08:19:55 +02:00
card.querySelector(".file-card-delete").addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`"${file.name}" löschen?`)) return;
try {
await api(
`/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`,
{ method: "DELETE" }
);
await loadFileGrid(projectName, container);
} catch (err) {
showToast("Löschen fehlgeschlagen: " + err.message, true);
}
});
grid.appendChild(card);
const canvasHost = card.querySelector(".file-card-canvas");
2026-09-07 11:53:19 +02:00
const fileUrlForViewer = `/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`;
if (file.size > PREVIEW_SIZE_LIMIT) {
renderLazyPreviewPlaceholder(canvasHost, fileUrlForViewer, file.type, file.size);
} else {
initViewer(canvasHost, fileUrlForViewer, file.type);
}
2026-09-07 08:19:55 +02:00
}
}
2026-09-07 11:53:19 +02:00
function renderLazyPreviewPlaceholder(host, url, type, size) {
host.style.position = "relative";
const wrap = document.createElement("div");
wrap.className = "preview-placeholder";
wrap.innerHTML = `
<span class="preview-placeholder-text">Große Datei (${formatSize(size)})<br />keine automatische Vorschau</span>
<button class="btn preview-load-btn" type="button">Vorschau laden</button>
`;
host.appendChild(wrap);
wrap.querySelector(".preview-load-btn").addEventListener("click", () => {
wrap.remove();
initViewer(host, url, type);
});
}
2026-09-07 08:19:55 +02:00
// ---------- Three.js viewer ----------
function initViewer(host, url, type) {
const width = host.clientWidth || 260;
const height = host.clientHeight || 195;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x20252b);
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 5000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
host.appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 0.9);
key.position.set(1, 1.5, 1);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.4);
fill.position.set(-1, -0.5, -1);
scene.add(fill);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
let frameId;
const animate = () => {
frameId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
const viewer = { host, renderer, scene, camera, controls, frameId: () => frameId };
activeViewers.push(viewer);
const resizeObserver = new ResizeObserver(() => {
const w = host.clientWidth;
const h = host.clientHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
resizeObserver.observe(host);
viewer.resizeObserver = resizeObserver;
const material = new THREE.MeshStandardMaterial({
color: 0xff6a35,
metalness: 0.15,
roughness: 0.55,
});
const fitAndAdd = (object3d) => {
const box = new THREE.Box3().setFromObject(object3d);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
object3d.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const distance = maxDim * 2.2;
camera.position.set(distance, distance * 0.75, distance);
camera.near = maxDim / 100;
camera.far = maxDim * 100;
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
scene.add(object3d);
};
if (type === "stl") {
stlLoader.load(
url,
(geometry) => {
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material);
fitAndAdd(mesh);
},
undefined,
() => showLoadError(host)
);
} else if (type === "3mf") {
mfLoader.load(
url,
(object) => {
fitAndAdd(object);
},
undefined,
() => showLoadError(host)
);
}
}
function showLoadError(host) {
const msg = document.createElement("div");
msg.style.cssText =
"position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#8a929b;font-size:12px;padding:12px;text-align:center;";
msg.textContent = "Vorschau konnte nicht geladen werden";
host.style.position = "relative";
host.appendChild(msg);
}
function disposeViewers() {
while (activeViewers.length) {
const v = activeViewers.pop();
cancelAnimationFrame(v.frameId());
v.resizeObserver.disconnect();
v.controls.dispose();
v.renderer.dispose();
v.scene.traverse((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();
}
});
}
}
// ---------- Init ----------
2026-09-07 11:39:39 +02:00
logoutBtn.addEventListener("click", async () => {
try {
await fetch("/api/logout", { method: "POST" });
} finally {
window.location.href = "/login.html";
}
});
(async function init() {
try {
await api("/api/me");
renderDashboard();
} catch (err) {
// api() already redirects to /login.html on 401
}
})();