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
+
+
-
+
{{range .Files}}
-
-
+
+
{{if eq .ThumbType "image"}}
- 
+

{{else if eq .ThumbType "video"}}
-
+
+ {{else if eq .ThumbType "audio"}}
+
+ {{else if eq .ThumbType "pdf"}}
+
{{else}}
-
-
-
{{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;