diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..64055f5
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,426 @@
+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 stlLoader = new STLLoader();
+const mfLoader = new ThreeMFLoader();
+
+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.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.
+
+
+ `;
+ header.querySelector(".view-title").textContent = name;
+ 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);
+}
+
+async function uploadFiles(projectName, fileList) {
+ const formData = new FormData();
+ let hasValid = false;
+ for (const file of fileList) {
+ const ext = "." + file.name.split(".").pop().toLowerCase();
+ if (ext === ".stl" || ext === ".3mf") {
+ formData.append("files", file);
+ hasValid = true;
+ }
+ }
+ if (!hasValid) {
+ showToast("Nur .stl und .3mf Dateien werden unterstützt.", true);
+ return;
+ }
+ try {
+ const result = await api(`/api/projects/${encodeURIComponent(projectName)}/upload`, {
+ method: "POST",
+ body: formData,
+ });
+ showToast(`${result.saved.length} Datei(en) hochgeladen`);
+ const container = document.getElementById("file-grid-container");
+ if (container) await loadFileGrid(projectName, container);
+ } catch (err) {
+ showToast("Upload fehlgeschlagen: " + err.message, true);
+ }
+}
+
+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)}`;
+
+ 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");
+ initViewer(canvasHost, `/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`, file.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 ----------
+
+renderDashboard();
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..ba912df
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ 3D Project Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..1993038
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,360 @@
+:root {
+ --bg: #14171a;
+ --panel: #1b1f23;
+ --panel-alt: #20252b;
+ --border: #2a2f36;
+ --border-strong: #3a4048;
+ --text: #e8eaed;
+ --text-muted: #8a929b;
+ --accent: #ff6a35;
+ --accent-dim: #7a3a22;
+ --danger: #e0605c;
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ --font-mono: "SF Mono", "Cascadia Code", Consolas, "Roboto Mono", monospace;
+ --radius: 6px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-sans);
+}
+
+a { color: inherit; text-decoration: none; }
+
+button {
+ font-family: inherit;
+ cursor: pointer;
+}
+
+/* Topbar */
+
+.topbar {
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.topbar-inner {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 18px 28px;
+ display: flex;
+ align-items: baseline;
+ gap: 14px;
+}
+
+.wordmark {
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--text);
+}
+
+.wordmark-mark {
+ color: var(--accent);
+ margin-right: 2px;
+}
+
+.crumbs {
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+.crumbs a {
+ color: var(--text-muted);
+ border-bottom: 1px solid transparent;
+}
+
+.crumbs a:hover {
+ color: var(--text);
+ border-bottom-color: var(--border-strong);
+}
+
+.crumbs .sep {
+ margin: 0 8px;
+ color: var(--border-strong);
+}
+
+.crumbs .current {
+ color: var(--text);
+}
+
+/* Layout */
+
+.view {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 32px 28px 80px;
+}
+
+.view-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+ gap: 16px;
+}
+
+.view-title {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin: 0;
+}
+
+.view-subtitle {
+ color: var(--text-muted);
+ font-size: 14px;
+ margin-top: 4px;
+}
+
+.btn {
+ border: 1px solid var(--border-strong);
+ background: transparent;
+ color: var(--text);
+ border-radius: var(--radius);
+ padding: 9px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.btn:hover {
+ border-color: var(--accent);
+}
+
+.btn-danger {
+ border-color: var(--border-strong);
+ color: var(--danger);
+}
+
+.btn-danger:hover {
+ border-color: var(--danger);
+}
+
+/* Project grid */
+
+.project-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 14px;
+}
+
+.project-card {
+ border: 1px solid var(--border);
+ background: var(--panel);
+ border-radius: var(--radius);
+ padding: 20px 18px;
+ text-align: left;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-height: 84px;
+ transition: border-color 0.15s ease, transform 0.1s ease;
+}
+
+.project-card:hover {
+ border-color: var(--border-strong);
+}
+
+.project-card-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text);
+ word-break: break-word;
+}
+
+.project-card-meta {
+ font-size: 13px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+
+.project-card--new {
+ border-style: dashed;
+ align-items: flex-start;
+ justify-content: center;
+ color: var(--text-muted);
+}
+
+.project-card--new:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.new-icon {
+ font-size: 22px;
+ line-height: 1;
+ margin-bottom: 4px;
+}
+
+.empty-state {
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+/* Inline new-project form */
+
+.new-project-form {
+ border: 1px solid var(--border-strong);
+ background: var(--panel);
+ border-radius: var(--radius);
+ padding: 20px;
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.new-project-form input[type="text"] {
+ flex: 1;
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ padding: 9px 12px;
+ font-size: 14px;
+ font-family: inherit;
+}
+
+.new-project-form input[type="text"]:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+/* Dropzone */
+
+.dropzone {
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius);
+ padding: 22px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 14px;
+ margin-bottom: 24px;
+ transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
+}
+
+.dropzone strong {
+ color: var(--text);
+ font-weight: 500;
+}
+
+.dropzone.is-dragover {
+ border-color: var(--accent);
+ color: var(--text);
+ background: rgba(255, 106, 53, 0.06);
+}
+
+/* File grid */
+
+.file-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 16px;
+}
+
+.file-card {
+ border: 1px solid var(--border);
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: hidden;
+ position: relative;
+ animation: fade-in 0.25s ease;
+}
+
+@keyframes fade-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.file-card-canvas {
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ background: var(--panel-alt);
+}
+
+.file-card-canvas canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.file-card-info {
+ padding: 12px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ border-top: 1px solid var(--border);
+}
+
+.file-card-name {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--text);
+ word-break: break-all;
+}
+
+.file-card-meta {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.file-card-delete {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 1px solid var(--border-strong);
+ background: rgba(20, 23, 26, 0.85);
+ color: var(--text-muted);
+ font-size: 15px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+
+.file-card:hover .file-card-delete {
+ opacity: 1;
+}
+
+.file-card-delete:hover {
+ color: var(--danger);
+ border-color: var(--danger);
+}
+
+.toast {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--panel-alt);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ padding: 10px 18px;
+ font-size: 13px;
+ color: var(--text);
+ z-index: 50;
+}
+
+.toast.toast--error {
+ border-color: var(--danger);
+ color: var(--danger);
+}