diff --git a/backend/main.py b/backend/main.py
index a2f4fdd..74a66dd 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,11 +1,13 @@
+import io
import os
import re
import shutil
+import zipfile
from pathlib import Path
from typing import List
from fastapi import FastAPI, HTTPException, UploadFile, File
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -117,6 +119,54 @@ def get_file(name: str, filename: str):
return FileResponse(target)
+@app.get("/api/download-all")
+def download_all_projects():
+ projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()]
+ files_found = False
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ for project in projects:
+ for f in project.iterdir():
+ if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
+ zf.write(f, arcname=f"{project.name}/{f.name}")
+ files_found = True
+
+ if not files_found:
+ raise HTTPException(404, "No files to download")
+
+ buffer.seek(0)
+ return StreamingResponse(
+ buffer,
+ media_type="application/zip",
+ headers={"Content-Disposition": 'attachment; filename="alle-projekte.zip"'},
+ )
+
+
+@app.get("/api/projects/{name}/download")
+def download_project(name: str):
+ path = safe_project_path(name)
+ if not path.exists():
+ raise HTTPException(404, "Project not found")
+
+ files = [f for f in path.iterdir() if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS]
+ if not files:
+ raise HTTPException(404, "Project has no files to download")
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ for f in files:
+ zf.write(f, arcname=f.name)
+ buffer.seek(0)
+
+ zip_filename = f"{name}.zip"
+ return StreamingResponse(
+ buffer,
+ media_type="application/zip",
+ headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
+ )
+
+
@app.delete("/api/projects/{name}/files/{filename}")
def delete_file(name: str, filename: str):
path = safe_project_path(name)
diff --git a/static/app.js b/static/app.js
index 64055f5..f29fd1e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -75,6 +75,9 @@ async function renderDashboard() {
Projekte
Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.
+
`;
view.appendChild(header);
@@ -161,9 +164,14 @@ async function renderProject(name) {
STL- und 3MF-Dateien dieses Projekts.
-
+
`;
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 {
@@ -275,6 +283,11 @@ async function loadFileGrid(projectName, container) {
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;
diff --git a/static/index.html b/static/index.html
index ba912df..4339058 100644
--- a/static/index.html
+++ b/static/index.html
@@ -47,6 +47,7 @@
+ ⬇
diff --git a/static/style.css b/static/style.css
index 1993038..775a9f3 100644
--- a/static/style.css
+++ b/static/style.css
@@ -312,26 +312,38 @@ button {
color: var(--text-muted);
}
-.file-card-delete {
+.file-card-delete,
+.file-card-download {
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;
+ font-size: 13px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
+ cursor: pointer;
+ text-decoration: none;
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
-.file-card:hover .file-card-delete {
+.file-card-delete {
+ right: 8px;
+ font-size: 15px;
+}
+
+.file-card-download {
+ right: 38px;
+}
+
+.file-card:hover .file-card-delete,
+.file-card:hover .file-card-download {
opacity: 1;
}
@@ -340,6 +352,16 @@ button {
border-color: var(--danger);
}
+.file-card-download:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.header-actions {
+ display: flex;
+ gap: 10px;
+}
+
.toast {
position: fixed;
bottom: 24px;