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"); const logoutBtn = document.getElementById("logout-btn"); const stlLoader = new STLLoader(); const mfLoader = new ThreeMFLoader(); // 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 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 = `/`; crumbs.querySelector(".current").textContent = name; renderProject(name); } // ---------- API helpers ---------- async function api(path, options = {}) { const res = await fetch(path, options); if (res.status === 401) { window.location.href = "/login.html"; throw new Error("Nicht angemeldet"); } 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 = `

Projekte

Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.
`; 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 = ` `; 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 = `

STL- und 3MF-Dateien dieses Projekts.
Alle herunterladen
`; header.querySelector(".view-title").textContent = name; header.querySelector('[data-action="download-project"]').href = `/api/projects/${encodeURIComponent(name)}/download`; 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 = `STL- oder 3MF-Dateien hierher ziehen 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); } function createProgressBar(afterNode, label) { const wrap = document.createElement("div"); wrap.className = "upload-progress"; wrap.innerHTML = `
0%
`; 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(); }, }; } async function uploadFiles(projectName, fileList) { const formData = new FormData(); const names = []; for (const file of fileList) { const ext = "." + file.name.split(".").pop().toLowerCase(); if (ext === ".stl" || ext === ".3mf") { formData.append("files", file); names.push(file.name); } } if (!names.length) { showToast("Nur .stl und .3mf Dateien werden unterstützt.", true); return; } const dropzone = document.querySelector(".dropzone"); const label = names.length === 1 ? names[0] : `${names.length} Dateien`; const progress = createProgressBar(dropzone, label); try { const result = await uploadWithProgress(projectName, formData, (percent) => progress.update(percent)); progress.done(); showToast(`${result.saved.length} Datei(en) hochgeladen`); setTimeout(() => progress.remove(), 600); const container = document.getElementById("file-grid-container"); if (container) await loadFileGrid(projectName, container); } catch (err) { progress.remove(); showToast("Upload fehlgeschlagen: " + err.message, true); } } 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); }); } 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)}`; const fileUrl = `/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`; const downloadLink = card.querySelector(".file-card-download"); downloadLink.href = fileUrl; downloadLink.download = file.name; 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"); 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); } } } function renderLazyPreviewPlaceholder(host, url, type, size) { host.style.position = "relative"; const wrap = document.createElement("div"); wrap.className = "preview-placeholder"; wrap.innerHTML = ` Große Datei (${formatSize(size)})
keine automatische Vorschau
`; host.appendChild(wrap); wrap.querySelector(".preview-load-btn").addEventListener("click", () => { wrap.remove(); initViewer(host, url, type); }); } // ---------- 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 ---------- 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 } })();