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 + + + + +
+
+ + 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); +}