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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-07 16:04:40 -04:00
parent a077add9c4
commit 573e7d0d80
6 changed files with 151 additions and 50 deletions

View File

@ -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()
}
}

View File

@ -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) {

View File

@ -4,36 +4,54 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Count}} files — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=3">
<link rel="stylesheet" href="/static/style.css?v=4">
</head>
<body>
<div class="container">
<h1>{{.Count}} files</h1>
<p class="subtitle">{{formatSize .TotalSize}} total</p>
<div class="container batch-container">
<div class="batch-header">
<div>
<h1>{{.Count}} files</h1>
<p class="subtitle">{{formatSize .TotalSize}} total</p>
</div>
<a href="/b/{{.BatchID}}/dl" class="btn">Download all</a>
</div>
<div class="batch-list">
<div class="batch-grid">
{{range .Files}}
<div class="batch-item">
<div class="batch-preview">
<div class="batch-card">
<a href="/f/{{.ID}}" class="batch-card-preview">
{{if eq .ThumbType "image"}}
<a href="{{.ViewURL}}"><img src="{{.PreviewURL}}" alt="{{.Filename}}" class="batch-thumb"></a>
<img src="{{.PreviewURL}}" alt="{{.Filename}}" loading="lazy">
{{else if eq .ThumbType "video"}}
<video src="{{.PreviewURL}}" class="batch-thumb" preload="metadata" muted playsinline></video>
<video src="{{.PreviewURL}}" preload="metadata" muted playsinline></video>
{{else if eq .ThumbType "audio"}}
<div class="batch-card-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
</div>
{{else if eq .ThumbType "pdf"}}
<div class="batch-card-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
{{else}}
<div class="batch-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<div class="batch-card-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
<polyline points="13 2 13 9 20 9"/>
</svg>
</div>
{{end}}
</div>
<div class="batch-info">
<a href="/f/{{.ID}}" class="batch-filename">{{.Filename}}</a>
<span class="batch-meta">{{formatSize .Size}}</span>
</div>
<div class="batch-actions">
<a href="{{.DownloadURL}}" class="btn btn-small btn-secondary" title="Download">&#8595;</a>
</a>
<div class="batch-card-footer">
<a href="/f/{{.ID}}" class="batch-card-name" title="{{.Filename}}">{{.Filename}}</a>
<span class="batch-card-size">{{formatSize .Size}}</span>
</div>
</div>
{{end}}

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Filename}} — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=4">
</head>
<body>
<div class="container">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=3">
<link rel="stylesheet" href="/static/style.css?v=4">
</head>
<body>
<div class="container">

View File

@ -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;