diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/lib/server/server.go b/lib/server/server.go index ed9e8f4..a5119ca 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,12 +1,20 @@ package server import ( + "crypto/rand" + "encoding/hex" + "fmt" "net/http" + "os" + "path/filepath" + "strings" "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" ) +const uploadRoot = "data/uploads" + func Run(addr string) error { router := gin.Default() router.LoadHTMLGlob("templates/*.html") @@ -15,8 +23,91 @@ func Run(addr string) error { 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.Static("/static", "./static") 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) +} diff --git a/static/css/app.css b/static/css/app.css index 5fa73f2..d8c9d76 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } -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; } @@ -80,4 +80,374 @@ body { width: 100vw; min-height: 100vh; height: auto; -} \ No newline at end of file +} + +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; + } +} diff --git a/static/js/app.js b/static/js/app.js index e69de29..a8ff93f 100644 --- a/static/js/app.js +++ b/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"); + } + }); +} diff --git a/templates/index.html b/templates/index.html index 54ff993..55fafb0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,11 +10,56 @@