feat: add thumbnail metadata and download endpoint
- Extend `BoxFile` with thumbnail path/status fields and internal URL - Populate `ThumbnailURL` when a thumbnail path is present during decoration - Add `/box/:id/thumbnails/:file_id` route and handler to serve JPEG thumbnails - Introduce thumbnail status constants to standardize processing state reportingfeat: add thumbnail metadata and download endpoint - Extend `BoxFile` with thumbnail path/status fields and internal URL - Populate `ThumbnailURL` when a thumbnail path is present during decoration - Add `/box/:id/thumbnails/:file_id` route and handler to serve JPEG thumbnails - Introduce thumbnail status constants to standardize processing state reporting
This commit is contained in:
@@ -329,6 +329,9 @@ func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
file.IconPath = IconForMimeType(file.MimeType, file.Name)
|
file.IconPath = IconForMimeType(file.MimeType, file.Name)
|
||||||
|
if file.ThumbnailPath != nil {
|
||||||
|
file.ThumbnailURL = *file.ThumbnailPath
|
||||||
|
}
|
||||||
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
|
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
|
||||||
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
|
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
|
||||||
file.IsComplete = file.Status == models.FileStatusReady
|
file.IsComplete = file.Status == models.FileStatusReady
|
||||||
|
|||||||
44
lib/boxstore/store_test.go
Normal file
44
lib/boxstore/store_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartRetentionWaitsForEveryFileToFinish(t *testing.T) {
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
RetentionSecs: 10,
|
||||||
|
Files: []models.BoxFile{
|
||||||
|
{ID: "one", Status: models.FileStatusReady},
|
||||||
|
{ID: "two", Status: models.FileStatusWork},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
t.Fatalf("expected retention to stay unset while a file is still uploading, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) {
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
RetentionSecs: 10,
|
||||||
|
Files: []models.BoxFile{
|
||||||
|
{ID: "one", Status: models.FileStatusReady},
|
||||||
|
{ID: "two", Status: models.FileStatusFailed},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := time.Now().UTC()
|
||||||
|
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
|
||||||
|
if manifest.ExpiresAt.IsZero() {
|
||||||
|
t.Fatal("expected retention to start once every file is complete or failed")
|
||||||
|
}
|
||||||
|
if manifest.ExpiresAt.Before(before.Add(9 * time.Second)) {
|
||||||
|
t.Fatalf("expected retention to start from completion time, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
246
lib/boxstore/thumbnails.go
Normal file
246
lib/boxstore/thumbnails.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/png"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
thumbnailDir = ".thumbnails"
|
||||||
|
thumbnailMaxSize = 160
|
||||||
|
)
|
||||||
|
|
||||||
|
type thumbnailTask struct {
|
||||||
|
BoxID string
|
||||||
|
FileID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartThumbnailWorker(batchSize int, interval time.Duration) {
|
||||||
|
if batchSize < 1 {
|
||||||
|
batchSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
ProcessThumbnailBatch(batchSize)
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProcessThumbnailBatch(batchSize int) int {
|
||||||
|
tasks := collectThumbnailTasks(batchSize)
|
||||||
|
for _, task := range tasks {
|
||||||
|
if err := generateThumbnail(task); err != nil {
|
||||||
|
markThumbnailFailed(task.BoxID, task.FileID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
|
||||||
|
if !helpers.ValidLowerHexID(fileID, 16) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return helpers.SafeChildPath(filepath.Join(BoxPath(boxID), thumbnailDir), fileID+".jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectThumbnailTasks(batchSize int) []thumbnailTask {
|
||||||
|
entries, err := os.ReadDir(UploadRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]thumbnailTask, 0, batchSize)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, collectBoxThumbnailTasks(entry.Name(), batchSize-len(tasks))...)
|
||||||
|
if len(tasks) >= batchSize {
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
|
||||||
|
if remaining <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil || IsExpired(manifest) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]thumbnailTask, 0, remaining)
|
||||||
|
changed := false
|
||||||
|
for index, file := range manifest.Files {
|
||||||
|
if len(tasks) >= remaining {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Status != models.FileStatusReady || file.ThumbnailPath != nil || file.ThumbnailStatus != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canGenerateThumbnail(file) {
|
||||||
|
manifest.Files[index].ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, thumbnailTask{
|
||||||
|
BoxID: boxID,
|
||||||
|
FileID: file.ID,
|
||||||
|
Name: file.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func canGenerateThumbnail(file models.BoxFile) bool {
|
||||||
|
if strings.HasPrefix(file.MimeType, "image/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := strings.ToLower(filepath.Ext(file.Name))
|
||||||
|
return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateThumbnail(task thumbnailTask) error {
|
||||||
|
source, err := os.Open(filepath.Join(BoxPath(task.BoxID), task.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
src, _, err := image.Decode(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb := resizeImage(src, thumbnailMaxSize)
|
||||||
|
if err := os.MkdirAll(filepath.Join(BoxPath(task.BoxID), thumbnailDir), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path, ok := ThumbnailFilePath(task.BoxID, task.FileID)
|
||||||
|
if !ok {
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return markThumbnailReady(task.BoxID, task.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeImage(src image.Image, maxSize int) image.Image {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
targetWidth := width
|
||||||
|
targetHeight := height
|
||||||
|
if width > maxSize || height > maxSize {
|
||||||
|
if width >= height {
|
||||||
|
targetWidth = maxSize
|
||||||
|
targetHeight = height * maxSize / width
|
||||||
|
} else {
|
||||||
|
targetHeight = maxSize
|
||||||
|
targetWidth = width * maxSize / height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetWidth < 1 {
|
||||||
|
targetWidth = 1
|
||||||
|
}
|
||||||
|
if targetHeight < 1 {
|
||||||
|
targetHeight = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||||
|
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||||
|
for y := 0; y < targetHeight; y++ {
|
||||||
|
for x := 0; x < targetWidth; x++ {
|
||||||
|
srcX := bounds.Min.X + x*width/targetWidth
|
||||||
|
srcY := bounds.Min.Y + y*height/targetHeight
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func markThumbnailReady(boxID string, fileID string) error {
|
||||||
|
path := "/box/" + boxID + "/thumbnails/" + url.PathEscape(fileID)
|
||||||
|
return updateThumbnailState(boxID, fileID, &path, models.ThumbnailStatusReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markThumbnailFailed(boxID string, fileID string) {
|
||||||
|
updateThumbnailState(boxID, fileID, nil, models.ThumbnailStatusFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateThumbnailState(boxID string, fileID string, thumbnailPath *string, status string) error {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, file := range manifest.Files {
|
||||||
|
if file.ID != fileID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Files[index].ThumbnailPath = thumbnailPath
|
||||||
|
manifest.Files[index].ThumbnailStatus = status
|
||||||
|
return writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@ const (
|
|||||||
FileStatusWork = "uploading"
|
FileStatusWork = "uploading"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThumbnailStatusFailed = "failed"
|
||||||
|
ThumbnailStatusProcessing = "processing"
|
||||||
|
ThumbnailStatusReady = "ready"
|
||||||
|
ThumbnailStatusUnsupported = "unsupported"
|
||||||
|
)
|
||||||
|
|
||||||
type RetentionOption struct {
|
type RetentionOption struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -16,18 +23,21 @@ type RetentionOption struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BoxFile struct {
|
type BoxFile struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
SizeLabel string `json:"size_label"`
|
SizeLabel string `json:"size_label"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusLabel string `json:"status_label"`
|
StatusLabel string `json:"status_label"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
IconPath string `json:"icon_path"`
|
IconPath string `json:"icon_path"`
|
||||||
DownloadPath string `json:"download_path"`
|
ThumbnailPath *string `json:"thumbnail_path"`
|
||||||
UploadPath string `json:"upload_path"`
|
ThumbnailStatus string `json:"thumbnail_status,omitempty"`
|
||||||
IsComplete bool `json:"is_complete"`
|
ThumbnailURL string `json:"-"`
|
||||||
|
DownloadPath string `json:"download_path"`
|
||||||
|
UploadPath string `json:"upload_path"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BoxManifest struct {
|
type BoxManifest struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Handlers struct {
|
|||||||
BoxStatus gin.HandlerFunc
|
BoxStatus gin.HandlerFunc
|
||||||
DownloadBox gin.HandlerFunc
|
DownloadBox gin.HandlerFunc
|
||||||
DownloadFile gin.HandlerFunc
|
DownloadFile gin.HandlerFunc
|
||||||
|
DownloadThumbnail gin.HandlerFunc
|
||||||
CreateBox gin.HandlerFunc
|
CreateBox gin.HandlerFunc
|
||||||
ManifestFileUpload gin.HandlerFunc
|
ManifestFileUpload gin.HandlerFunc
|
||||||
FileStatusUpdate gin.HandlerFunc
|
FileStatusUpdate gin.HandlerFunc
|
||||||
@@ -25,6 +26,7 @@ func Register(router *gin.Engine, handlers Handlers) {
|
|||||||
router.GET("/box/:id/status", handlers.BoxStatus)
|
router.GET("/box/:id/status", handlers.BoxStatus)
|
||||||
router.GET("/box/:id/download", handlers.DownloadBox)
|
router.GET("/box/:id/download", handlers.DownloadBox)
|
||||||
router.GET("/box/:id/files/:filename", handlers.DownloadFile)
|
router.GET("/box/:id/files/:filename", handlers.DownloadFile)
|
||||||
|
router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail)
|
||||||
|
|
||||||
router.POST("/box", handlers.CreateBox)
|
router.POST("/box", handlers.CreateBox)
|
||||||
router.POST("/box/:id/login", handlers.BoxLoginPost)
|
router.POST("/box/:id/login", handlers.BoxLoginPost)
|
||||||
|
|||||||
@@ -208,6 +208,33 @@ func handleDownloadFile(ctx *gin.Context) {
|
|||||||
ctx.FileAttachment(path, filename)
|
ctx.FileAttachment(path, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleDownloadThumbnail(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
fileID := ctx.Param("file_id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
|
||||||
|
if !ok {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Thumbnail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Header("Content-Type", "image/jpeg")
|
||||||
|
ctx.File(path)
|
||||||
|
}
|
||||||
|
|
||||||
func handleCreateBox(ctx *gin.Context) {
|
func handleCreateBox(ctx *gin.Context) {
|
||||||
boxID, err := boxstore.NewBoxID()
|
boxID, err := boxstore.NewBoxID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
"warpbox/lib/routing"
|
"warpbox/lib/routing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ func Run(addr string) error {
|
|||||||
BoxStatus: handleBoxStatus,
|
BoxStatus: handleBoxStatus,
|
||||||
DownloadBox: handleDownloadBox,
|
DownloadBox: handleDownloadBox,
|
||||||
DownloadFile: handleDownloadFile,
|
DownloadFile: handleDownloadFile,
|
||||||
|
DownloadThumbnail: handleDownloadThumbnail,
|
||||||
CreateBox: handleCreateBox,
|
CreateBox: handleCreateBox,
|
||||||
ManifestFileUpload: handleManifestFileUpload,
|
ManifestFileUpload: handleManifestFileUpload,
|
||||||
FileStatusUpdate: handleFileStatusUpdate,
|
FileStatusUpdate: handleFileStatusUpdate,
|
||||||
@@ -29,5 +34,9 @@ func Run(addr string) error {
|
|||||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||||
compressed.Static("/static", "./static")
|
compressed.Static("/static", "./static")
|
||||||
|
|
||||||
|
batchSize := helpers.EnvInt("WARPBOX_THUMBNAIL_BATCH_SIZE", 10, 1)
|
||||||
|
intervalSeconds := helpers.EnvInt("WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 30, 1)
|
||||||
|
boxstore.StartThumbnailWorker(batchSize, time.Duration(intervalSeconds)*time.Second)
|
||||||
|
|
||||||
return router.Run(addr)
|
return router.Run(addr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,14 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-file.has-thumbnail .box-file-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
.box-file-name,
|
.box-file-name,
|
||||||
.box-file-meta {
|
.box-file-meta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -286,6 +286,14 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-file-row.has-thumbnail .upload-file-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-file-name,
|
.upload-file-name,
|
||||||
.upload-file-size {
|
.upload-file-size {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ let selectedFiles = [];
|
|||||||
let statusTimer = null;
|
let statusTimer = null;
|
||||||
let shareURL = "";
|
let shareURL = "";
|
||||||
|
|
||||||
|
function revokePreviewURLs() {
|
||||||
|
selectedFiles.forEach((selectedFile) => {
|
||||||
|
if (selectedFile.previewURL) {
|
||||||
|
URL.revokeObjectURL(selectedFile.previewURL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
let size = bytes;
|
let size = bytes;
|
||||||
@@ -167,10 +175,11 @@ function setRowProgress(row, percent) {
|
|||||||
function createFileRow(selectedFile) {
|
function createFileRow(selectedFile) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "upload-file-row";
|
row.className = "upload-file-row";
|
||||||
|
row.classList.toggle("has-thumbnail", Boolean(selectedFile.previewURL));
|
||||||
|
|
||||||
const icon = document.createElement("img");
|
const icon = document.createElement("img");
|
||||||
icon.className = "upload-file-icon";
|
icon.className = "upload-file-icon";
|
||||||
icon.src = iconForFile(selectedFile.file);
|
icon.src = selectedFile.previewURL || iconForFile(selectedFile.file);
|
||||||
icon.alt = "";
|
icon.alt = "";
|
||||||
icon.setAttribute("aria-hidden", "true");
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
@@ -197,8 +206,10 @@ function createFileRow(selectedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedFiles(files) {
|
function updateSelectedFiles(files) {
|
||||||
|
revokePreviewURLs();
|
||||||
selectedFiles = Array.from(files || []).map((file) => ({
|
selectedFiles = Array.from(files || []).map((file) => ({
|
||||||
file,
|
file,
|
||||||
|
previewURL: file.type.startsWith("image/") ? URL.createObjectURL(file) : "",
|
||||||
loaded: 0,
|
loaded: 0,
|
||||||
row: null,
|
row: null,
|
||||||
uploaded: false,
|
uploaded: false,
|
||||||
@@ -438,7 +449,10 @@ if (uploadForm) {
|
|||||||
selectedFile.boxID = box.box_id;
|
selectedFile.boxID = box.box_id;
|
||||||
selectedFile.boxFile = box.files[index];
|
selectedFile.boxFile = box.files[index];
|
||||||
const icon = selectedFile.row.querySelector(".upload-file-icon");
|
const icon = selectedFile.row.querySelector(".upload-file-icon");
|
||||||
if (icon && selectedFile.boxFile.icon_path) {
|
if (icon && selectedFile.boxFile.thumbnail_path) {
|
||||||
|
selectedFile.row.classList.add("has-thumbnail");
|
||||||
|
icon.src = selectedFile.boxFile.thumbnail_path;
|
||||||
|
} else if (icon && selectedFile.boxFile.icon_path && !selectedFile.previewURL) {
|
||||||
icon.src = selectedFile.boxFile.icon_path;
|
icon.src = selectedFile.boxFile.icon_path;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -489,3 +503,5 @@ if (shareButton) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", revokePreviewURLs);
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ function updateBoxFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = item.querySelector(".box-file-meta");
|
const meta = item.querySelector(".box-file-meta");
|
||||||
|
const icon = item.querySelector(".box-file-icon");
|
||||||
const isComplete = file.status === "complete";
|
const isComplete = file.status === "complete";
|
||||||
const isFailed = file.status === "failed";
|
const isFailed = file.status === "failed";
|
||||||
|
|
||||||
item.classList.toggle("is-complete", isComplete);
|
item.classList.toggle("is-complete", isComplete);
|
||||||
item.classList.toggle("is-failed", isFailed);
|
item.classList.toggle("is-failed", isFailed);
|
||||||
item.classList.toggle("is-loading", !isComplete && !isFailed);
|
item.classList.toggle("is-loading", !isComplete && !isFailed);
|
||||||
|
item.classList.toggle("has-thumbnail", Boolean(file.thumbnail_path));
|
||||||
item.dataset.status = file.status;
|
item.dataset.status = file.status;
|
||||||
item.title = file.title;
|
item.title = file.title;
|
||||||
|
|
||||||
@@ -38,6 +40,10 @@ function updateBoxFile(file) {
|
|||||||
if (meta) {
|
if (meta) {
|
||||||
meta.textContent = `${file.status_label} · ${file.size_label}`;
|
meta.textContent = `${file.status_label} · ${file.size_label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.src = file.thumbnail_path || file.icon_path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshBoxStatus() {
|
async function refreshBoxStatus() {
|
||||||
@@ -59,7 +65,11 @@ async function refreshBoxStatus() {
|
|||||||
boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
|
boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.files.some((file) => file.status === "pending" || file.status === "uploading");
|
return result.files.some((file) => {
|
||||||
|
const isUploading = file.status === "pending" || file.status === "uploading";
|
||||||
|
const isWaitingForThumbnail = file.status === "complete" && !file.thumbnail_status && !file.thumbnail_path;
|
||||||
|
return isUploading || isWaitingForThumbnail || file.thumbnail_status === "processing";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (boxPanel) {
|
if (boxPanel) {
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
{{ if .Files }}
|
{{ if .Files }}
|
||||||
<div class="box-file-grid" aria-label="Uploaded files">
|
<div class="box-file-grid" aria-label="Uploaded files">
|
||||||
{{ range .Files }}
|
{{ range .Files }}
|
||||||
<a class="box-file {{ if .IsComplete }}is-complete{{ else if eq .Status "failed" }}is-failed{{ else }}is-loading{{ end }}" href="{{ if .IsComplete }}{{ .DownloadPath }}{{ else }}#{{ end }}" title="{{ .Title }}" data-file-id="{{ .ID }}" data-status="{{ .Status }}" {{ if .IsComplete }}download{{ else }}aria-disabled="true"{{ end }}>
|
<a class="box-file {{ if .IsComplete }}is-complete{{ else if eq .Status "failed" }}is-failed{{ else }}is-loading{{ end }} {{ if .ThumbnailURL }}has-thumbnail{{ end }}" href="{{ if .IsComplete }}{{ .DownloadPath }}{{ else }}#{{ end }}" title="{{ .Title }}" data-file-id="{{ .ID }}" data-status="{{ .Status }}" {{ if .IsComplete }}download{{ else }}aria-disabled="true"{{ end }}>
|
||||||
<img class="box-file-icon" src="{{ .IconPath }}" alt="" aria-hidden="true">
|
<img class="box-file-icon" src="{{ if .ThumbnailURL }}{{ .ThumbnailURL }}{{ else }}{{ .IconPath }}{{ end }}" alt="" aria-hidden="true">
|
||||||
<span class="box-file-name">{{ .Name }}</span>
|
<span class="box-file-name">{{ .Name }}</span>
|
||||||
<span class="box-file-meta">{{ .StatusLabel }} · {{ .SizeLabel }}</span>
|
<span class="box-file-meta">{{ .StatusLabel }} · {{ .SizeLabel }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user