diff --git a/lib/server/server.go b/lib/server/server.go index a5119ca..ec8a6d1 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "mime/multipart" "net/http" "os" "path/filepath" @@ -23,6 +24,50 @@ func Run(addr string) error { ctx.HTML(http.StatusOK, "index.html", gin.H{}) }) + router.POST("/box", func(ctx *gin.Context) { + boxID, err := newBoxID() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) + return + } + + if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "box_id": boxID, + "box_url": "/box/" + boxID, + }) + }) + + router.POST("/box/:id/upload", func(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) + return + } + + file, err := ctx.FormFile("file") + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) + return + } + + savedFile, err := saveUploadedFile(ctx, boxID, file) + 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, + "file": savedFile, + }) + }) + router.POST("/upload", func(ctx *gin.Context) { form, err := ctx.MultipartForm() if err != nil { @@ -42,33 +87,21 @@ func Run(addr string) error { return } - boxPath := filepath.Join(uploadRoot, boxID) - if err := os.MkdirAll(boxPath, 0755); err != nil { + if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) return } savedFiles := make([]gin.H, 0, len(files)) - usedNames := make(map[string]int, len(files)) for _, file := range files { - filename, ok := safeFilename(file.Filename) - if !ok { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filename"}) + savedFile, err := saveUploadedFile(ctx, boxID, file) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - filename = uniqueFilename(filename, usedNames) - destination := filepath.Join(boxPath, filename) - if err := ctx.SaveUploadedFile(file, destination); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not save uploaded files"}) - return - } - - savedFiles = append(savedFiles, gin.H{ - "name": filename, - "size": file.Size, - }) + savedFiles = append(savedFiles, savedFile) } ctx.JSON(http.StatusOK, gin.H{ @@ -93,21 +126,64 @@ func newBoxID() (string, error) { return hex.EncodeToString(bytes), nil } +func validBoxID(boxID string) bool { + if len(boxID) != 32 { + return false + } + + for _, character := range boxID { + if !strings.ContainsRune("0123456789abcdef", character) { + return false + } + } + + return true +} + +func boxPath(boxID string) string { + return filepath.Join(uploadRoot, boxID) +} + +func saveUploadedFile(ctx *gin.Context, boxID string, file *multipart.FileHeader) (gin.H, error) { + filename, ok := safeFilename(file.Filename) + if !ok { + return nil, fmt.Errorf("Invalid filename") + } + + boxPath := boxPath(boxID) + if err := os.MkdirAll(boxPath, 0755); err != nil { + return nil, fmt.Errorf("Could not prepare upload box") + } + + filename = uniqueFilename(boxPath, filename) + destination := filepath.Join(boxPath, filename) + if err := ctx.SaveUploadedFile(file, destination); err != nil { + return nil, fmt.Errorf("Could not save uploaded file") + } + + return gin.H{ + "name": filename, + "size": file.Size, + }, nil +} + func safeFilename(name string) (string, bool) { filename := filepath.Base(name) filename = strings.TrimSpace(filename) return filename, filename != "" && filename != "." && filename != string(filepath.Separator) } -func uniqueFilename(filename string, usedNames map[string]int) string { - count := usedNames[filename] - usedNames[filename] = count + 1 - - if count == 0 { +func uniqueFilename(directory string, filename string) string { + if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) { return filename } extension := filepath.Ext(filename) base := strings.TrimSuffix(filename, extension) - return fmt.Sprintf("%s-%d%s", base, count+1, extension) + for count := 2; ; count++ { + candidate := fmt.Sprintf("%s-%d%s", base, count, extension) + if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) { + return candidate + } + } } diff --git a/static/css/app.css b/static/css/app.css index d8c9d76..255f87c 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -169,6 +169,8 @@ main { } .upload-panel { + display: flex; + flex-direction: column; flex: 1; min-height: 0; margin: 0 8px 8px; @@ -182,6 +184,7 @@ main { } .upload-dropzone { + flex: 0 0 auto; height: 118px; box-sizing: border-box; display: flex; @@ -244,6 +247,7 @@ main { } .upload-details { + flex: 0 0 auto; display: flex; align-items: center; height: 28px; @@ -270,7 +274,8 @@ main { } .upload-file-list { - height: 132px; + flex: 1 1 auto; + min-height: 0; margin-top: 8px; overflow-y: auto; background: #ffffff; @@ -291,10 +296,11 @@ main { .upload-file-row { display: grid; grid-template-columns: 22px minmax(0, 1fr) 82px; + grid-template-rows: 20px 8px; align-items: center; - height: 26px; + height: 36px; box-sizing: border-box; - padding: 0 8px; + padding: 4px 8px; border-bottom: 1px solid #dfdfdf; font-size: 13px; line-height: 13px; @@ -305,6 +311,7 @@ main { } .upload-file-icon { + grid-row: 1 / 3; width: 16px; height: 18px; position: relative; @@ -338,6 +345,36 @@ main { text-align: right; } +.upload-progress { + grid-column: 2 / 4; + grid-row: 2; + display: block; + height: 8px; + box-sizing: border-box; + overflow: hidden; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #dfdfdf; + border-bottom: 1px solid #dfdfdf; +} + +.upload-progress-bar { + display: block; + width: 0%; + height: 100%; + background: #000078; +} + +.upload-file-row.is-uploaded .upload-progress-bar { + background: #008000; +} + +.upload-file-row.is-failed .upload-progress-bar { + width: 100%; + background: #800000; +} + .upload-actions { display: flex; justify-content: flex-end; @@ -447,7 +484,6 @@ main { } .upload-file-list { - height: calc(100dvh - 284px); min-height: 160px; } } diff --git a/static/js/app.js b/static/js/app.js index a8ff93f..d3d696c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4,6 +4,10 @@ const fileList = document.querySelector(".upload-file-list"); const dropzone = document.querySelector(".upload-dropzone"); const uploadForm = document.querySelector(".upload-form"); const uploadStatus = document.querySelector(".upload-statusbar span:first-child"); +const boxStatus = document.querySelector(".upload-statusbar span:last-child"); + +let selectedFiles = []; +let statusTimer = null; function formatBytes(bytes) { const units = ["B", "KB", "MB", "GB"]; @@ -28,12 +32,82 @@ function updateStatus(message) { } } -function updateSelectedFiles(files) { - const selectedFiles = Array.from(files || []); +function stopStatusAnimation() { + if (statusTimer) { + clearInterval(statusTimer); + statusTimer = null; + } +} +function animateUploadStatus(getPrefix) { + let dotCount = 0; + stopStatusAnimation(); + + statusTimer = setInterval(() => { + dotCount = (dotCount % 3) + 1; + updateStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`); + }, 350); +} + +function setBoxStatus(message) { + if (boxStatus) { + boxStatus.textContent = message; + boxStatus.title = message; + } +} + +function updateFileCount() { if (fileCount) { fileCount.textContent = `${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"}`; } +} + +function setRowProgress(row, percent) { + const progressBar = row.querySelector(".upload-progress-bar"); + if (progressBar) { + progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`; + } +} + +function createFileRow(selectedFile) { + const row = document.createElement("div"); + row.className = "upload-file-row"; + + const icon = document.createElement("span"); + icon.className = "upload-file-icon"; + icon.setAttribute("aria-hidden", "true"); + + const name = document.createElement("span"); + name.className = "upload-file-name"; + name.textContent = selectedFile.file.name; + name.title = selectedFile.file.name; + + const size = document.createElement("span"); + size.className = "upload-file-size"; + size.textContent = formatBytes(selectedFile.file.size); + + const progress = document.createElement("span"); + progress.className = "upload-progress"; + progress.setAttribute("aria-hidden", "true"); + + const progressBar = document.createElement("span"); + progressBar.className = "upload-progress-bar"; + progress.append(progressBar); + + row.append(icon, name, size, progress); + selectedFile.row = row; + return row; +} + +function updateSelectedFiles(files) { + selectedFiles = Array.from(files || []).map((file) => ({ + file, + row: null, + uploaded: false, + failed: false, + })); + + updateFileCount(); if (!fileList) { return; @@ -47,38 +121,77 @@ function updateSelectedFiles(files) { emptyState.textContent = "No files selected"; fileList.append(emptyState); updateStatus("Ready"); + setBoxStatus("WarpBox"); return; } const fragment = document.createDocumentFragment(); - - selectedFiles.forEach((file) => { - const row = document.createElement("div"); - row.className = "upload-file-row"; - - const icon = document.createElement("span"); - icon.className = "upload-file-icon"; - icon.setAttribute("aria-hidden", "true"); - - const name = document.createElement("span"); - name.className = "upload-file-name"; - name.textContent = file.name; - name.title = file.name; - - const size = document.createElement("span"); - size.className = "upload-file-size"; - size.textContent = formatBytes(file.size); - - row.append(icon, name, size); - fragment.append(row); + selectedFiles.forEach((selectedFile) => { + fragment.append(createFileRow(selectedFile)); }); fileList.append(fragment); updateStatus("Files selected"); + setBoxStatus("WarpBox"); +} + +async function createBox() { + const response = await fetch("/box", { method: "POST" }); + if (!response.ok) { + throw new Error("Could not create upload box"); + } + + return response.json(); +} + +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.upload.addEventListener("loadstart", () => { + setRowProgress(selectedFile.row, 2); + }); + + xhr.upload.addEventListener("progress", (event) => { + if (!event.lengthComputable) { + return; + } + + setRowProgress(selectedFile.row, (event.loaded / event.total) * 100); + }); + + xhr.addEventListener("load", () => { + if (xhr.status < 200 || xhr.status >= 300) { + selectedFile.failed = true; + selectedFile.row.classList.add("is-failed"); + reject(new Error("Upload failed")); + return; + } + + selectedFile.uploaded = true; + selectedFile.row.classList.add("is-uploaded"); + setRowProgress(selectedFile.row, 100); + onComplete(); + resolve(); + }); + + xhr.addEventListener("error", () => { + selectedFile.failed = true; + selectedFile.row.classList.add("is-failed"); + reject(new Error("Upload failed")); + }); + + xhr.send(formData); + }); } if (fileInput) { fileInput.addEventListener("change", () => { + stopStatusAnimation(); updateSelectedFiles(fileInput.files); }); } @@ -102,34 +215,54 @@ if (fileInput && dropzone) { } fileInput.files = event.dataTransfer.files; + stopStatusAnimation(); updateSelectedFiles(fileInput.files); }); } -if (uploadForm && fileInput) { +if (uploadForm) { uploadForm.addEventListener("submit", async (event) => { event.preventDefault(); - if (!fileInput.files.length) { + if (!selectedFiles.length) { updateStatus("Choose files first"); return; } - updateStatus("Uploading..."); + let completedCount = 0; + const totalCount = selectedFiles.length; + const statusPrefix = () => `${completedCount}/${totalCount}`; + + selectedFiles.forEach((selectedFile) => { + selectedFile.uploaded = false; + selectedFile.failed = false; + selectedFile.row.classList.remove("is-uploaded", "is-failed"); + setRowProgress(selectedFile.row, 0); + }); + + updateStatus(`${statusPrefix()} Uploading.`); + animateUploadStatus(statusPrefix); try { - const response = await fetch(uploadForm.action, { - method: "POST", - body: new FormData(uploadForm), - }); + const box = await createBox(); + setBoxStatus(box.box_url); - if (!response.ok) { - throw new Error("Upload failed"); + await Promise.allSettled(selectedFiles.map((selectedFile) => { + return uploadFile(box.box_id, selectedFile, () => { + completedCount += 1; + }); + })); + + stopStatusAnimation(); + const failedCount = selectedFiles.filter((selectedFile) => selectedFile.failed).length; + if (failedCount > 0) { + updateStatus(`${completedCount}/${totalCount} Uploaded, ${failedCount} failed`); + return; } - const result = await response.json(); - updateStatus(`Uploaded to ${result.box_url}`); + updateStatus(`${completedCount}/${totalCount} Uploaded`); } catch (error) { + stopStatusAnimation(); updateStatus("Upload failed"); } });