Download-Update
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user