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.
+
+ Alle Projekte herunterladen +
`; view.appendChild(header); @@ -161,9 +164,14 @@ async function renderProject(name) {

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 { @@ -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;