From 573e7d0d801647d38eafc3fc98becad691db294e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 7 Apr 2026 16:04:40 -0400 Subject: [PATCH] feat: Thumbnail grid for batch page with Download All as zip Batch page now shows files as a thumbnail grid (images render as cover-fit thumbnails, other types show icons). Added /b/{id}/dl endpoint that streams all batch files as a zip archive. Co-Authored-By: Claude Opus 4.6 --- internal/handler/batch.go | 62 ++++++++++++++++++++++++++++++ main.go | 1 + web/batch.html | 54 +++++++++++++++++--------- web/download.html | 2 +- web/index.html | 2 +- web/static/style.css | 80 ++++++++++++++++++++++++--------------- 6 files changed, 151 insertions(+), 50 deletions(-) diff --git a/internal/handler/batch.go b/internal/handler/batch.go index 8a17869..7c6322e 100644 --- a/internal/handler/batch.go +++ b/internal/handler/batch.go @@ -1,7 +1,10 @@ package handler import ( + "archive/zip" + "fmt" "html/template" + "io" "log" "net/http" "strings" @@ -95,3 +98,62 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, must-revalidate") batchTmpl.Execute(w, data) } + +// BatchDownload streams all batch files as a zip archive. +func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) { + batchID := r.PathValue("id") + if batchID == "" { + http.NotFound(w, r) + return + } + + files, err := h.store.GetBatch(batchID) + if err != nil { + log.Printf("db get batch error: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if len(files) == 0 { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, batchID)) + + zw := zip.NewWriter(w) + defer zw.Close() + + for _, f := range files { + if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) { + continue + } + if f.PasswordHash != nil { + continue // skip password-protected files + } + + // Get presigned URL for this file + dlURL, err := h.r2.PresignGet(r.Context(), f.R2Key, f.Filename, false) + if err != nil { + log.Printf("batch zip presign error for %s: %v", f.ID, err) + continue + } + + // Fetch the file from R2 + resp, err := http.Get(dlURL) + if err != nil { + log.Printf("batch zip fetch error for %s: %v", f.ID, err) + continue + } + + fw, err := zw.Create(f.Filename) + if err != nil { + resp.Body.Close() + log.Printf("batch zip create entry error: %v", err) + continue + } + + io.Copy(fw, resp.Body) + resp.Body.Close() + } +} diff --git a/main.go b/main.go index 92e696e..9f41161 100644 --- a/main.go +++ b/main.go @@ -72,6 +72,7 @@ func main() { mux.HandleFunc("POST /f/{id}/auth", h.AuthSubmit) mux.HandleFunc("DELETE /f/{id}", h.Delete) mux.HandleFunc("GET /b/{id}", h.Batch) + mux.HandleFunc("GET /b/{id}/dl", h.BatchDownload) // Favicon (prevent 404) mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) { diff --git a/web/batch.html b/web/batch.html index 4595f7c..6290701 100644 --- a/web/batch.html +++ b/web/batch.html @@ -4,36 +4,54 @@ {{.Count}} files — upload.jeffemmett.com - + -
-

{{.Count}} files

-

{{formatSize .TotalSize}} total

+
+
+
+

{{.Count}} files

+

{{formatSize .TotalSize}} total

+
+ Download all +
-
+
{{range .Files}} -
-
+
+ {{if eq .ThumbType "image"}} - {{.Filename}} + {{.Filename}} {{else if eq .ThumbType "video"}} - + + {{else if eq .ThumbType "audio"}} +
+ + + + +
+ {{else if eq .ThumbType "pdf"}} +
+ + + + + + +
{{else}} -
- +
+
{{end}} -
-
- {{.Filename}} - {{formatSize .Size}} -
-
- + +
{{end}} diff --git a/web/download.html b/web/download.html index 72ed3b4..7d6a562 100644 --- a/web/download.html +++ b/web/download.html @@ -4,7 +4,7 @@ {{.Filename}} — upload.jeffemmett.com - +
diff --git a/web/index.html b/web/index.html index aa9c7aa..78d0d11 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ upload.jeffemmett.com - +
diff --git a/web/static/style.css b/web/static/style.css index 6109b22..889537f 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -366,72 +366,92 @@ footer a:hover { color: var(--accent); } } /* Batch page */ -.batch-list { - display: flex; - flex-direction: column; - gap: 0.5rem; +.batch-container { + max-width: 720px; } -.batch-item { +.batch-header { display: flex; - align-items: center; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.25rem; +} + +.batch-header h1 { + margin-bottom: 0.125rem; +} + +.batch-header .btn { + margin-top: 0; + flex-shrink: 0; +} + +.batch-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; - padding: 0.625rem 0.75rem; +} + +.batch-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s; } -.batch-preview { - flex-shrink: 0; - width: 48px; - height: 48px; +.batch-card:hover { + border-color: var(--accent); +} + +.batch-card-preview { display: flex; align-items: center; justify-content: center; + width: 100%; + aspect-ratio: 1; + background: var(--bg); + overflow: hidden; + text-decoration: none; } -.batch-thumb { - max-width: 48px; - max-height: 48px; - border-radius: 4px; +.batch-card-preview img, +.batch-card-preview video { + width: 100%; + height: 100%; object-fit: cover; } -.batch-icon { +.batch-card-icon { color: var(--text-dim); } -.batch-info { - flex: 1; - min-width: 0; +.batch-card-footer { + padding: 0.5rem 0.625rem; display: flex; flex-direction: column; gap: 0.125rem; + min-width: 0; } -.batch-filename { - font-size: 0.875rem; - color: var(--accent); +.batch-card-name { + font-size: 0.75rem; + color: var(--text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.batch-filename:hover { - text-decoration: underline; +.batch-card-name:hover { + color: var(--accent); } -.batch-meta { - font-size: 0.75rem; +.batch-card-size { + font-size: 0.6875rem; color: var(--text-dim); } -.batch-actions { - flex-shrink: 0; -} - /* Download page */ .file-card { margin-top: 0;