From 2523410c8454936519e51d029c749361773ab9b5 Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Mon, 18 May 2026 16:01:03 +0000 Subject: [PATCH] feat: Add "Download All" button and outside-click close to gallery Co-authored-by: aider (gemini/gemini-2.5-pro) --- app/app.py | 34 +++++++++++++++++++++++++++++++++- frontend/menu.html | 10 ++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/app.py b/app/app.py index 6dddd19..d7d22b3 100644 --- a/app/app.py +++ b/app/app.py @@ -20,6 +20,7 @@ import sqlite3 import binascii import base64 import mimetypes +import zipfile import pytz from datetime import datetime from typing import Dict, List, Optional, Tuple @@ -28,7 +29,7 @@ import math import logging import httpx 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.staticfiles import StaticFiles from starlette.websockets import WebSocketState @@ -1385,6 +1386,37 @@ async def api_files_full(path_b64: str, request: Request): 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) ---------- def ensure_invites_table() -> None: diff --git a/frontend/menu.html b/frontend/menu.html index 1eee5e3..d51807c 100644 --- a/frontend/menu.html +++ b/frontend/menu.html @@ -548,7 +548,10 @@
Files
- +
+ Download All + +
${files.length ? `
` + files.map(it => { @@ -571,7 +574,10 @@ wrap.innerHTML = html; const dlg = wrap.firstElementChild; 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;