feat(server): add multipart upload endpoint with box IDs
- Add POST /upload to accept multipart file uploads - Generate random box IDs and persist files under data/uploads/<boxID> - Sanitize and deduplicate filenames to avoid invalid paths/collisions - Return box metadata and saved file details in the response - Add Win98-style upload UI styling to support the new flowfeat(server): add multipart upload endpoint with box IDs - Add POST /upload to accept multipart file uploads - Generate random box IDs and persist files under data/uploads/<boxID> - Sanitize and deduplicate filenames to avoid invalid paths/collisions - Return box metadata and saved file details in the response - Add Win98-style upload UI styling to support the new flow
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const uploadRoot = "data/uploads"
|
||||||
|
|
||||||
func Run(addr string) error {
|
func Run(addr string) error {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
router.LoadHTMLGlob("templates/*.html")
|
router.LoadHTMLGlob("templates/*.html")
|
||||||
@@ -15,8 +23,91 @@ func Run(addr string) error {
|
|||||||
ctx.HTML(http.StatusOK, "index.html", gin.H{})
|
ctx.HTML(http.StatusOK, "index.html", gin.H{})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.POST("/upload", func(ctx *gin.Context) {
|
||||||
|
form, err := ctx.MultipartForm()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := form.File["files"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boxID, err := newBoxID()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boxPath := filepath.Join(uploadRoot, boxID)
|
||||||
|
if err := os.MkdirAll(boxPath, 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"})
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"box_id": boxID,
|
||||||
|
"box_url": "/box/" + boxID,
|
||||||
|
"files": savedFiles,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||||
compressed.Static("/static", "./static")
|
compressed.Static("/static", "./static")
|
||||||
|
|
||||||
return router.Run(addr)
|
return router.Run(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newBoxID() (string, error) {
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(bytes), 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 {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := filepath.Ext(filename)
|
||||||
|
base := strings.TrimSuffix(filename, extension)
|
||||||
|
return fmt.Sprintf("%s-%d%s", base, count+1, extension)
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ a, .window-roach-message, .window-buttons .wbtn:not(.disabled_button) {
|
|||||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), auto;
|
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"], textarea, [contenteditable="true"] {
|
input[type="text"], input[type="file"], textarea, [contenteditable="true"] {
|
||||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text;
|
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,3 +81,373 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-window {
|
||||||
|
width: 520px;
|
||||||
|
height: 420px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #808080,
|
||||||
|
inset 1px 1px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 22px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 2px;
|
||||||
|
padding: 2px 3px 2px 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--w98-blue-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-titlebar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-window-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-control {
|
||||||
|
width: 16px;
|
||||||
|
height: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #000000;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #808080,
|
||||||
|
inset 1px 1px 0 #dfdfdf;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
height: 22px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0 8px 8px;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone {
|
||||||
|
height: 118px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
text-align: center;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border: 1px dotted #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone.is-dragging {
|
||||||
|
background: #c7d8f2;
|
||||||
|
outline: 2px solid #000078;
|
||||||
|
outline-offset: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 30px;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px solid #000000;
|
||||||
|
box-shadow: inset -3px -3px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: -2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-left: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-primary {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-secondary {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #dfdfdf;
|
||||||
|
border-bottom: 1px solid #dfdfdf;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-detail-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-count {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-list {
|
||||||
|
height: 132px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-empty-state {
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 8px;
|
||||||
|
color: #555555;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) 82px;
|
||||||
|
align-items: center;
|
||||||
|
height: 26px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid #dfdfdf;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-row:nth-child(even) {
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 18px;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset -2px -2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-icon::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-left: 1px solid #000000;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-name,
|
||||||
|
.upload-file-size {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-size {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button {
|
||||||
|
width: 92px;
|
||||||
|
height: 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #808080,
|
||||||
|
inset 1px 1px 0 #dfdfdf;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
text-align: center;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button:active {
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #dfdfdf,
|
||||||
|
inset 1px 1px 0 #808080;
|
||||||
|
padding: 1px 9px 0 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button:focus-visible,
|
||||||
|
.upload-dropzone:focus-visible {
|
||||||
|
outline: 1px dotted #000000;
|
||||||
|
outline-offset: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-statusbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 96px;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-statusbar span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-window {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-titlebar {
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-menu {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel {
|
||||||
|
margin: 0 6px 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone {
|
||||||
|
height: 126px;
|
||||||
|
min-height: 126px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-list {
|
||||||
|
height: calc(100dvh - 284px);
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
136
static/js/app.js
136
static/js/app.js
@@ -0,0 +1,136 @@
|
|||||||
|
const fileInput = document.querySelector("#file-upload");
|
||||||
|
const fileCount = document.querySelector("#upload-file-count");
|
||||||
|
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");
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitIndex === 0) {
|
||||||
|
return `${size} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(message) {
|
||||||
|
if (uploadStatus) {
|
||||||
|
uploadStatus.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedFiles(files) {
|
||||||
|
const selectedFiles = Array.from(files || []);
|
||||||
|
|
||||||
|
if (fileCount) {
|
||||||
|
fileCount.textContent = `${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileList.replaceChildren();
|
||||||
|
|
||||||
|
if (!selectedFiles.length) {
|
||||||
|
const emptyState = document.createElement("p");
|
||||||
|
emptyState.className = "upload-empty-state";
|
||||||
|
emptyState.textContent = "No files selected";
|
||||||
|
fileList.append(emptyState);
|
||||||
|
updateStatus("Ready");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileList.append(fragment);
|
||||||
|
updateStatus("Files selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
updateSelectedFiles(fileInput.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInput && dropzone) {
|
||||||
|
dropzone.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dropzone.classList.add("is-dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener("dragleave", () => {
|
||||||
|
dropzone.classList.remove("is-dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener("drop", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dropzone.classList.remove("is-dragging");
|
||||||
|
|
||||||
|
if (!event.dataTransfer.files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.files = event.dataTransfer.files;
|
||||||
|
updateSelectedFiles(fileInput.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadForm && fileInput) {
|
||||||
|
uploadForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
updateStatus("Choose files first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus("Uploading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(uploadForm.action, {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(uploadForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
updateStatus(`Uploaded to ${result.box_url}`);
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus("Upload failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,54 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
<section class="upload-window" aria-labelledby="upload-window-title">
|
||||||
|
<header class="upload-titlebar">
|
||||||
|
<h1 id="upload-window-title">WarpBox Upload</h1>
|
||||||
|
<div class="upload-window-controls" aria-hidden="true">
|
||||||
|
<span class="upload-control">_</span>
|
||||||
|
<span class="upload-control">□</span>
|
||||||
|
<span class="upload-control">×</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="upload-form" action="/upload" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="upload-menu" aria-hidden="true">
|
||||||
|
<span>File</span>
|
||||||
|
<span>Edit</span>
|
||||||
|
<span>View</span>
|
||||||
|
<span>Help</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-panel">
|
||||||
|
<label class="upload-dropzone" for="file-upload">
|
||||||
|
<span class="upload-icon" aria-hidden="true"></span>
|
||||||
|
<span class="upload-primary">Drop file here</span>
|
||||||
|
<span class="upload-secondary">or click Browse to choose one</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input id="file-upload" class="upload-input" type="file" name="files" multiple>
|
||||||
|
|
||||||
|
<div class="upload-details">
|
||||||
|
<span class="upload-detail-label">Selected Files</span>
|
||||||
|
<span id="upload-file-count" class="upload-file-count">0 files</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-file-list" aria-live="polite" aria-label="Selected files">
|
||||||
|
<p class="upload-empty-state">No files selected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="upload-actions">
|
||||||
|
<label class="win98-button" for="file-upload">Browse...</label>
|
||||||
|
<button class="win98-button" type="submit">Upload</button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div class="upload-statusbar" aria-live="polite">
|
||||||
|
<span>Ready</span>
|
||||||
|
<span>WarpBox</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user