Download-Update

This commit is contained in:
2026-09-07 08:29:52 +02:00
parent 2c2620a624
commit 59722ee255
4 changed files with 92 additions and 6 deletions

View File

@@ -1,11 +1,13 @@
import io
import os import os
import re import re
import shutil import shutil
import zipfile
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
@@ -117,6 +119,54 @@ def get_file(name: str, filename: str):
return FileResponse(target) 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}") @app.delete("/api/projects/{name}/files/{filename}")
def delete_file(name: str, filename: str): def delete_file(name: str, filename: str):
path = safe_project_path(name) path = safe_project_path(name)

View File

@@ -75,6 +75,9 @@ async function renderDashboard() {
<h1 class="view-title">Projekte</h1> <h1 class="view-title">Projekte</h1>
<div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div> <div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div>
</div> </div>
<div class="header-actions">
<a class="btn" href="/api/download-all" download>Alle Projekte herunterladen</a>
</div>
`; `;
view.appendChild(header); view.appendChild(header);
@@ -161,9 +164,14 @@ async function renderProject(name) {
<h1 class="view-title"></h1> <h1 class="view-title"></h1>
<div class="view-subtitle">STL- und 3MF-Dateien dieses Projekts.</div> <div class="view-subtitle">STL- und 3MF-Dateien dieses Projekts.</div>
</div> </div>
<div class="header-actions">
<a class="btn" data-action="download-project" download>Alle herunterladen</a>
<button class="btn btn-danger" type="button" data-action="delete-project">Projekt löschen</button> <button class="btn btn-danger" type="button" data-action="delete-project">Projekt löschen</button>
</div>
`; `;
header.querySelector(".view-title").textContent = name; 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 () => { header.querySelector('[data-action="delete-project"]').addEventListener("click", async () => {
if (!confirm(`Projekt "${name}" inklusive aller Dateien löschen?`)) return; if (!confirm(`Projekt "${name}" inklusive aller Dateien löschen?`)) return;
try { try {
@@ -275,6 +283,11 @@ async function loadFileGrid(projectName, container) {
card.querySelector(".file-card-meta").textContent = card.querySelector(".file-card-meta").textContent =
`${file.type.toUpperCase()} · ${formatSize(file.size)}`; `${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) => { card.querySelector(".file-card-delete").addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
if (!confirm(`"${file.name}" löschen?`)) return; if (!confirm(`"${file.name}" löschen?`)) return;

View File

@@ -47,6 +47,7 @@
<span class="file-card-name"></span> <span class="file-card-name"></span>
<span class="file-card-meta"></span> <span class="file-card-meta"></span>
</div> </div>
<a class="file-card-download" title="Datei herunterladen" download></a>
<button class="file-card-delete" type="button" title="Datei löschen">×</button> <button class="file-card-delete" type="button" title="Datei löschen">×</button>
</div> </div>
</template> </template>

View File

@@ -312,26 +312,38 @@ button {
color: var(--text-muted); color: var(--text-muted);
} }
.file-card-delete { .file-card-delete,
.file-card-download {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px;
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
background: rgba(20, 23, 26, 0.85); background: rgba(20, 23, 26, 0.85);
color: var(--text-muted); color: var(--text-muted);
font-size: 15px; font-size: 13px;
line-height: 1; line-height: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
cursor: pointer;
text-decoration: none;
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease; 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; opacity: 1;
} }
@@ -340,6 +352,16 @@ button {
border-color: var(--danger); border-color: var(--danger);
} }
.file-card-download:hover {
color: var(--accent);
border-color: var(--accent);
}
.header-actions {
display: flex;
gap: 10px;
}
.toast { .toast {
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;