Download-Update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -75,6 +75,9 @@ 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);
|
||||
|
||||
@@ -161,9 +164,14 @@ async function renderProject(name) {
|
||||
<h1 class="view-title"></h1>
|
||||
<div class="view-subtitle">STL- und 3MF-Dateien dieses Projekts.</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>
|
||||
</div>
|
||||
`;
|
||||
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;
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<span class="file-card-name"></span>
|
||||
<span class="file-card-meta"></span>
|
||||
</div>
|
||||
<a class="file-card-download" title="Datei herunterladen" download>⬇</a>
|
||||
<button class="file-card-delete" type="button" title="Datei löschen">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user