diff --git a/lib/server/server.go b/lib/server/server.go
index c4e1348..008e075 100644
--- a/lib/server/server.go
+++ b/lib/server/server.go
@@ -3,6 +3,7 @@ package server
import (
"archive/zip"
"crypto/rand"
+ "encoding/json"
"encoding/hex"
"fmt"
"io"
@@ -13,20 +14,53 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
)
-const uploadRoot = "data/uploads"
+const (
+ uploadRoot = "data/uploads"
+ boxManifestFile = ".warpbox.json"
+ fileStatusFailed = "failed"
+ fileStatusReady = "complete"
+ fileStatusWait = "pending"
+ fileStatusWork = "uploading"
+)
+
+var boxManifestMu sync.Mutex
type boxFile struct {
- Name string
- Size int64
- SizeLabel string
- MimeType string
- IconPath string
- DownloadPath string
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ SizeLabel string `json:"size_label"`
+ MimeType string `json:"mime_type"`
+ Status string `json:"status"`
+ StatusLabel string `json:"status_label"`
+ Title string `json:"title"`
+ IconPath string `json:"icon_path"`
+ DownloadPath string `json:"download_path"`
+ UploadPath string `json:"upload_path"`
+ IsComplete bool `json:"is_complete"`
+}
+
+type boxManifest struct {
+ Files []boxFile `json:"files"`
+}
+
+type createBoxRequest struct {
+ Files []createBoxFileRequest `json:"files"`
+}
+
+type createBoxFileRequest struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+}
+
+type updateFileStatusRequest struct {
+ Status string `json:"status"`
}
func Run(addr string) error {
@@ -58,6 +92,25 @@ func Run(addr string) error {
})
})
+ router.GET("/box/:id/status", func(ctx *gin.Context) {
+ boxID := ctx.Param("id")
+ if !validBoxID(boxID) {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
+ return
+ }
+
+ files, err := listBoxFiles(boxID)
+ if err != nil {
+ ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{
+ "box_id": boxID,
+ "files": files,
+ })
+ })
+
router.GET("/box/:id/download", func(ctx *gin.Context) {
boxID := ctx.Param("id")
if !validBoxID(boxID) {
@@ -78,6 +131,10 @@ func Run(addr string) error {
defer zipWriter.Close()
for _, file := range files {
+ if !file.IsComplete {
+ continue
+ }
+
if err := addFileToZip(zipWriter, boxID, file.Name); err != nil {
ctx.Status(http.StatusInternalServerError)
return
@@ -119,12 +176,77 @@ func Run(addr string) error {
return
}
+ var request createBoxRequest
+ if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
+ return
+ }
+
+ files, err := createBoxManifest(boxID, request.Files)
+ if err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
ctx.JSON(http.StatusOK, gin.H{
"box_id": boxID,
"box_url": "/box/" + boxID,
+ "files": files,
})
})
+ router.POST("/box/:id/files/:file_id/upload", func(ctx *gin.Context) {
+ boxID := ctx.Param("id")
+ fileID := ctx.Param("file_id")
+ if !validBoxID(boxID) {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
+ return
+ }
+
+ file, err := ctx.FormFile("file")
+ if err != nil {
+ markManifestFileStatus(boxID, fileID, fileStatusFailed)
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
+ return
+ }
+
+ savedFile, err := saveManifestUploadedFile(ctx, boxID, fileID, file)
+ if err != nil {
+ markManifestFileStatus(boxID, fileID, fileStatusFailed)
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{
+ "box_id": boxID,
+ "box_url": "/box/" + boxID,
+ "file": savedFile,
+ })
+ })
+
+ router.POST("/box/:id/files/:file_id/status", func(ctx *gin.Context) {
+ boxID := ctx.Param("id")
+ fileID := ctx.Param("file_id")
+ if !validBoxID(boxID) {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
+ return
+ }
+
+ var request updateFileStatusRequest
+ if err := ctx.ShouldBindJSON(&request); err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
+ return
+ }
+
+ file, err := markManifestFileStatus(boxID, fileID, request.Status)
+ if err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx.JSON(http.StatusOK, gin.H{"file": file})
+ })
+
router.POST("/box/:id/upload", func(ctx *gin.Context) {
boxID := ctx.Param("id")
if !validBoxID(boxID) {
@@ -209,6 +331,15 @@ func newBoxID() (string, error) {
return hex.EncodeToString(bytes), nil
}
+func newFileID() (string, error) {
+ bytes := make([]byte, 8)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+
+ return hex.EncodeToString(bytes), nil
+}
+
func validBoxID(boxID string) bool {
if len(boxID) != 32 {
return false
@@ -227,7 +358,20 @@ func boxPath(boxID string) string {
return filepath.Join(uploadRoot, boxID)
}
+func manifestPath(boxID string) string {
+ return filepath.Join(boxPath(boxID), boxManifestFile)
+}
+
func listBoxFiles(boxID string) ([]boxFile, error) {
+ if manifest, err := readBoxManifest(boxID); err == nil && len(manifest.Files) > 0 {
+ files := make([]boxFile, 0, len(manifest.Files))
+ for _, file := range manifest.Files {
+ files = append(files, decorateBoxFile(boxID, file))
+ }
+
+ return files, nil
+ }
+
entries, err := os.ReadDir(boxPath(boxID))
if err != nil {
return nil, err
@@ -235,7 +379,7 @@ func listBoxFiles(boxID string) ([]boxFile, error) {
files := make([]boxFile, 0, len(entries))
for _, entry := range entries {
- if entry.IsDir() {
+ if entry.IsDir() || entry.Name() == boxManifestFile {
continue
}
@@ -246,19 +390,160 @@ func listBoxFiles(boxID string) ([]boxFile, error) {
name := entry.Name()
mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), name)
- files = append(files, boxFile{
- Name: name,
- Size: info.Size(),
- SizeLabel: formatBytes(info.Size()),
- MimeType: mimeType,
- IconPath: iconForMimeType(mimeType, name),
- DownloadPath: "/box/" + boxID + "/files/" + url.PathEscape(name),
- })
+ files = append(files, decorateBoxFile(boxID, boxFile{
+ ID: name,
+ Name: name,
+ Size: info.Size(),
+ MimeType: mimeType,
+ Status: fileStatusReady,
+ }))
}
return files, nil
}
+func createBoxManifest(boxID string, requests []createBoxFileRequest) ([]boxFile, error) {
+ usedNames := make(map[string]int, len(requests))
+ files := make([]boxFile, 0, len(requests))
+
+ for _, request := range requests {
+ filename, ok := safeFilename(request.Name)
+ if !ok {
+ return nil, fmt.Errorf("Invalid filename")
+ }
+
+ filename = uniqueManifestFilename(filename, usedNames)
+ fileID, err := newFileID()
+ if err != nil {
+ return nil, fmt.Errorf("Could not create file id")
+ }
+
+ mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ files = append(files, boxFile{
+ ID: fileID,
+ Name: filename,
+ Size: request.Size,
+ MimeType: mimeType,
+ Status: fileStatusWait,
+ })
+ }
+
+ manifest := boxManifest{Files: files}
+ if err := writeBoxManifest(boxID, manifest); err != nil {
+ return nil, err
+ }
+
+ decoratedFiles := make([]boxFile, 0, len(files))
+ for _, file := range files {
+ decoratedFiles = append(decoratedFiles, decorateBoxFile(boxID, file))
+ }
+
+ return decoratedFiles, nil
+}
+
+func readBoxManifest(boxID string) (boxManifest, error) {
+ boxManifestMu.Lock()
+ defer boxManifestMu.Unlock()
+
+ return readBoxManifestUnlocked(boxID)
+}
+
+func readBoxManifestUnlocked(boxID string) (boxManifest, error) {
+ var manifest boxManifest
+ data, err := os.ReadFile(manifestPath(boxID))
+ if err != nil {
+ return manifest, err
+ }
+
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return manifest, err
+ }
+
+ return manifest, nil
+}
+
+func writeBoxManifest(boxID string, manifest boxManifest) error {
+ boxManifestMu.Lock()
+ defer boxManifestMu.Unlock()
+
+ return writeBoxManifestUnlocked(boxID, manifest)
+}
+
+func writeBoxManifestUnlocked(boxID string, manifest boxManifest) error {
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(manifestPath(boxID), data, 0644)
+}
+
+func markManifestFileStatus(boxID string, fileID string, status string) (boxFile, error) {
+ if status != fileStatusWait && status != fileStatusWork && status != fileStatusReady && status != fileStatusFailed {
+ return boxFile{}, fmt.Errorf("Invalid file status")
+ }
+
+ boxManifestMu.Lock()
+ defer boxManifestMu.Unlock()
+
+ manifest, err := readBoxManifestUnlocked(boxID)
+ if err != nil {
+ return boxFile{}, err
+ }
+
+ for index, file := range manifest.Files {
+ if file.ID != fileID {
+ continue
+ }
+
+ manifest.Files[index].Status = status
+ if err := writeBoxManifestUnlocked(boxID, manifest); err != nil {
+ return boxFile{}, err
+ }
+
+ return decorateBoxFile(boxID, manifest.Files[index]), nil
+ }
+
+ return boxFile{}, fmt.Errorf("File not found")
+}
+
+func decorateBoxFile(boxID string, file boxFile) boxFile {
+ if file.MimeType == "" {
+ file.MimeType = mimeTypeForFile(filepath.Join(boxPath(boxID), file.Name), file.Name)
+ }
+
+ if file.SizeLabel == "" {
+ file.SizeLabel = formatBytes(file.Size)
+ }
+
+ file.IconPath = iconForMimeType(file.MimeType, file.Name)
+ file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
+ file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
+ file.IsComplete = file.Status == fileStatusReady
+
+ switch file.Status {
+ case fileStatusReady:
+ file.StatusLabel = "Ready"
+ file.Title = "Download " + file.Name
+ case fileStatusFailed:
+ file.StatusLabel = "Failed"
+ file.Title = "Failed to upload"
+ case fileStatusWork:
+ file.StatusLabel = "Loading"
+ file.Title = "Loading"
+ default:
+ file.Status = fileStatusWait
+ file.StatusLabel = "Waiting"
+ file.Title = "Loading"
+ }
+
+ return file
+}
+
func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
path := filepath.Join(boxPath(boxID), filename)
source, err := os.Open(path)
@@ -276,6 +561,49 @@ func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
return err
}
+func saveManifestUploadedFile(ctx *gin.Context, boxID string, fileID string, file *multipart.FileHeader) (boxFile, error) {
+ boxManifestMu.Lock()
+ defer boxManifestMu.Unlock()
+
+ manifest, err := readBoxManifestUnlocked(boxID)
+ if err != nil {
+ return boxFile{}, err
+ }
+
+ fileIndex := -1
+ for index, manifestFile := range manifest.Files {
+ if manifestFile.ID == fileID {
+ fileIndex = index
+ break
+ }
+ }
+
+ if fileIndex < 0 {
+ return boxFile{}, fmt.Errorf("File not found")
+ }
+
+ filename := manifest.Files[fileIndex].Name
+ if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
+ return boxFile{}, fmt.Errorf("Could not prepare upload box")
+ }
+
+ destination := filepath.Join(boxPath(boxID), filename)
+ if err := ctx.SaveUploadedFile(file, destination); err != nil {
+ manifest.Files[fileIndex].Status = fileStatusFailed
+ writeBoxManifestUnlocked(boxID, manifest)
+ return boxFile{}, fmt.Errorf("Could not save uploaded file")
+ }
+
+ manifest.Files[fileIndex].Size = file.Size
+ manifest.Files[fileIndex].MimeType = mimeTypeForFile(destination, filename)
+ manifest.Files[fileIndex].Status = fileStatusReady
+ if err := writeBoxManifestUnlocked(boxID, manifest); err != nil {
+ return boxFile{}, err
+ }
+
+ return decorateBoxFile(boxID, manifest.Files[fileIndex]), nil
+}
+
func saveUploadedFile(ctx *gin.Context, boxID string, file *multipart.FileHeader) (gin.H, error) {
filename, ok := safeFilename(file.Filename)
if !ok {
@@ -320,6 +648,19 @@ func uniqueFilename(directory string, filename string) string {
}
}
+func uniqueManifestFilename(filename string, usedNames map[string]int) string {
+ count := usedNames[filename]
+ usedNames[filename] = count + 1
+
+ if count == 0 {
+ return filename
+ }
+
+ extension := filepath.Ext(filename)
+ base := strings.TrimSuffix(filename, extension)
+ return fmt.Sprintf("%s-%d%s", base, count+1, extension)
+}
+
func mimeTypeForFile(path string, filename string) string {
if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" {
return mimeType
diff --git a/static/css/box.css b/static/css/box.css
index 4d3fd14..d545d7a 100644
--- a/static/css/box.css
+++ b/static/css/box.css
@@ -74,6 +74,28 @@
text-decoration: none;
}
+.box-file.is-loading,
+.box-file.is-failed {
+ color: #666666;
+ filter: grayscale(1);
+}
+
+.box-file.is-loading {
+ animation: box-loading-pulse 900ms steps(2, end) infinite;
+}
+
+.box-file.is-failed {
+ opacity: 0.58;
+}
+
+.box-file.is-failed .box-file-name::after {
+ content: " (failed)";
+}
+
+.box-file[aria-disabled="true"] {
+ cursor: default;
+}
+
.box-file:hover,
.box-file:focus-visible {
color: #ffffff;
@@ -99,6 +121,16 @@
white-space: nowrap;
}
+@keyframes box-loading-pulse {
+ 0% {
+ opacity: 0.48;
+ }
+
+ 100% {
+ opacity: 0.82;
+ }
+}
+
.box-file-name {
font-size: 13px;
line-height: 13px;
diff --git a/static/css/upload.css b/static/css/upload.css
index 97e938e..f606c7a 100644
--- a/static/css/upload.css
+++ b/static/css/upload.css
@@ -204,6 +204,11 @@
background: #f7f7f7;
}
+.upload-file-row.is-uploading,
+.upload-file-row.is-processing {
+ animation: upload-row-loading 900ms steps(2, end) infinite;
+}
+
.upload-file-icon {
grid-row: 1 / 3;
width: 16px;
@@ -269,6 +274,16 @@
background: #800000;
}
+@keyframes upload-row-loading {
+ 0% {
+ background-color: #ffffff;
+ }
+
+ 100% {
+ background-color: #e6e6e6;
+ }
+}
+
.upload-actions {
display: flex;
justify-content: flex-end;
diff --git a/static/js/app.js b/static/js/app.js
index 5803d22..f1d4008 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -98,7 +98,9 @@ function setOverallProgress(percent) {
function updateOverallProgress() {
const totalBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.file.size, 0);
const loadedBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.loaded, 0);
- setOverallProgress(totalBytes > 0 ? (loadedBytes / totalBytes) * 100 : 0);
+ const uploadedCount = selectedFiles.filter((selectedFile) => selectedFile.uploaded).length;
+ const percent = totalBytes > 0 ? (loadedBytes / totalBytes) * 100 : 0;
+ setOverallProgress(percent >= 100 && uploadedCount < selectedFiles.length ? 99 : percent);
}
function updateFileCount() {
@@ -186,7 +188,18 @@ function updateSelectedFiles(files) {
}
async function createBox() {
- const response = await fetch("/box", { method: "POST" });
+ const response = await fetch("/box", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ files: selectedFiles.map((selectedFile) => ({
+ name: selectedFile.file.name,
+ size: selectedFile.file.size,
+ })),
+ }),
+ });
if (!response.ok) {
throw new Error("Could not create upload box");
}
@@ -194,16 +207,32 @@ async function createBox() {
return response.json();
}
+async function markFileStatus(selectedFile, status) {
+ if (!selectedFile.boxFile) {
+ return;
+ }
+
+ await fetch(`/box/${selectedFile.boxID}/files/${selectedFile.boxFile.id}/status`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ status }),
+ });
+}
+
function uploadFile(boxID, selectedFile, onComplete) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", selectedFile.file);
- xhr.open("POST", `/box/${boxID}/upload`);
+ xhr.open("POST", selectedFile.boxFile.upload_path);
xhr.upload.addEventListener("loadstart", () => {
selectedFile.loaded = 0;
+ selectedFile.row.classList.add("is-uploading");
+ selectedFile.row.title = "Loading";
updateOverallProgress();
setRowProgress(selectedFile.row, 2);
});
@@ -215,20 +244,33 @@ function uploadFile(boxID, selectedFile, onComplete) {
selectedFile.loaded = Math.min(event.loaded, selectedFile.file.size);
updateOverallProgress();
- setRowProgress(selectedFile.row, (event.loaded / event.total) * 100);
+ const percent = (event.loaded / event.total) * 100;
+ if (percent >= 100) {
+ selectedFile.row.classList.add("is-processing");
+ selectedFile.row.title = "Loading";
+ setRowProgress(selectedFile.row, 99);
+ return;
+ }
+
+ setRowProgress(selectedFile.row, percent);
});
xhr.addEventListener("load", () => {
if (xhr.status < 200 || xhr.status >= 300) {
selectedFile.failed = true;
+ selectedFile.row.classList.remove("is-uploading", "is-processing");
selectedFile.row.classList.add("is-failed");
+ selectedFile.row.title = "Failed to upload";
+ markFileStatus(selectedFile, "failed");
reject(new Error("Upload failed"));
return;
}
selectedFile.uploaded = true;
selectedFile.loaded = selectedFile.file.size;
+ selectedFile.row.classList.remove("is-uploading", "is-processing");
selectedFile.row.classList.add("is-uploaded");
+ selectedFile.row.title = "Uploaded";
updateOverallProgress();
setRowProgress(selectedFile.row, 100);
onComplete();
@@ -237,10 +279,23 @@ function uploadFile(boxID, selectedFile, onComplete) {
xhr.addEventListener("error", () => {
selectedFile.failed = true;
+ selectedFile.row.classList.remove("is-uploading", "is-processing");
selectedFile.row.classList.add("is-failed");
+ selectedFile.row.title = "Failed to upload";
+ markFileStatus(selectedFile, "failed");
reject(new Error("Upload failed"));
});
+ xhr.addEventListener("abort", () => {
+ selectedFile.failed = true;
+ selectedFile.row.classList.remove("is-uploading", "is-processing");
+ selectedFile.row.classList.add("is-failed");
+ selectedFile.row.title = "Failed to upload";
+ markFileStatus(selectedFile, "failed");
+ reject(new Error("Upload cancelled"));
+ });
+
+ markFileStatus(selectedFile, "uploading");
xhr.send(formData);
});
}
@@ -293,7 +348,8 @@ if (uploadForm) {
selectedFile.uploaded = false;
selectedFile.failed = false;
selectedFile.loaded = 0;
- selectedFile.row.classList.remove("is-uploaded", "is-failed");
+ selectedFile.row.classList.remove("is-uploaded", "is-failed", "is-uploading", "is-processing");
+ selectedFile.row.title = "";
setRowProgress(selectedFile.row, 0);
});
@@ -305,6 +361,12 @@ if (uploadForm) {
try {
const box = await createBox();
setBoxStatus(box.box_url);
+ setBoxLink(box.box_url);
+
+ selectedFiles.forEach((selectedFile, index) => {
+ selectedFile.boxID = box.box_id;
+ selectedFile.boxFile = box.files[index];
+ });
await Promise.allSettled(selectedFiles.map((selectedFile) => {
return uploadFile(box.box_id, selectedFile, () => {
@@ -315,14 +377,10 @@ if (uploadForm) {
stopStatusAnimation();
const failedCount = selectedFiles.filter((selectedFile) => selectedFile.failed).length;
if (failedCount > 0) {
- if (completedCount > 0) {
- setBoxLink(box.box_url);
- }
updateStatus(`${completedCount}/${totalCount} Uploaded, ${failedCount} failed`);
return;
}
- setBoxLink(box.box_url);
setOverallProgress(100);
updateStatus(`${completedCount}/${totalCount} Uploaded`);
} catch (error) {
diff --git a/static/js/box.js b/static/js/box.js
new file mode 100644
index 0000000..816a00e
--- /dev/null
+++ b/static/js/box.js
@@ -0,0 +1,72 @@
+const boxPanel = document.querySelector(".box-panel[data-box-id]");
+const boxStatus = document.querySelector(".box-statusbar span:first-child");
+
+document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => {
+ item.addEventListener("click", (event) => {
+ if (item.getAttribute("aria-disabled") === "true") {
+ event.preventDefault();
+ }
+ });
+});
+
+function updateBoxFile(file) {
+ const item = document.querySelector(`.box-file[data-file-id="${file.id}"]`);
+ if (!item) {
+ return;
+ }
+
+ const meta = item.querySelector(".box-file-meta");
+ const isComplete = file.status === "complete";
+ const isFailed = file.status === "failed";
+
+ item.classList.toggle("is-complete", isComplete);
+ item.classList.toggle("is-failed", isFailed);
+ item.classList.toggle("is-loading", !isComplete && !isFailed);
+ item.dataset.status = file.status;
+ item.title = file.title;
+
+ if (isComplete) {
+ item.href = file.download_path;
+ item.setAttribute("download", "");
+ item.removeAttribute("aria-disabled");
+ } else {
+ item.href = "#";
+ item.removeAttribute("download");
+ item.setAttribute("aria-disabled", "true");
+ }
+
+ if (meta) {
+ meta.textContent = `${file.status_label} · ${file.size_label}`;
+ }
+}
+
+async function refreshBoxStatus() {
+ if (!boxPanel) {
+ return false;
+ }
+
+ const boxID = boxPanel.dataset.boxId;
+ const response = await fetch(`/box/${boxID}/status`);
+ if (!response.ok) {
+ return false;
+ }
+
+ const result = await response.json();
+ result.files.forEach(updateBoxFile);
+
+ if (boxStatus) {
+ const completeCount = result.files.filter((file) => file.status === "complete").length;
+ boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
+ }
+
+ return result.files.some((file) => file.status === "pending" || file.status === "uploading");
+}
+
+if (boxPanel) {
+ const timer = setInterval(async () => {
+ const hasLoadingFiles = await refreshBoxStatus();
+ if (!hasLoadingFiles) {
+ clearInterval(timer);
+ }
+ }, 1500);
+}
diff --git a/templates/box.html b/templates/box.html
index daacd02..c7e0434 100644
--- a/templates/box.html
+++ b/templates/box.html
@@ -40,14 +40,14 @@
/box/{{ .BoxID }}
-