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:
parent
a077add9c4
commit
573e7d0d80
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
main.go
1
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) {
|
||||
|
|
|
|||
|
|
@ -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">↓</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}}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue