feat: Add "Download All" button and outside-click close to gallery
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
34
app/app.py
34
app/app.py
@@ -20,6 +20,7 @@ import sqlite3
|
|||||||
import binascii
|
import binascii
|
||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import zipfile
|
||||||
import pytz
|
import pytz
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
@@ -28,7 +29,7 @@ import math
|
|||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
|
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
|
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response, StreamingResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.websockets import WebSocketState
|
from starlette.websockets import WebSocketState
|
||||||
@@ -1385,6 +1386,37 @@ async def api_files_full(path_b64: str, request: Request):
|
|||||||
return FileResponse(file_path)
|
return FileResponse(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/files/zip/{path_b64}")
|
||||||
|
async def api_files_zip(path_b64: str, request: Request):
|
||||||
|
if not request.session.get("accessToken"):
|
||||||
|
return Response(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||||
|
dir_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||||
|
except Exception:
|
||||||
|
return Response(status_code=400)
|
||||||
|
|
||||||
|
if not _is_safe_path(UPLOAD_ROOT, dir_path) or not os.path.isdir(dir_path):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
for filename in sorted(os.listdir(dir_path)):
|
||||||
|
file_path = os.path.join(dir_path, filename)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
zip_file.write(file_path, filename)
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
zip_filename = f"{os.path.basename(rel_path) or 'download'}.zip"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([zip_buffer.getvalue()]),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=\"{zip_filename}\""}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------- Invites (one-time/expiring links) ----------
|
# ---------- Invites (one-time/expiring links) ----------
|
||||||
|
|
||||||
def ensure_invites_table() -> None:
|
def ensure_invites_table() -> None:
|
||||||
|
|||||||
@@ -548,8 +548,11 @@
|
|||||||
<div class="max-w-6xl w-full h-[90vh] rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4 flex flex-col">
|
<div class="max-w-6xl w-full h-[90vh] rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4 flex flex-col">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="text-lg font-medium">Files</div>
|
<div class="text-lg font-medium">Files</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/api/files/zip/${path_b64}" class="rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Download All</a>
|
||||||
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
|
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
${files.length ? `<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">` + files.map(it => {
|
${files.length ? `<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">` + files.map(it => {
|
||||||
const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : '';
|
const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : '';
|
||||||
@@ -571,7 +574,10 @@
|
|||||||
wrap.innerHTML = html;
|
wrap.innerHTML = html;
|
||||||
const dlg = wrap.firstElementChild;
|
const dlg = wrap.firstElementChild;
|
||||||
document.body.appendChild(dlg);
|
document.body.appendChild(dlg);
|
||||||
dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = () => { try { dlg.remove(); } catch {} });
|
|
||||||
|
const close = () => { try { dlg.remove(); } catch {} };
|
||||||
|
dlg.onclick = (e) => { if (e.target === e.currentTarget) close(); };
|
||||||
|
dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = close);
|
||||||
}
|
}
|
||||||
|
|
||||||
btnFilesRefresh.onclick = loadDirs;
|
btnFilesRefresh.onclick = loadDirs;
|
||||||
|
|||||||
Reference in New Issue
Block a user