This commit is contained in:
2026-09-07 11:39:39 +02:00
parent 59722ee255
commit d0e022484e
10 changed files with 440 additions and 30 deletions

View File

@@ -6,6 +6,7 @@ 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();
@@ -37,6 +38,10 @@ function navigateToProject(name) {
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 {
@@ -75,9 +80,6 @@ async function renderDashboard() {
<h1 class="view-title">Projekte</h1>
<div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div>
</div>
<div class="header-actions">
<a class="btn" href="/api/download-all" download>Alle Projekte herunterladen</a>
</div>
`;
view.appendChild(header);
@@ -225,33 +227,102 @@ async function renderProject(name) {
await loadFileGrid(name, gridContainer);
}
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();
},
};
}
async function uploadFiles(projectName, fileList) {
const formData = new FormData();
let hasValid = false;
const names = [];
for (const file of fileList) {
const ext = "." + file.name.split(".").pop().toLowerCase();
if (ext === ".stl" || ext === ".3mf") {
formData.append("files", file);
hasValid = true;
names.push(file.name);
}
}
if (!hasValid) {
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 api(`/api/projects/${encodeURIComponent(projectName)}/upload`, {
method: "POST",
body: formData,
});
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 = "";
@@ -436,4 +507,19 @@ function disposeViewers() {
// ---------- Init ----------
renderDashboard();
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
}
})();

View File

@@ -21,6 +21,7 @@
<span class="wordmark-mark"></span> 3D Project Manager
</a>
<nav class="crumbs" id="crumbs"></nav>
<button class="logout-link" id="logout-btn" type="button" style="margin-left:auto;">Abmelden</button>
</div>
</header>

22
static/login.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login · 3D Project Manager</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="login-wrap">
<form class="login-card" id="login-form" autocomplete="off">
<div class="login-title"><span class="wordmark-mark"></span> 3D Project Manager</div>
<label class="login-label" for="password">Passwort</label>
<input class="login-input" type="password" id="password" name="password" autofocus required />
<button class="btn login-submit" type="submit">Anmelden</button>
<div class="login-error" id="login-error"></div>
</form>
</div>
<script type="module" src="/login.js"></script>
</body>
</html>

41
static/login.js Normal file
View File

@@ -0,0 +1,41 @@
const form = document.getElementById("login-form");
const passwordInput = document.getElementById("password");
const errorEl = document.getElementById("login-error");
// If already logged in, skip straight to the app.
fetch("/api/me").then((res) => {
if (res.ok) window.location.href = "/";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.textContent = "";
const submitBtn = form.querySelector(".login-submit");
submitBtn.disabled = true;
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: passwordInput.value }),
});
if (res.ok) {
window.location.href = "/";
return;
}
if (res.status === 429) {
errorEl.textContent = "Zu viele Fehlversuche. Bitte kurz warten.";
} else {
errorEl.textContent = "Falsches Passwort.";
}
passwordInput.value = "";
passwordInput.focus();
} catch (err) {
errorEl.textContent = "Verbindung fehlgeschlagen.";
} finally {
submitBtn.disabled = false;
}
});

View File

@@ -257,6 +257,63 @@ button {
background: rgba(255, 106, 53, 0.06);
}
/* Upload progress */
.upload-progress {
margin-bottom: 24px;
}
.upload-progress-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.upload-progress-row:last-child {
margin-bottom: 0;
}
.upload-progress-name {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
width: 160px;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-progress-track {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--panel-alt);
border: 1px solid var(--border);
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.15s ease;
}
.upload-progress-fill.is-done {
background: var(--accent-teal, #3fa796);
}
.upload-progress-pct {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
width: 38px;
text-align: right;
flex-shrink: 0;
}
/* File grid */
.file-grid {
@@ -380,3 +437,88 @@ button {
border-color: var(--danger);
color: var(--danger);
}
/* Login page */
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 320px;
border: 1px solid var(--border);
background: var(--panel);
border-radius: var(--radius);
padding: 28px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 6px;
}
.login-label {
font-size: 13px;
color: var(--text-muted);
}
.login-input {
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
.login-input:focus {
outline: none;
border-color: var(--accent);
}
.login-submit {
margin-top: 6px;
background: var(--accent);
border-color: var(--accent);
color: #14171a;
font-weight: 600;
}
.login-submit:hover {
background: var(--accent);
opacity: 0.9;
}
.login-submit:disabled {
opacity: 0.6;
cursor: default;
}
.login-error {
color: var(--danger);
font-size: 13px;
min-height: 16px;
}
.logout-link {
color: var(--text-muted);
font-size: 13px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 6px 12px;
}
.logout-link:hover {
color: var(--text);
border-color: var(--accent);
}