diff --git a/lib/server/handlers.go b/lib/server/handlers.go index f6210d5..3ebe002 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -516,10 +516,39 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) { return } - savedFiles := make([]models.BoxFile, 0, len(files)) + retentionKey := strings.TrimSpace(ctx.PostForm("retention_key")) + if retentionKey == "" { + retentionKey = strings.TrimSpace(ctx.PostForm("retention")) + } + allowZip := true + if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") { + allowZip = false + } + request := models.CreateBoxRequest{ + RetentionKey: retentionKey, + Password: ctx.PostForm("password"), + AllowZip: &allowZip, + Files: make([]models.CreateBoxFileRequest, 0, len(files)), + } for _, file := range files { - savedFile, err := boxstore.SaveUpload(boxID, file) + request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size}) + } + if err := app.validateCreateBoxRequest(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + manifestFiles, err := boxstore.CreateManifest(boxID, request) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + savedFiles := make([]models.BoxFile, 0, len(files)) + for index, file := range files { + savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file) if err != nil { + _, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/static/css/admin.css b/static/css/admin.css index 2dbbf1c..654f157 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -11,6 +11,10 @@ body { display: grid; gap: 16px; padding: 16px; + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)), + repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px); } .admin-nav { @@ -35,6 +39,12 @@ body { padding: 12px; color: inherit; text-decoration: none; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; } .admin-link strong, @@ -50,6 +60,10 @@ body { width: 100%; border-collapse: collapse; background: #fff; + border-top: 2px solid #808080; + border-left: 2px solid #808080; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; } .admin-table th, @@ -74,7 +88,14 @@ body { .admin-form-row textarea, .admin-form-row select { width: 100%; - box-sizing: border-box; + min-height: 24px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-family: inherit; } .admin-checks { @@ -103,4 +124,9 @@ body { .admin-summary span { padding: 6px 8px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; } diff --git a/static/css/app.css b/static/css/app.css index 5fbddb2..fede830 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -15,82 +15,183 @@ } @font-face { - font-family: 'PixelOperatorMono'; - src: url('/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf'); - font-weight: bold; -} - -@font-face { - font-family: 'PixeloidSans'; - src: url('/static/fonts/pixeloid_sans/PixeloidSans.ttf'); -} - -@font-face { - font-family: 'PixeloidSans'; - src: url('/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf'); - font-weight: bold; + font-family: 'MonoCraft'; + src: url('/static/fonts/Monocraft.ttf'); } :root { - font-family: 'PixeloidSans', 'PixelOperator', sans-serif, Arial, Helvetica; + font-family: 'PixelOperator', 'MS Sans Serif', Arial, sans-serif; font-smooth: never; - image-rendering: pixelated; - cursor: url('/static/cursors/vaporwave-hotline-white-plus/Normal\ Select.cur'), auto; --base-font-size: 14px; - - /* Colours */ --w98-blue: #000078; - --w98-blue-gradient: linear-gradient(to right, #000078, 80%, #0f80cd); - --w98-gray: #c0c0c0; + --w98-blue-gradient: linear-gradient(90deg, #000078 0%, #000078 28%, #0f80cd 50%, #000078 72%, #000078 100%); + --w98-gray: #c0c0c0; --w98-gray2: #a6a6a6; - --w98-gray-gradient: linear-gradient(to bottom, #fff, 95%, #c0c0c0); - - scroll-behavior: smooth; + --ok: #008000; + --danger: #800000; } -a, -button, -label[for], -.win98-button:not(:disabled) { - cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), auto; -} - -input[type="text"], -input[type="password"], -input[type="file"], -textarea, -[contenteditable="true"] { - cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text; +* { + box-sizing: border-box; + scrollbar-width: auto; + scrollbar-color: #c0c0c0 #808080; } html { + min-height: 100%; font-size: var(--base-font-size); - color: white; - background-color: #000; + color: #ffffff; + background: #000000; } html, body { margin: 0; padding: 0; - overflow-x: hidden; } body { - width: 100vw; min-height: 100vh; - height: auto; + overflow-x: hidden; background-color: #000000; background-image: url('/static/img/bg/stars1.gif'); background-repeat: repeat; + background-size: auto; + font-family: 'PixelOperator', 'MS Sans Serif', Arial, sans-serif; } main { + min-height: 100vh; display: grid; place-items: center; - width: 100vw; - min-height: 100vh; + padding: 18px; +} + +button, +label[for], +.menu-button, +.win98-button:not(:disabled), +a { + cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), pointer; +} + +button, +input, +select, +textarea { + font-family: inherit; +} + +input[type="text"], +input[type="password"], +input[type="number"], +input[type="file"], +textarea { + cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text; +} + +:focus-visible { + outline: 2px dotted #000078; + outline-offset: 2px; +} + +::-webkit-scrollbar { + width: 17px; + height: 17px; + background: #c0c0c0; +} + +::-webkit-scrollbar-track { + background: repeating-linear-gradient(45deg, #c0c0c0 0 2px, #b5b5b5 2px 4px); + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; +} + +::-webkit-scrollbar-thumb, +::-webkit-scrollbar-button:single-button { + background: #c0c0c0; + 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; +} + +::-webkit-scrollbar-corner { + background: #c0c0c0; +} + +.win98-button { + min-width: 92px; + height: 28px; + 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-size: 13px; + line-height: 13px; + text-align: center; + text-decoration: none; + appearance: none; +} + +.win98-button:disabled, +button:disabled, +input:disabled, +select:disabled, +textarea:disabled { + cursor: not-allowed; +} + +.win98-button:disabled { + color: #808080; + text-shadow: 1px 1px 0 #ffffff; +} + +.win98-button:active:not(:disabled), +.win98-control:active, +.menu-button[aria-expanded="true"] { + 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-top: 1px; +} + +@media (min-width: 1800px) { + :root { --base-font-size: 15px; } + .desktop-wrap { zoom: 1.2; } +} + +@media (min-width: 2048px) { + :root { --base-font-size: 16px; } + .desktop-wrap { zoom: 1.36; } +} + +@media (min-width: 2560px) { + :root { --base-font-size: 18px; } + .desktop-wrap { zoom: 1.58; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } } diff --git a/static/css/box.css b/static/css/box.css index b170cbb..b9a83b2 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -1,13 +1,12 @@ .box-window { - width: 640px; - height: 460px; + width: min(760px, calc(100vw - 36px)); + height: min(560px, calc(100vh - 36px)); } .box-toolbar { display: flex; gap: 8px; height: 40px; - box-sizing: border-box; padding: 6px 8px; } @@ -20,7 +19,6 @@ grid-template-columns: 58px minmax(0, 1fr); align-items: center; height: 28px; - box-sizing: border-box; padding: 0 8px 6px; gap: 6px; font-size: 13px; @@ -32,7 +30,6 @@ grid-template-columns: 58px minmax(0, 1fr); align-items: center; height: 24px; - box-sizing: border-box; padding: 0 8px 6px; gap: 6px; color: #333333; @@ -45,7 +42,6 @@ height: 22px; display: flex; align-items: center; - box-sizing: border-box; padding: 0 6px; overflow: hidden; text-overflow: ellipsis; @@ -64,6 +60,10 @@ min-height: 0; margin: 0 8px 8px; overflow: auto; + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)), + repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px); } .box-file-grid { @@ -81,7 +81,6 @@ grid-template-rows: 34px 18px 28px; justify-items: center; align-items: center; - box-sizing: border-box; padding: 8px 6px; color: #000000; text-decoration: none; @@ -185,6 +184,7 @@ main { display: block; min-height: 100dvh; + padding: 0; } .box-window { diff --git a/static/css/login.css b/static/css/login.css index 10b7ec6..1b29697 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -14,7 +14,12 @@ flex: 1; margin: 8px; padding: 12px; - background: #c0c0c0; + background-color: #dfdfdf; + background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.18) 0 1px, transparent 1px 5px); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; } .login-alert { @@ -52,7 +57,6 @@ .login-input { width: 100%; height: 24px; - box-sizing: border-box; padding: 2px 5px; color: #000000; background: #ffffff; @@ -82,7 +86,6 @@ justify-content: flex-end; gap: 8px; height: 40px; - box-sizing: border-box; padding: 0 8px 8px; } @@ -98,6 +101,7 @@ main { display: block; min-height: 100dvh; + padding: 0; } .login-window { diff --git a/static/css/upload.css b/static/css/upload.css index 60bc550..b929821 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -1,6 +1,29 @@ +.upload-main { + height: 100vh; + min-height: 0; + overflow: hidden; +} + +.desktop-wrap { + --window-height: 736px; + --side-width: 440px; + width: min(1278px, 100%); + height: min(var(--window-height), calc(100vh - 36px)); + max-height: calc(100vh - 36px); + display: grid; + grid-template-columns: minmax(0, 820px) var(--side-width); + grid-template-rows: minmax(0, 1fr); + align-items: stretch; + justify-content: center; + gap: 18px; + overflow: hidden; +} + .upload-window { - width: 520px; - height: 566px; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; } .upload-form { @@ -10,6 +33,108 @@ min-height: 0; } +.menu-bar { + position: relative; + display: flex; + align-items: center; + gap: 2px; + height: 24px; + padding: 1px 6px; + font-size: 13px; + line-height: 13px; + z-index: 5; +} + +.menu-item { + position: relative; +} + +.menu-button { + height: 20px; + min-width: 54px; + padding: 0 8px; + color: #000000; + background: transparent; + border: 1px solid transparent; + font-family: inherit; + font-size: 13px; + text-align: left; +} + +.menu-button:hover, +.menu-button:focus-visible { + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + outline: none; +} + +.menu-popup { + position: absolute; + top: 22px; + left: 0; + min-width: 198px; + padding: 2px; + display: none; + 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: 3px 3px 0 rgba(0,0,0,.35); + z-index: 20; +} + +.menu-item.is-open .menu-popup { + display: block; +} + +.menu-action { + width: 100%; + min-height: 22px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 2px 6px; + color: #000000; + background: transparent; + border: 0; + font-family: inherit; + font-size: 12px; + text-align: left; +} + +.menu-action img { + width: 16px; + height: 16px; + object-fit: contain; + image-rendering: pixelated; +} + +.menu-action:hover, +.menu-action:focus-visible { + color: #ffffff; + background: #000078; + outline: none; +} + +.menu-separator { + height: 1px; + margin: 3px 2px; + background: #808080; + border-bottom: 1px solid #ffffff; +} + +.shortcut { + color: #555555; +} + +.menu-action:hover .shortcut { + color: #ffffff; +} + .upload-panel { display: flex; flex: 1; @@ -17,55 +142,141 @@ min-height: 0; margin: 0 8px 8px; padding: 12px; + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)), + repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px); +} + +.upload-header { + display: grid; + grid-template-columns: minmax(0, 1fr) 270px; + gap: 10px; + margin-bottom: 10px; + padding: 8px; + color: #000000; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; +} + +.upload-heading { + margin: 0 0 4px; + font-size: 20px; + line-height: 22px; + font-weight: bold; +} + +.upload-subtext { + margin: 0; + color: #333333; + font-size: 13px; + line-height: 15px; +} + +.upload-quota { + min-width: 250px; + padding: 7px; + overflow: hidden; + background: #c7d8f2; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #404040; + border-bottom: 1px solid #404040; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #e9f2ff; + font-size: 12px; + line-height: 13px; +} + +.upload-quota strong { + display: block; + margin-bottom: 4px; + font-size: 13px; +} + +.upload-quota.is-quota-warning { + background: repeating-linear-gradient(45deg, #ffdede 0 5px, #fff2a8 5px 10px); + border-color: #800000; + animation: quota-warning-breathe 900ms steps(4, end) infinite; +} + +.upload-quota-track, +.upload-overall-track, +.upload-progress { + display: block; + min-width: 0; + overflow: hidden; + background-color: #ffffff; + background-image: repeating-linear-gradient(to right, rgba(0,0,0,.05) 0 1px, transparent 1px 18px); + border-top: 2px solid #808080; + border-left: 2px solid #808080; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +.upload-quota-track { + width: 100%; + height: 16px; + margin-top: 6px; +} + +.upload-quota-bar, +.upload-overall-bar, +.upload-progress-bar { + display: block; + width: 0%; + max-width: 100%; + height: 100%; + background-color: #000078; + background-image: repeating-linear-gradient(to right, rgba(255,255,255,.12) 0 1px, transparent 1px 18px); + transform-origin: left center; +} + +.upload-quota-bar.is-over-quota { + background-image: repeating-linear-gradient(45deg, #800000 0 7px, #ffcc00 7px 14px); } .upload-dropzone { flex: 0 0 auto; - height: 88px; - box-sizing: border-box; + min-height: 154px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; - padding: 14px; + padding: 18px; text-align: center; - background: #dfdfdf; - border: 1px dotted #000000; + color: #000000; + background: repeating-linear-gradient(45deg, #dfdfdf 0 4px, #e9e9e9 4px 8px), #dfdfdf; + border: 1px solid #808080; + box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080, inset 2px 2px 0 rgba(0,0,0,.18), 0 1px 0 rgba(255,255,255,.7); } -.upload-dropzone.is-dragging { - background: #c7d8f2; - outline: 2px solid #000078; - outline-offset: -4px; +.upload-dropzone.is-dragging, +.upload-dropzone:hover { + background: repeating-linear-gradient(45deg, #c7d8f2 0 4px, #d8e5f8 4px 8px), #c7d8f2; + outline: 2px dashed #000078; + outline-offset: -6px; } -.upload-dropzone:focus-visible { - outline: 1px dotted #000000; - outline-offset: -5px; +.upload-dropzone.is-current-step { + animation: dropzone-attention 1500ms steps(5, end) infinite; } -.upload-icon { +.upload-dropzone.is-locked { + opacity: .72; + cursor: not-allowed; + filter: grayscale(.3); +} + +.upload-icon-img { 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; + height: 34px; + object-fit: contain; + image-rendering: pixelated; } .upload-primary { @@ -75,10 +286,17 @@ } .upload-secondary { + color: #333333; font-size: 13px; line-height: 15px; } +.upload-linklike { + color: #000078; + text-decoration: underline; + font-weight: bold; +} + .upload-input { position: absolute; width: 1px; @@ -87,89 +305,18 @@ clip: rect(0, 0, 0, 0); } -.upload-options { - flex: 0 0 auto; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 6px 10px; - box-sizing: border-box; - margin: 10px 0 0; - padding: 8px 8px 10px; - background: #dfdfdf; - border-top: 1px solid #ffffff; - border-left: 1px solid #ffffff; - border-right: 1px solid #808080; - border-bottom: 1px solid #808080; - font-size: 12px; - line-height: 12px; -} - -.upload-options legend { - padding: 0 4px; - font-weight: bold; -} - -.upload-option-row { - grid-column: 1 / 3; - display: grid; - grid-template-columns: 76px minmax(0, 1fr); - align-items: center; - gap: 6px; -} - -.upload-check-row { - display: flex; - align-items: center; - min-width: 0; - gap: 5px; - white-space: nowrap; -} - -.upload-check-row input { - width: 13px; - height: 13px; - margin: 0; -} - -.upload-select, -.upload-text-input { - width: 100%; - height: 22px; - box-sizing: border-box; - padding: 1px 4px; - color: #000000; - background: #ffffff; - border-top: 1px solid #808080; - border-left: 1px solid #808080; - border-right: 1px solid #ffffff; - border-bottom: 1px solid #ffffff; - font-family: inherit; - font-size: 12px; - line-height: 12px; -} - -.upload-text-input { - min-width: 0; -} - -.upload-text-input:disabled { - color: #808080; - background: #c0c0c0; -} - .upload-details { - flex: 0 0 auto; display: flex; align-items: center; - height: 28px; + min-height: 28px; margin-top: 12px; - padding: 0 8px; - box-sizing: border-box; + padding: 5px 8px; background: #ffffff; border-top: 1px solid #808080; border-left: 1px solid #808080; border-right: 1px solid #dfdfdf; border-bottom: 1px solid #dfdfdf; + box-shadow: inset 1px 1px 0 rgba(0,0,0,.16), inset -1px -1px 0 rgba(255,255,255,.75); font-size: 13px; line-height: 13px; } @@ -189,99 +336,56 @@ min-height: 0; margin-top: 8px; overflow-y: auto; - border-top: 2px solid #808080; - border-left: 2px solid #808080; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; } -.upload-result { - flex: 0 0 auto; - display: grid; - grid-template-columns: 72px minmax(0, 1fr) 72px; - align-items: center; - gap: 6px; - height: 36px; - box-sizing: border-box; - margin-top: 8px; - padding: 4px 6px; - background: #dfdfdf; - border-top: 1px solid #808080; - border-left: 1px solid #808080; - border-right: 1px solid #ffffff; - border-bottom: 1px solid #ffffff; - font-size: 12px; - line-height: 12px; -} - -.upload-result.is-hidden { - visibility: hidden; -} - -.upload-result-label { - font-weight: bold; -} - -.upload-result-link { - min-width: 0; - overflow: hidden; - color: #000078; - text-overflow: ellipsis; - white-space: nowrap; -} - -.upload-result-link.is-empty { - color: #555555; - pointer-events: none; - text-decoration: none; -} - -.upload-share-button { - width: 72px; - height: 24px; - font-size: 12px; - line-height: 12px; -} - -.upload-share-button:disabled { - color: #808080; - text-shadow: 1px 1px 0 #ffffff; -} - .upload-empty-state { margin: 0; - padding: 9px 8px; + padding: 10px 8px; color: #555555; font-size: 13px; - line-height: 13px; + line-height: 15px; } .upload-file-row { display: grid; - grid-template-columns: 22px minmax(0, 1fr) 82px; + grid-template-columns: 22px minmax(0, 1fr) 82px 30px; grid-template-rows: 20px 8px; align-items: center; - height: 36px; - box-sizing: border-box; + height: 38px; padding: 4px 8px; border-bottom: 1px solid #dfdfdf; font-size: 13px; line-height: 13px; + column-gap: 6px; } -.upload-file-row:nth-child(even) { - background: #f7f7f7; -} +.upload-file-row:nth-child(odd) { background: rgba(255,255,255,.92); } +.upload-file-row:nth-child(even) { background: rgba(240,244,255,.88); } +.upload-file-row:hover { background: #d8e5f8; } +.upload-file-row.is-working { animation: upload-row-loading 900ms steps(2, end) infinite; } +.upload-file-row.is-failed { background: #ffe2e2 !important; } +.upload-file-row.is-too-large { position: relative; background: #fff0b8 !important; animation: row-warning-breathe 900ms steps(4, end) infinite; } -.upload-file-row.is-uploading, -.upload-file-row.is-processing { - animation: upload-row-loading 900ms steps(2, end) infinite; +.upload-file-row.is-too-large::after { + content: ""; + position: absolute; + inset: 1px; + pointer-events: none; + border: 2px solid transparent; + border-image: repeating-linear-gradient(90deg, #800000 0 8px, #ffcc00 8px 16px) 1; } .upload-file-icon { grid-row: 1 / 3; width: 18px; height: 18px; + display: grid; + place-items: center; object-fit: contain; image-rendering: pixelated; } @@ -304,56 +408,57 @@ .upload-file-size { text-align: right; + color: #333333; +} + +.upload-file-remove { + grid-column: 4; + grid-row: 1 / 3; + justify-self: end; + width: 22px; + min-width: 22px; + height: 22px; + padding: 0; + font-size: 12px; } .upload-progress { grid-column: 2 / 4; grid-row: 2; - display: block; height: 8px; - box-sizing: border-box; - overflow: hidden; - background: #ffffff; + width: 100%; + border-width: 1px; +} + +.upload-file-row.is-uploaded .upload-progress-bar { background-color: #008000; } +.upload-file-row.is-failed .upload-progress-bar { width: 100%; background-color: #800000; } + +.upload-result { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 72px; + align-items: center; + gap: 6px; + min-height: 36px; + margin-top: 8px; + padding: 4px 6px; + background: #dfdfdf; border-top: 1px solid #808080; border-left: 1px solid #808080; - border-right: 1px solid #dfdfdf; - border-bottom: 1px solid #dfdfdf; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + box-shadow: inset 1px 1px 0 rgba(0,0,0,.16), inset -1px -1px 0 rgba(255,255,255,.75); + font-size: 12px; + line-height: 12px; } -.upload-progress-bar { - display: block; - width: 0%; - height: 100%; - background: #000078; +.upload-result.is-current-step { + animation: share-ready-pulse 1100ms steps(4, end) infinite; } -.upload-file-row.is-uploaded .upload-progress-bar { - background: #008000; -} - -.upload-file-row.is-failed .upload-progress-bar { - width: 100%; - background: #800000; -} - -@keyframes upload-row-loading { - 0% { - background-color: #ffffff; - } - - 100% { - background-color: #e6e6e6; - } -} - -.upload-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - height: 40px; - box-sizing: border-box; - padding: 0 8px 8px; -} +.upload-result-label { font-weight: bold; } +.upload-result-link { min-width: 0; overflow: hidden; color: #000078; text-overflow: ellipsis; white-space: nowrap; } +.upload-result-link.is-empty { color: #555555; text-decoration: none; pointer-events: none; } +.upload-share-button { min-width: 72px; width: 72px; height: 24px; font-size: 12px; line-height: 12px; } .upload-overall { display: grid; @@ -361,7 +466,6 @@ align-items: center; gap: 6px; height: 28px; - box-sizing: border-box; padding: 0 8px 8px; font-size: 12px; line-height: 12px; @@ -369,7 +473,490 @@ .upload-overall-track { height: 18px; - box-sizing: border-box; +} + +.upload-overall-percent { + min-width: 0; + text-align: right; +} + +.upload-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + height: 40px; + padding: 0 8px 8px; +} + +.start-upload-cta { + min-width: 128px; + position: relative; + overflow: visible; + isolation: isolate; + font-weight: bold; +} + +.start-upload-cta.is-current-step { + animation: start-ready-rainbow-breathe 1150ms ease-in-out infinite; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 0 0 1px #000000; +} + +.start-upload-cta.is-current-step::after { + content: ""; + position: absolute; + inset: -4px; + pointer-events: none; + z-index: 1; + padding: 4px; + background: linear-gradient(90deg, #ff004c, #ffcc00, #00d26a, #00a2ff, #8c48ff, #ff004c, #ffcc00); + background-size: 280% 100%; + opacity: .9; + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: start-border-rainbow-slide 1850ms linear infinite; +} + +.upload-statusbar { + grid-template-columns: 1fr 100px; +} + +.side-stack { + width: var(--side-width); + min-width: var(--side-width); + max-width: var(--side-width); + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: var(--side-width); + grid-template-rows: 350px 210px 1fr; + gap: 12px; + overflow: hidden; +} + +.side-panel, +.helper-window { + width: var(--side-width); + min-width: var(--side-width); + max-width: var(--side-width); + min-height: 0; + overflow: hidden; +} + +.side-panel { + display: flex; + flex-direction: column; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); +} + +.side-body, +.helper-body, +.popup-body { + margin: 0 6px 6px; + padding: 9px; + color: #000000; + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)), + repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px); + font-size: 13px; + line-height: 15px; +} + +.side-body { + flex: 1 1 auto; + overflow: auto; +} + +.box-options-form { + display: grid; + gap: 8px; + min-height: 100%; + align-content: start; +} + +.box-options-form.is-locked { + opacity: .82; + filter: grayscale(.12); +} + +.box-options-form.is-locked::after { + content: "Box sealed after upload"; + display: block; + margin-top: 8px; + padding: 5px 6px; + color: #000000; + background: #dfdfdf; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 13px; +} + +.option-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 6px; + align-items: center; +} + +.option-check { + position: relative; + min-height: 18px; + display: flex; + gap: 6px; + align-items: center; +} + +.option-check input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; + pointer-events: none; +} + +.option-check span { + position: relative; + min-height: 16px; + display: inline-flex; + align-items: center; + padding-left: 22px; +} + +.option-check span::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 14px; + height: 14px; + background: #ffffff; + border-top: 2px solid #808080; + border-left: 2px solid #808080; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; + box-shadow: inset -1px -1px 0 #dfdfdf; +} + +.option-check input[type="checkbox"]:checked + span::after { + content: "✓"; + position: absolute; + left: 2px; + top: -3px; + color: #000000; + font-family: Arial, Helvetica, sans-serif; + font-size: 18px; + line-height: 18px; + font-weight: bold; +} + +.upload-select, +.upload-text-input { + width: 100%; + height: 22px; + padding: 1px 4px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 12px; +} + +.upload-text-input:disabled, +.upload-select:disabled, +.box-options-form.is-locked input[readonly], +.box-options-form.is-locked input:disabled, +.box-options-form.is-locked select:disabled { + color: #404040; + background: repeating-linear-gradient(45deg, #d0d0d0 0 4px, #c7c7c7 4px 8px); +} + +.api-key-row { + display: none; +} + +.api-key-row.is-visible { + display: grid; +} + +.api-key-field { + position: relative; + display: block; +} + +.api-key-state { + position: absolute; + right: 4px; + top: 3px; + color: #000078; + font-size: 11px; + line-height: 12px; + pointer-events: none; +} + +.api-key-field.is-checking::after { + content: ""; + position: absolute; + inset: 2px; + background: repeating-linear-gradient(90deg, rgba(0,0,120,.16) 0 8px, rgba(15,128,205,.16) 8px 16px); + animation: api-key-scan 700ms steps(6, end) infinite; + pointer-events: none; +} + +.terminal-box { + flex: 1 1 auto; + min-height: 104px; + max-height: 134px; + overflow: auto; + padding: 10px; + color: #b4efbd; + background-color: #030403; + background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px); + border: 0; + box-shadow: inset 1px 1px 0 #000000, inset -1px -1px 0 rgba(255,255,255,.22); + font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; + font-size: 13px; + line-height: 16px; + white-space: pre-wrap; +} + +.terminal-box::after { + content: "█"; + display: inline-block; + margin-left: 2px; + color: #7dff8a; + animation: terminal-cursor 1s steps(2, end) infinite; +} + +.terminal-muted { + color: #79ad83; +} + +.terminal-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; + padding-top: 2px; +} + +.terminal-copy-button { + min-width: 148px; + height: 24px; + font-size: 12px; + line-height: 12px; +} + +.helper-body { + height: calc(100% - 34px); + min-height: 0; + display: flex; + justify-content: flex-start; + align-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + gap: 8px; + overflow: auto; +} + +.folder-icon-button { + flex: 0 0 86px; + width: 86px; + min-width: 86px; + height: 68px; + display: grid; + grid-template-rows: 34px 1fr; + place-items: center; + gap: 4px; + padding: 4px; + color: #000000; + background: transparent; + border: 1px solid transparent; + font-family: inherit; + font-size: 12px; + line-height: 12px; +} + +.folder-icon-button img { + width: 34px; + height: 34px; + object-fit: contain; + image-rendering: pixelated; +} + +.folder-icon-button:hover, +.folder-icon-button:focus-visible { + color: #ffffff; + background: #000078; + border: 1px dotted #ffffff; + outline: none; +} + +.folder-icon-button-disabled { + color: #606060; +} + +.folder-icon-button-disabled img { + filter: grayscale(.9); + opacity: .75; +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: none; + background: rgba(128, 128, 128, .42); + z-index: 70; +} + +.modal-backdrop.is-visible { + display: block; +} + +.popup-window { + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(780px, calc(100vw - 24px)); + max-height: min(760px, calc(100vh - 24px)); + display: none; + z-index: 80; +} + +.popup-window.is-visible { + display: flex; + animation: popup-open-v10 180ms steps(5, end); +} + +.popup-window.is-about-popup { + width: min(360px, calc(100vw - 28px)); + min-height: 220px; +} + +.popup-body { + max-height: calc(100vh - 90px); + padding: 12px; + overflow: auto; + font-size: 13px; + line-height: 16px; +} + +.popup-body h3 { margin: 0 0 8px; font-size: 16px; line-height: 18px; } +.popup-body h4 { margin: 14px 0 6px; font-size: 14px; line-height: 16px; } +.popup-body p { margin: 0 0 8px; } +.popup-body ul, +.popup-body ol { margin: 0 0 8px 18px; padding: 0; } +.popup-body li { margin: 0 0 4px; } +.popup-body pre { + margin: 6px 0 10px; + padding: 8px; + overflow: auto; + color: #00ff66; + background: #000000; + border: 0; + font-family: 'PixelOperatorMono', 'Courier New', monospace; + font-size: 12px; + line-height: 15px; + white-space: pre-wrap; +} + +.popup-close { + cursor: pointer; +} + +.toast { + position: fixed; + right: 12px; + bottom: 52px; + max-width: min(360px, calc(100vw - 24px)); + display: none; + padding: 8px 10px; + color: #000000; + background: #ffffcc; + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + z-index: 60; + font-size: 12px; + line-height: 14px; + box-shadow: 4px 4px 0 rgba(0,0,0,.45); +} + +.toast.is-visible { + display: block; + animation: toast-in 180ms steps(3, end), toast-buzz 700ms steps(2, end) 180ms; +} + +.toast.toast-warning { + color: #000000; + background: #ffffcc; + border: 4px solid transparent; + border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 4; +} + +.toast.toast-error { + color: #ffffff; + background: #b00000; + text-shadow: 1px 1px 0 #000000; + border-color: #ffb0b0 #330000 #330000 #ffb0b0; +} + +.duplicate-list, +.quota-dialog-list { + margin: 8px 0; + padding: 6px 6px 6px 28px; + background: #ffffff; + border-top: 2px solid #808080; + border-left: 2px solid #808080; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; + max-height: 180px; + overflow: auto; +} + +.quota-dialog-summary, +.quota-note { + padding: 8px; + background: #ffffcc; + border: 1px solid #808080; +} + +.quota-meter-list, +.faq-list, +.shortcut-list { + display: grid; + gap: 10px; +} + +.quota-meter, +.faq-item, +.shortcut-list li { + padding: 8px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; +} + +.quota-meter-head { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 5px; + font-weight: bold; +} + +.quota-meter-track { + height: 18px; overflow: hidden; background: #ffffff; border-top: 2px solid #808080; @@ -378,71 +965,154 @@ border-bottom: 2px solid #ffffff; } -.upload-overall-bar { +.quota-meter-bar { display: block; - width: 0%; height: 100%; - background-color: #000078; - background-image: repeating-linear-gradient( - to right, - #000078 0, - #000078 10px, - #c0c0c0 10px, - #c0c0c0 12px - ); + background: #000078; } -.upload-overall-percent { - min-width: 0; - text-align: right; +.copy-fallback-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; } -.upload-statusbar { - grid-template-columns: 1fr 96px; +.copy-fallback-text { + width: 100%; + min-height: 58px; + font-family: 'PixelOperatorMono', monospace; } -@media (max-width: 600px) { - main { - display: block; - min-height: 100dvh; +.kbd { + display: inline-block; + min-width: 18px; + padding: 1px 5px; + color: #000000; + background: #c0c0c0; + border: 1px solid #000000; + box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080; + text-align: center; +} + +@keyframes upload-row-loading { 0% { background-color: #ffffff; } 100% { background-color: #e6e6e6; } } +@keyframes quota-warning-breathe { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.08); } } +@keyframes row-warning-breathe { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.12); } } +@keyframes dropzone-attention { 0%, 100% { filter: brightness(1); transform: translateY(0); } 50% { filter: brightness(1.07); transform: translateY(-1px); } } +@keyframes share-ready-pulse { 50% { filter: brightness(1.08); box-shadow: 0 0 0 2px #000078; } } +@keyframes start-ready-rainbow-breathe { 0%, 100% { transform: rotate(-.35deg) scale(1); } 50% { transform: rotate(.35deg) scale(1.016); } } +@keyframes start-border-rainbow-slide { from { background-position: 0% 50%; } to { background-position: 100% 50%; } } +@keyframes terminal-cursor { 50% { opacity: 0; } } +@keyframes popup-open-v10 { from { transform: translate(-50%, -48%) scale(.97); opacity: .35; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } } +@keyframes toast-in { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@keyframes toast-buzz { 0%, 100% { margin-right: 0; } 25% { margin-right: 2px; } 50% { margin-right: -2px; } } +@keyframes api-key-scan { to { background-position: 32px 0; } } + +@media (max-width: 1320px) { + body { height: auto; min-height: 100vh; overflow-y: auto; } + .upload-main { height: auto; min-height: 100vh; place-items: start center; overflow: visible; } + .desktop-wrap { + --window-height: 680px; + grid-template-columns: minmax(0, 820px); + grid-template-rows: var(--window-height) auto; + width: min(820px, 100%); + max-width: 820px; + height: auto; + max-height: none; + overflow: visible; } + .side-stack { + width: 100%; + min-width: 0; + max-width: none; + height: auto; + grid-template-columns: 1fr; + grid-template-rows: 350px 210px 132px; + overflow: visible; + } + .side-panel, + .helper-window { + width: 100%; + min-width: 0; + max-width: none; + } +} +@media (min-width: 1440px) { + .desktop-wrap { --window-height: 780px; } + .side-stack { grid-template-rows: 372px 230px 1fr; } +} + +@media (max-width: 760px) { + .upload-main { + height: auto; + min-height: 100dvh; + place-items: stretch; + align-items: stretch; + padding: 0; + overflow: visible; + } + .desktop-wrap { + width: 100%; + max-width: none; + height: auto; + max-height: none; + min-height: 100dvh; + gap: 10px; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + overflow: visible; + } .upload-window { + min-height: 100dvh; + height: auto; + width: 100vw; + border-left: 0; + border-right: 0; + box-shadow: none; + } + .side-stack { + grid-template-rows: auto auto auto; + padding: 0 6px 12px; + } + .side-panel:first-child { min-height: 360px; } + .side-panel:nth-child(2) { min-height: 210px; } + .helper-window { min-height: 128px; } + .upload-header { grid-template-columns: 1fr; } + .upload-panel { margin: 0 6px 8px; padding: 10px; } + .upload-dropzone { min-height: 118px; padding: 14px 10px; } + .upload-primary { font-size: 16px; } + .upload-details { flex-wrap: wrap; gap: 4px; } + .upload-file-count { margin-left: 0; width: 100%; } + .upload-file-row { grid-template-columns: 22px minmax(0, 1fr) 58px 28px; padding: 4px 5px; font-size: 12px; } + .upload-result { grid-template-columns: 1fr 72px; } + .upload-result-label { grid-column: 1 / 3; } + .upload-actions { justify-content: stretch; } + .upload-actions .win98-button { flex: 1; min-width: 0; } + .menu-bar { overflow-x: auto; } + .menu-popup { position: fixed; left: 6px; right: 6px; top: 50px; min-width: 0; } + .popup-window { + left: 0; + top: 0; + transform: none; width: 100vw; height: 100dvh; + max-height: none; 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: 96px; - min-height: 96px; - } - - .upload-result { - grid-template-columns: 64px minmax(0, 1fr) 68px; - } - - .upload-options { - grid-template-columns: 1fr; - } - - .upload-option-row, - .upload-text-input { - grid-column: 1; - } + .popup-window .win98-titlebar { height: 32px; } + .popup-close { width: 28px; height: 24px; font-size: 18px; font-weight: bold; } + .popup-body { max-height: calc(100dvh - 40px); } + .popup-window.is-visible { animation: popup-open-mobile-v10 160ms steps(5, end); } + @keyframes popup-open-mobile-v10 { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } } +} + +@media (max-width: 420px) { + :root { --base-font-size: 13px; } + .win98-titlebar h1 { font-size: 13px; } + .upload-file-size { display: none; } + .upload-file-row { grid-template-columns: 22px minmax(0, 1fr) 28px; } + .upload-file-remove { grid-column: 3; } + .upload-progress { grid-column: 2 / 3; } } diff --git a/static/css/window.css b/static/css/window.css index 340fd7d..e98c0e3 100644 --- a/static/css/window.css +++ b/static/css/window.css @@ -1,16 +1,16 @@ .win98-window { - box-sizing: border-box; display: flex; flex-direction: column; 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; + background-color: #c0c0c0; + background-image: + linear-gradient(180deg, rgba(255,255,255,.34), rgba(0,0,0,.06)), + repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 1px, transparent 1px 5px); + 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, 5px 6px 0 rgba(0,0,0,.5); } .win98-titlebar { @@ -18,14 +18,23 @@ 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); + background-size: 240% 100%; + box-shadow: inset 0 1px 0 rgba(255,255,255,.35), inset 0 -1px 0 rgba(0,0,0,.35); + user-select: none; + animation: titlebar-center-drift 34s ease-in-out infinite alternate; } -.win98-titlebar h1 { +@keyframes titlebar-center-drift { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } +} + +.win98-titlebar h1, +.win98-titlebar h2 { min-width: 0; margin: 0; overflow: hidden; @@ -60,36 +69,28 @@ .win98-control { width: 16px; height: 14px; - box-sizing: border-box; display: grid; place-items: center; + padding: 0; 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; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; font-family: Arial, Helvetica, sans-serif; font-size: 12px; line-height: 12px; } -.win98-menu { - display: flex; - align-items: center; - gap: 18px; - height: 22px; - box-sizing: border-box; - padding: 0 8px; - font-size: 13px; - line-height: 13px; +.win98-minimize { + align-items: start; + padding-top: 0; + line-height: 8px; } .win98-panel { - box-sizing: border-box; background: #ffffff; border-top: 2px solid #808080; border-left: 2px solid #808080; @@ -97,51 +98,10 @@ border-bottom: 2px solid #ffffff; } -.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 { - outline: 1px dotted #000000; - outline-offset: -5px; -} - .win98-statusbar { display: grid; gap: 4px; height: 22px; - box-sizing: border-box; padding: 0 4px 4px; font-size: 12px; line-height: 12px; @@ -152,7 +112,6 @@ align-items: center; min-width: 0; padding: 0 5px; - box-sizing: border-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -161,3 +120,13 @@ border-right: 1px solid #ffffff; border-bottom: 1px solid #ffffff; } + +.win98-menu { + display: flex; + align-items: center; + gap: 18px; + height: 22px; + padding: 0 8px; + font-size: 13px; + line-height: 13px; +} diff --git a/static/js/app.js b/static/js/app.js index 801799c..a20ffe9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,48 +1,151 @@ -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"); -const boxStatus = document.querySelector(".upload-statusbar span:last-child"); -const uploadResult = document.querySelector(".upload-result"); -const boxLink = document.querySelector("#upload-box-link"); -const shareButton = document.querySelector("#upload-share-button"); -const overallProgressBar = document.querySelector(".upload-overall-bar"); -const overallProgressPercent = document.querySelector(".upload-overall-percent"); -const retentionSelect = document.querySelector("#upload-retention"); -const passwordEnabled = document.querySelector("#upload-password-enabled"); -const passwordInput = document.querySelector("#upload-password"); -const zipEnabled = document.querySelector("#upload-zip-enabled"); +const SETTINGS_KEY = "warpbox.upload.settings.v1"; + +const el = { + form: document.querySelector("#upload-form"), + fileInput: document.querySelector("#file-upload"), + dropSurface: document.querySelector("#drop-surface"), + dropzone: document.querySelector("#dropzone"), + fileList: document.querySelector("#file-list"), + queueLabel: document.querySelector("#queue-label"), + queueSize: document.querySelector("#queue-size"), + limitHint: document.querySelector("#limit-hint"), + boxSpaceText: document.querySelector("#box-space-text"), + boxSpaceBar: document.querySelector("#box-space-bar"), + overallBar: document.querySelector("#overall-bar"), + overallPercent: document.querySelector("#overall-percent"), + shareLink: document.querySelector("#share-link"), + copyButton: document.querySelector("#copy-button"), + startButton: document.querySelector("#start-button"), + statusText: document.querySelector("#status-text"), + toast: document.querySelector("#toast"), + terminal: document.querySelector("#terminal-box"), + copyCurlButton: document.querySelector("#copy-curl-button"), + docPopup: document.querySelector("#doc-popup"), + modalBackdrop: document.querySelector("#modal-backdrop"), + docPopupTitle: document.querySelector("#doc-popup-title"), + docPopupBody: document.querySelector("#doc-popup-body"), + docPopupClose: document.querySelector("#doc-popup-close"), + expiry: document.querySelector("#expiry-select"), + password: document.querySelector("#password-input"), + optionsForm: document.querySelector("#box-options-form"), + maxViews: document.querySelector("#max-views"), + boxName: document.querySelector("#box-name"), + customSlug: document.querySelector("#custom-slug"), + downloadPage: document.querySelector("#download-page"), + allowZip: document.querySelector("#allow-zip"), + allowPreview: document.querySelector("#allow-preview"), + keepFilenames: document.querySelector("#keep-filenames"), + privateBox: document.querySelector("#private-box"), + apiKeyMode: document.querySelector("#api-key-mode"), + apiKeyInput: document.querySelector("#api-key-input"), + apiKeyRow: document.querySelector("#api-key-row"), + apiKeyState: document.querySelector("#api-key-state"), +}; + +const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true"; +const defaultRetention = el.form?.dataset.defaultRetention || "10s"; +const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes); +const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes); const oneTimeRetentionKey = "one-time"; -let selectedFiles = []; +let files = []; +let shareUrl = ""; +let uploadLocked = false; let statusTimer = null; -let shareURL = ""; +let pendingDuplicateFiles = []; +let apiKeyTimer = null; -function revokePreviewURLs() { - selectedFiles.forEach((selectedFile) => { - if (selectedFile.previewURL) { - URL.revokeObjectURL(selectedFile.previewURL); - } - }); +function numberFromDataset(value) { + const number = Number.parseInt(value || "0", 10); + return Number.isFinite(number) && number > 0 ? number : 0; } 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 (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; } + return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} - if (unitIndex === 0) { - return `${size} ${units[unitIndex]}`; +function htmlEscape(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function totalBytes() { + return files.reduce((sum, item) => sum + item.file.size, 0); +} + +function uploadedBytes() { + return files.reduce((sum, item) => sum + item.loaded, 0); +} + +function overallProgress() { + const total = totalBytes(); + return total ? Math.round((uploadedBytes() / total) * 100) : 0; +} + +function oversizedFiles() { + return maxFileBytes ? files.filter((item) => item.file.size > maxFileBytes) : []; +} + +function isOverBoxQuota() { + return maxBoxBytes ? totalBytes() > maxBoxBytes : false; +} + +function hasQuotaError() { + return isOverBoxQuota() || oversizedFiles().length > 0; +} + +function normalizedFileName(name) { + return String(name || "").trim().toLowerCase(); +} + +function splitNameForIncrement(name) { + const value = String(name || "file"); + const dot = value.lastIndexOf("."); + if (dot > 0 && dot < value.length - 1) return [value.slice(0, dot), value.slice(dot)]; + return [value, ""]; +} + +function nextIncrementedFileName(name, usedNames) { + const [base, ext] = splitNameForIncrement(name); + let index = 2; + let candidate = `${base} (${index})${ext}`; + while (usedNames.has(normalizedFileName(candidate))) { + index += 1; + candidate = `${base} (${index})${ext}`; } + usedNames.add(normalizedFileName(candidate)); + return candidate; +} - return `${size.toFixed(1)} ${units[unitIndex]}`; +function makeQueuedFile(file, displayName = file.name) { + return { + file, + displayName, + loaded: 0, + uploaded: false, + failed: false, + error: "", + row: null, + boxID: "", + boxFile: null, + previewURL: file.type?.startsWith("image/") ? URL.createObjectURL(file) : "", + }; } function iconForFile(file) { @@ -50,49 +153,28 @@ function iconForFile(file) { const mimeType = file.type || ""; const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : ""; - if (extension === ".exe") { - return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"; - } - - if (mimeType.startsWith("image/")) { - return "/static/img/sprites/bitmap.png"; - } - - if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) { - return "/static/img/icons/netshow_notransm-1.png"; - } - - if (mimeType.startsWith("text/") || extension === ".md") { - return "/static/img/sprites/notepad_file-1.png"; - } - - if ( - mimeType.includes("zip") || - mimeType.includes("compressed") || - [".rar", ".7z", ".tar", ".gz"].includes(extension) - ) { - return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"; - } - - if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) { - return "/static/img/sprites/font.png"; - } - - if (extension === ".pdf") { - return "/static/img/sprites/journal.png"; - } - - if ([".html", ".css", ".js"].includes(extension)) { - return "/static/img/sprites/frame_web-0.png"; - } - + if (extension === ".exe") return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"; + if (mimeType.startsWith("image/")) return "/static/img/sprites/bitmap.png"; + if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) return "/static/img/icons/netshow_notransm-1.png"; + if (mimeType.startsWith("text/") || extension === ".md") return "/static/img/sprites/notepad_file-1.png"; + if (mimeType.includes("zip") || mimeType.includes("compressed") || [".rar", ".7z", ".tar", ".gz"].includes(extension)) return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"; + if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) return "/static/img/sprites/font.png"; + if (extension === ".pdf") return "/static/img/sprites/journal.png"; + if ([".html", ".css", ".js"].includes(extension)) return "/static/img/sprites/frame_web-0.png"; return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"; } -function updateStatus(message) { - if (uploadStatus) { - uploadStatus.textContent = message; - } +function setStatus(message) { + if (el.statusText) el.statusText.textContent = message; +} + +function showToast(message, type = "info") { + if (!el.toast) return; + el.toast.textContent = message; + el.toast.classList.remove("toast-info", "toast-warning", "toast-error", "is-visible"); + el.toast.classList.add(`toast-${type}`, "is-visible"); + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => el.toast.classList.remove("is-visible"), 2600); } function stopStatusAnimation() { @@ -105,427 +187,1021 @@ function stopStatusAnimation() { function animateUploadStatus(getPrefix) { let dotCount = 0; stopStatusAnimation(); - statusTimer = setInterval(() => { dotCount = (dotCount % 3) + 1; - updateStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`); + setStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`); }, 350); } -function setBoxStatus(message) { - if (boxStatus) { - boxStatus.textContent = message; - boxStatus.title = message; - } -} - -function isOneTimeDownloadSelected() { - return retentionSelect && retentionSelect.value === oneTimeRetentionKey; -} - -function updateZipOptionForRetention() { - if (!zipEnabled) { - return; - } - - if (isOneTimeDownloadSelected()) { - zipEnabled.checked = true; - zipEnabled.disabled = true; - return; - } - - zipEnabled.disabled = false; -} - -function setBoxLink(path) { - shareURL = path ? new URL(path, window.location.origin).toString() : ""; - - if (uploadResult) { - uploadResult.classList.toggle("is-hidden", !shareURL); - } - - if (boxLink) { - boxLink.href = shareURL || "#"; - boxLink.textContent = shareURL || "Waiting for upload"; - boxLink.title = shareURL; - boxLink.classList.toggle("is-empty", !shareURL); - boxLink.setAttribute("aria-disabled", shareURL ? "false" : "true"); - } - - if (shareButton) { - shareButton.disabled = !shareURL; - } +function setShareUrl(url) { + shareUrl = url ? new URL(url, window.location.origin).toString() : ""; + if (!el.shareLink || !el.copyButton) return; + el.shareLink.textContent = shareUrl || "Not created yet"; + el.shareLink.href = shareUrl || "#"; + el.shareLink.title = shareUrl; + el.shareLink.classList.toggle("is-empty", !shareUrl); + el.shareLink.setAttribute("aria-disabled", shareUrl ? "false" : "true"); + el.copyButton.disabled = !shareUrl; + el.copyButton.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first."; + updateTerminal(); + updateCurrentStep(); } function setOverallProgress(percent) { - const clampedPercent = Math.max(0, Math.min(100, percent)); - const displayPercent = `${Math.round(clampedPercent)}%`; + const clamped = Math.max(0, Math.min(100, percent)); + const display = `${Math.round(clamped)}%`; + if (el.overallBar) el.overallBar.style.width = display; + if (el.overallPercent) el.overallPercent.textContent = display; +} - if (overallProgressBar) { - overallProgressBar.style.width = displayPercent; - } +function setRowProgress(item, percent) { + const bar = item.row?.querySelector(".upload-progress-bar"); + if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`; +} - if (overallProgressPercent) { - overallProgressPercent.textContent = displayPercent; +function updateCurrentStep() { + const hasFiles = files.length > 0; + const allDone = hasFiles && files.every((item) => item.uploaded); + el.dropzone?.classList.toggle("is-current-step", uploadsEnabled && !hasFiles && !uploadLocked); + el.startButton?.classList.toggle("is-current-step", uploadsEnabled && hasFiles && !allDone && !uploadLocked && !hasQuotaError()); + document.querySelector(".upload-result")?.classList.toggle("is-current-step", allDone && Boolean(shareUrl)); +} + +function quotaWarningMessage(incoming = []) { + const combined = [...files, ...incoming]; + const tooBig = maxFileBytes ? combined.filter((item) => item.file.size > maxFileBytes) : []; + const total = combined.reduce((sum, item) => sum + item.file.size, 0); + if (tooBig.length) { + const list = tooBig.slice(0, 4).map((item) => `${item.displayName} (${formatBytes(item.file.size)})`).join(", "); + const more = tooBig.length > 4 ? ` and ${tooBig.length - 4} more` : ""; + return `These files are over the single-file limit of ${formatBytes(maxFileBytes)}: ${list}${more}. Remove them before uploading.`; } + if (maxBoxBytes && total > maxBoxBytes) { + return `This box is ${formatBytes(total - maxBoxBytes)} over the ${formatBytes(maxBoxBytes)} limit. Remove some files before uploading.`; + } + return ""; +} + +function updateLimitHint() { + if (!el.limitHint) return; + const parts = []; + if (maxBoxBytes) parts.push(`Max box: ${formatBytes(maxBoxBytes)}`); + if (maxFileBytes) parts.push(`max file: ${formatBytes(maxFileBytes)}`); + parts.push("links expire automatically"); + el.limitHint.textContent = parts.join(" · "); +} + +function updateQuota() { + const used = totalBytes(); + const limitText = maxBoxBytes ? ` / ${formatBytes(maxBoxBytes)}` : ""; + const overQuota = isOverBoxQuota(); + const overFile = oversizedFiles().length > 0; + const percent = maxBoxBytes ? Math.min(100, Math.round((used / maxBoxBytes) * 100)) : 0; + document.querySelector(".upload-quota")?.classList.toggle("is-quota-warning", overQuota || overFile); + if (el.boxSpaceText) el.boxSpaceText.textContent = `${formatBytes(used)}${limitText}${overQuota ? " - over quota" : ""}`; + if (el.boxSpaceBar) { + el.boxSpaceBar.style.width = `${percent}%`; + el.boxSpaceBar.classList.toggle("is-over-quota", overQuota || overFile); + } +} + +function updateQueueSummary() { + const count = files.length; + if (el.queueLabel) el.queueLabel.textContent = count ? `${count} file${count === 1 ? "" : "s"} selected` : "No files selected"; + if (el.queueSize) el.queueSize.textContent = `${formatBytes(totalBytes())} total`; } function updateOverallProgress() { - const totalBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.file.size, 0); - const loadedBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.loaded, 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); + const uploadedCount = files.filter((item) => item.uploaded).length; + const percent = overallProgress(); + setOverallProgress(percent >= 100 && uploadedCount < files.length ? 99 : percent); } -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) { +function createFileRow(item, index) { const row = document.createElement("div"); row.className = "upload-file-row"; - row.classList.toggle("has-thumbnail", Boolean(selectedFile.previewURL)); + row.dataset.index = String(index); + row.classList.toggle("has-thumbnail", Boolean(item.previewURL)); + row.classList.toggle("is-too-large", maxFileBytes > 0 && item.file.size > maxFileBytes); + row.classList.toggle("is-working", item.loaded > 0 && !item.uploaded && !item.failed); + row.classList.toggle("is-uploaded", item.uploaded); + row.classList.toggle("is-failed", item.failed); + row.title = item.error || ""; const icon = document.createElement("img"); icon.className = "upload-file-icon"; - icon.src = selectedFile.previewURL || iconForFile(selectedFile.file); + icon.src = item.previewURL || iconForFile(item.file); icon.alt = ""; 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; + name.textContent = item.displayName; + name.title = item.displayName; const size = document.createElement("span"); size.className = "upload-file-size"; - size.textContent = formatBytes(selectedFile.file.size); + size.textContent = formatBytes(item.file.size); + + const remove = document.createElement("button"); + remove.className = "win98-button upload-file-remove"; + remove.type = "button"; + remove.textContent = "×"; + remove.dataset.remove = String(index); + remove.title = uploadLocked ? "This file cannot be removed because this upload box was already created." : "Remove file"; + remove.disabled = uploadLocked; const progress = document.createElement("span"); progress.className = "upload-progress"; - progress.setAttribute("aria-hidden", "true"); + progress.setAttribute("aria-label", `Upload progress ${Math.round(item.file.size ? (item.loaded / item.file.size) * 100 : 0)} percent`); const progressBar = document.createElement("span"); progressBar.className = "upload-progress-bar"; + progressBar.style.width = `${item.uploaded ? 100 : item.failed ? 100 : Math.max(0, Math.min(100, item.file.size ? (item.loaded / item.file.size) * 100 : 0))}%`; progress.append(progressBar); - row.append(icon, name, size, progress); - selectedFile.row = row; + row.append(icon, name, size, remove, progress); + item.row = row; return row; } -function updateSelectedFiles(files) { - revokePreviewURLs(); - selectedFiles = Array.from(files || []).map((file) => ({ - file, - previewURL: file.type.startsWith("image/") ? URL.createObjectURL(file) : "", - loaded: 0, - row: null, - uploaded: false, - failed: false, - })); +function renderFiles() { + if (!el.fileList) return; + el.fileList.replaceChildren(); - updateFileCount(); - - if (!fileList) { - return; + if (!files.length) { + const empty = document.createElement("p"); + empty.className = "upload-empty-state"; + empty.textContent = uploadsEnabled + ? "No files in the box yet. Drop files here, use File > Add files, or click the dropzone." + : "Guest uploads are disabled."; + el.fileList.append(empty); + } else { + const fragment = document.createDocumentFragment(); + files.forEach((item, index) => fragment.append(createFileRow(item, index))); + el.fileList.append(fragment); } - fileList.replaceChildren(); + updateQueueSummary(); + updateQuota(); + updateOverallProgress(); + updateTerminal(); + updateDisabledReasons(); + updateCurrentStep(); +} - if (!selectedFiles.length) { - const emptyState = document.createElement("p"); - emptyState.className = "upload-empty-state"; - emptyState.textContent = "No files selected"; - fileList.append(emptyState); - updateStatus("Ready"); - setBoxStatus("WarpBox"); - setBoxLink(""); - setOverallProgress(0); - return; - } - - const fragment = document.createDocumentFragment(); - selectedFiles.forEach((selectedFile) => { - fragment.append(createFileRow(selectedFile)); +function duplicateFileReport(incoming = []) { + const used = new Set(files.map((item) => normalizedFileName(item.displayName))); + const duplicates = []; + const unique = []; + incoming.forEach((item) => { + const key = normalizedFileName(item.displayName); + if (used.has(key)) { + duplicates.push(item); + return; + } + used.add(key); + unique.push(item); }); + return { unique, duplicates }; +} - fileList.append(fragment); - updateStatus("Files selected"); - setBoxStatus("WarpBox"); - setBoxLink(""); - setOverallProgress(0); +function addFiles(fileList) { + if (!uploadsEnabled) { + showToast("Guest uploads are disabled.", "warning"); + return; + } + if (uploadLocked) { + showToast("This box is sealed. Clear it to create a fresh upload.", "warning"); + return; + } + const incoming = Array.from(fileList || []).map((file) => makeQueuedFile(file)); + if (!incoming.length) return; + + const { unique, duplicates } = duplicateFileReport(incoming); + if (unique.length) { + files.push(...unique); + setShareUrl(""); + renderFiles(); + const warning = quotaWarningMessage(); + if (warning) showWarningDialog("Quota warning", warning); + } + if (duplicates.length) showDuplicateDialog(duplicates); + + if (unique.length) setStatus(`${unique.length} file${unique.length === 1 ? "" : "s"} added to queue`); + if (duplicates.length && !unique.length) setStatus(`${duplicates.length} duplicate file${duplicates.length === 1 ? "" : "s"} need your choice`); +} + +function showDuplicateDialog(duplicates) { + pendingDuplicateFiles = duplicates; + const list = duplicates.map((item) => `
  • ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)}
  • `).join(""); + openPopup("Duplicate file names", ` +

    Duplicate file names detected

    +

    These files have the same names as files already in the queue.

    +
      ${list}
    +

    Skip them, or append numbers so they become names like file (2).zip.

    +
    + + +
    `); + showToast("Duplicate names found. Choose skip or append numbers.", "warning"); + setTimeout(() => document.querySelector("#duplicate-append")?.focus(), 0); +} + +function appendPendingDuplicates() { + if (!pendingDuplicateFiles.length) return; + const used = new Set(files.map((item) => normalizedFileName(item.displayName))); + pendingDuplicateFiles.forEach((item) => { + item.displayName = nextIncrementedFileName(item.displayName, used); + files.push(item); + }); + const count = pendingDuplicateFiles.length; + pendingDuplicateFiles = []; + closeDoc(); + setShareUrl(""); + renderFiles(); + showToast("Duplicate files added with numbered names.", "info"); + setStatus(`${count} duplicate file${count === 1 ? "" : "s"} added with numbered names`); +} + +function removeFile(index) { + if (uploadLocked) { + showToast("Box already created. Clear it before editing the queue.", "warning"); + return; + } + const [removed] = files.splice(index, 1); + if (removed?.previewURL) URL.revokeObjectURL(removed.previewURL); + setShareUrl(""); + renderFiles(); + setStatus("File removed from queue"); +} + +function clearQueue() { + files.forEach((item) => { + if (item.previewURL) URL.revokeObjectURL(item.previewURL); + }); + files = []; + pendingDuplicateFiles = []; + uploadLocked = false; + stopStatusAnimation(); + setBoxOptionsLocked(false); + setShareUrl(""); + if (el.fileInput) { + el.fileInput.value = ""; + el.fileInput.disabled = !uploadsEnabled; + } + el.dropzone?.classList.remove("is-locked"); + renderFiles(); + setStatus(uploadsEnabled ? "Queue cleared" : "Guest uploads are disabled"); + showToast("Queue cleared."); +} + +function confirmClearQueue() { + if (!files.length && !shareUrl) { + showToast("Nothing to clear."); + return; + } + openPopup("Clear WarpBox?", ` +

    Confirm clear

    +

    This removes the current queue, resets progress, and unlocks the Start upload button.

    +
    + + +
    `); + setTimeout(() => document.querySelector("#confirm-clear-no")?.focus(), 0); } async function createBox() { const response = await fetch("/box", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - retention_key: retentionSelect ? retentionSelect.value : "10s", - password: passwordEnabled && passwordEnabled.checked && passwordInput ? passwordInput.value : "", - allow_zip: isOneTimeDownloadSelected() || !zipEnabled || zipEnabled.checked, - files: selectedFiles.map((selectedFile) => ({ - name: selectedFile.file.name, - size: selectedFile.file.size, - })), + retention_key: el.expiry?.value || defaultRetention, + password: el.password?.value || "", + allow_zip: isOneTimeDownloadSelected() || !el.allowZip || el.allowZip.checked, + files: files.map((item) => ({ name: item.displayName, size: item.file.size })), }), }); - if (!response.ok) { - throw new Error("Could not create upload box"); - } - return response.json(); + const result = await readJSON(response); + if (!response.ok) throw new Error(result.error || "Could not create upload box"); + return result; } -async function markFileStatus(selectedFile, status) { - if (!selectedFile.boxFile) { - return; +async function readJSON(response) { + try { + return await response.json(); + } catch (_) { + 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) { +async function markFileStatus(item, status) { + if (!item.boxID || !item.boxFile) return; + try { + await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + } catch (_) { + // Best effort only. The upload endpoint also marks hard failures. + } +} + +function setFileFailed(item, message) { + item.failed = true; + item.uploaded = false; + item.error = message || "Failed to upload"; + item.loaded = item.file.size; + item.row?.classList.remove("is-working", "is-uploaded"); + item.row?.classList.add("is-failed"); + if (item.row) item.row.title = item.error; + setRowProgress(item, 100); + updateOverallProgress(); +} + +function uploadFile(item, onComplete) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); - formData.append("file", selectedFile.file); + formData.append("file", item.file, item.displayName); - xhr.open("POST", selectedFile.boxFile.upload_path); + xhr.open("POST", item.boxFile.upload_path); xhr.upload.addEventListener("loadstart", () => { - selectedFile.loaded = 0; - selectedFile.row.classList.add("is-uploading"); - selectedFile.row.title = "Loading"; + item.loaded = 0; + item.failed = false; + item.uploaded = false; + item.row?.classList.remove("is-failed", "is-uploaded"); + item.row?.classList.add("is-working"); + setRowProgress(item, 2); updateOverallProgress(); - setRowProgress(selectedFile.row, 2); }); xhr.upload.addEventListener("progress", (event) => { - if (!event.lengthComputable) { - return; - } - - selectedFile.loaded = Math.min(event.loaded, selectedFile.file.size); - updateOverallProgress(); + if (!event.lengthComputable) return; + item.loaded = Math.min(event.loaded, item.file.size); 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); + setRowProgress(item, percent >= 100 ? 99 : percent); + updateOverallProgress(); }); - xhr.addEventListener("load", () => { + xhr.addEventListener("load", async () => { 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")); + let message = "Upload failed"; + try { + message = JSON.parse(xhr.responseText).error || message; + } catch (_) {} + setFileFailed(item, message); + await markFileStatus(item, "failed"); + reject(new Error(message)); 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"; + item.uploaded = true; + item.failed = false; + item.loaded = item.file.size; + item.row?.classList.remove("is-working", "is-failed"); + item.row?.classList.add("is-uploaded"); + if (item.row) item.row.title = "Uploaded"; + setRowProgress(item, 100); + + try { + const result = JSON.parse(xhr.responseText); + if (result.file) { + item.boxFile = result.file; + const icon = item.row?.querySelector(".upload-file-icon"); + if (icon && result.file.thumbnail_path) { + item.row.classList.add("has-thumbnail"); + icon.src = result.file.thumbnail_path; + } else if (icon && result.file.icon_path && !item.previewURL) { + icon.src = result.file.icon_path; + } + } + } catch (_) {} + updateOverallProgress(); - setRowProgress(selectedFile.row, 100); onComplete(); resolve(); }); - 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("error", async () => { + setFileFailed(item, "Network error while uploading"); + await markFileStatus(item, "failed"); + reject(new Error("Network error while uploading")); }); - 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"); + xhr.addEventListener("abort", async () => { + setFileFailed(item, "Upload cancelled"); + await markFileStatus(item, "failed"); reject(new Error("Upload cancelled")); }); - markFileStatus(selectedFile, "uploading"); + markFileStatus(item, "uploading"); xhr.send(formData); }); } -if (fileInput) { - fileInput.addEventListener("change", () => { - stopStatusAnimation(); - updateSelectedFiles(fileInput.files); +async function startUpload() { + if (!uploadsEnabled) { + showToast("Guest uploads are disabled.", "warning"); + return; + } + if (uploadLocked) { + showToast("Upload already started. Press Clear to create another box.", "warning"); + return; + } + if (!files.length) { + showWarningDialog("No files selected", "There are no files selected. Please select files to upload."); + showToast("No files selected. Please select files to upload.", "warning"); + setStatus("No files selected"); + return; + } + if (hasQuotaError()) { + showWarningDialog("Over maximum upload size", quotaWarningMessage() || "Over maximum upload size."); + showToast("Over maximum upload size.", "error"); + return; + } + + uploadLocked = true; + setBoxOptionsLocked(true); + if (el.fileInput) el.fileInput.disabled = true; + el.dropzone?.classList.add("is-locked"); + setShareUrl(""); + files.forEach((item) => { + item.loaded = 0; + item.uploaded = false; + item.failed = false; + item.error = ""; }); -} + renderFiles(); -if (passwordEnabled && passwordInput) { - passwordEnabled.addEventListener("change", () => { - passwordInput.disabled = !passwordEnabled.checked; - if (!passwordEnabled.checked) { - passwordInput.value = ""; - return; - } + let completedCount = 0; + const totalCount = files.length; + const statusPrefix = () => `${completedCount}/${totalCount}`; + setStatus(`${statusPrefix()} Uploading.`); + animateUploadStatus(statusPrefix); - passwordInput.focus(); - }); -} - -if (retentionSelect) { - updateZipOptionForRetention(); - retentionSelect.addEventListener("change", updateZipOptionForRetention); -} - -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; - stopStatusAnimation(); - updateSelectedFiles(fileInput.files); - }); -} - -if (uploadForm) { - uploadForm.addEventListener("submit", async (event) => { - event.preventDefault(); - - if (!selectedFiles.length) { - updateStatus("Choose files first"); - return; - } - - if (passwordEnabled && passwordEnabled.checked && passwordInput && !passwordInput.value.trim()) { - updateStatus("Enter password"); - passwordInput.focus(); - return; - } - - let completedCount = 0; - const totalCount = selectedFiles.length; - const statusPrefix = () => `${completedCount}/${totalCount}`; - - selectedFiles.forEach((selectedFile) => { - selectedFile.uploaded = false; - selectedFile.failed = false; - selectedFile.loaded = 0; - selectedFile.row.classList.remove("is-uploaded", "is-failed", "is-uploading", "is-processing"); - selectedFile.row.title = ""; - setRowProgress(selectedFile.row, 0); + try { + const box = await createBox(); + setShareUrl(box.box_url); + files.forEach((item, index) => { + item.boxID = box.box_id; + item.boxFile = box.files[index]; + item.displayName = item.boxFile?.name || item.displayName; + const icon = item.row?.querySelector(".upload-file-icon"); + if (icon && item.boxFile?.thumbnail_path) { + item.row.classList.add("has-thumbnail"); + icon.src = item.boxFile.thumbnail_path; + } else if (icon && item.boxFile?.icon_path && !item.previewURL) { + icon.src = item.boxFile.icon_path; + } }); - setBoxLink(""); - setOverallProgress(0); - updateStatus(`${statusPrefix()} Uploading.`); - animateUploadStatus(statusPrefix); + const results = await Promise.allSettled(files.map((item) => uploadFile(item, () => { completedCount += 1; }))); + stopStatusAnimation(); - 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]; - const icon = selectedFile.row.querySelector(".upload-file-icon"); - 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; - } - }); - - 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; - } - - setOverallProgress(100); - updateStatus(`${completedCount}/${totalCount} Uploaded`); - } catch (error) { - stopStatusAnimation(); - setBoxLink(""); - updateStatus("Upload failed"); - } - }); -} - -if (shareButton) { - shareButton.addEventListener("click", async () => { - if (!shareURL) { + const failedCount = results.filter((result) => result.status === "rejected").length; + if (failedCount > 0) { + setStatus(`${completedCount}/${totalCount} uploaded, ${failedCount} failed`); + showToast(`${failedCount} file${failedCount === 1 ? "" : "s"} failed. The share URL contains the successful files.`, "error"); + renderFiles(); return; } - try { - if (navigator.share) { - await navigator.share({ - title: "WarpBox download", - text: "Download these files from WarpBox", - url: shareURL, - }); - return; - } - - await navigator.clipboard.writeText(shareURL); - updateStatus("Link copied"); - } catch (error) { - updateStatus("Share cancelled"); - } - }); + setOverallProgress(100); + setStatus(`${completedCount}/${totalCount} uploaded. Share URL created. Press Clear to start another upload.`); + showToast("Upload complete. Share URL created."); + renderFiles(); + } catch (error) { + stopStatusAnimation(); + uploadLocked = false; + setBoxOptionsLocked(false); + if (el.fileInput) el.fileInput.disabled = !uploadsEnabled; + el.dropzone?.classList.remove("is-locked"); + setShareUrl(""); + setStatus(error.message || "Upload failed"); + showToast(error.message || "Upload failed", "error"); + renderFiles(); + } } -window.addEventListener("beforeunload", revokePreviewURLs); +function isOneTimeDownloadSelected() { + return el.expiry?.value === oneTimeRetentionKey; +} + +function syncZipForRetention() { + if (!el.allowZip) return; + if (isOneTimeDownloadSelected()) { + el.allowZip.checked = true; + el.allowZip.disabled = true; + } else if (!uploadLocked) { + el.allowZip.disabled = false; + } +} + +function setBoxOptionsLocked(locked) { + const controls = [el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean); + el.optionsForm?.classList.toggle("is-locked", locked); + controls.forEach((control) => { + control.dataset.disabledReason = locked ? "Box Options are locked because this box was already created. Press Clear to start another upload." : ""; + if (control.tagName === "INPUT" && !["checkbox", "radio", "file"].includes(control.type)) { + control.readOnly = locked; + } else { + control.disabled = locked; + } + }); + if (el.password) el.password.type = locked ? "password" : "text"; + if (!locked) { + syncZipForRetention(); + syncApiKeyField(); + } + updateDisabledReasons(); +} + +function updateDisabledReasons() { + if (el.startButton) { + let reason = ""; + if (!uploadsEnabled) reason = "Guest uploads are disabled."; + else if (uploadLocked) reason = "This upload already started. Press Clear to create another box."; + else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files."; + else if (!files.length) reason = "There are no files selected. Please select files to upload."; + el.startButton.disabled = !uploadsEnabled || uploadLocked || hasQuotaError(); + el.startButton.dataset.disabledReason = reason; + el.startButton.title = reason; + } +} + +function saveSettings() { + const settings = { + expiry: el.expiry?.value || defaultRetention, + password: el.password?.value || "", + maxViews: el.maxViews?.value || "", + boxName: el.boxName?.value || "", + customSlug: el.customSlug?.value || "", + downloadPage: Boolean(el.downloadPage?.checked), + allowZip: Boolean(el.allowZip?.checked), + allowPreview: Boolean(el.allowPreview?.checked), + keepFilenames: Boolean(el.keepFilenames?.checked), + privateBox: Boolean(el.privateBox?.checked), + apiKeyMode: Boolean(el.apiKeyMode?.checked), + apiKey: el.apiKeyInput?.value || "", + }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +function loadSettings() { + let settings = {}; + try { + settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"); + } catch (_) {} + if (settings.expiry && Array.from(el.expiry?.options || []).some((option) => option.value === settings.expiry)) el.expiry.value = settings.expiry; + if (el.password) el.password.value = settings.password || ""; + if (el.maxViews) el.maxViews.value = settings.maxViews || ""; + if (el.boxName) el.boxName.value = settings.boxName || ""; + if (el.customSlug) el.customSlug.value = settings.customSlug || ""; + if (el.downloadPage) el.downloadPage.checked = settings.downloadPage !== false; + if (el.allowZip) el.allowZip.checked = settings.allowZip !== false; + if (el.allowPreview) el.allowPreview.checked = settings.allowPreview !== false; + if (el.keepFilenames) el.keepFilenames.checked = settings.keepFilenames !== false; + if (el.privateBox) el.privateBox.checked = Boolean(settings.privateBox); + if (el.apiKeyMode) el.apiKeyMode.checked = Boolean(settings.apiKeyMode); + if (el.apiKeyInput) el.apiKeyInput.value = settings.apiKey || ""; + syncZipForRetention(); + syncApiKeyField(); +} + +function syncMenuChecks() { + document.querySelectorAll("[data-expiry-check]").forEach((node) => { + node.textContent = node.dataset.expiryCheck === el.expiry?.value ? "✓" : ""; + }); + const downloadCheck = document.querySelector("[data-download-page-check]"); + if (downloadCheck) downloadCheck.textContent = el.downloadPage?.checked ? "✓" : ""; +} + +function syncApiKeyField() { + const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked; + el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked)); + if (el.apiKeyInput) { + el.apiKeyInput.disabled = !enabled; + el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key."; + } + validateApiKeyField(); +} + +function validateApiKeyField() { + if (!el.apiKeyInput || !el.apiKeyState) return; + clearTimeout(apiKeyTimer); + const wrapper = el.apiKeyInput.closest(".api-key-field"); + wrapper?.classList.remove("is-checking"); + + if (!el.apiKeyMode?.checked) { + el.apiKeyState.textContent = ""; + return; + } + const value = el.apiKeyInput.value.trim(); + if (!value) { + el.apiKeyState.textContent = "waiting"; + return; + } + + el.apiKeyInput.disabled = true; + wrapper?.classList.add("is-checking"); + el.apiKeyState.textContent = "checking"; + apiKeyTimer = setTimeout(() => { + wrapper?.classList.remove("is-checking"); + el.apiKeyInput.disabled = uploadLocked; + el.apiKeyState.textContent = value.length >= 12 ? "saved locally" : "too short"; + if (value.length < 12) showToast("API key looks too short. It was saved locally, but not sent during browser uploads.", "warning"); + }, 650); +} + +function slugify(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 32); +} + +function syncSlugFromName(force = false) { + if (!el.customSlug || !el.boxName) return; + if (force || !el.customSlug.value || el.customSlug.dataset.auto === "true") { + el.customSlug.value = slugify(el.boxName.value); + el.customSlug.dataset.auto = "true"; + } + saveSettings(); + updateTerminal(); +} + +function randomPassword() { + if (!el.password || uploadLocked) return; + el.password.value = `${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`; + saveSettings(); + updateTerminal(); + setStatus("Generated a password"); +} + +function randomBoxName() { + if (!el.boxName || uploadLocked) return; + const adjectives = ["neon", "turbo", "quiet", "cosmic", "lucky", "midnight", "pixel", "rapid"]; + const nouns = ["floppy", "archive", "packet", "portal", "folder", "upload", "cache", "drive"]; + el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}`; + syncSlugFromName(true); + setStatus("Generated a local box name"); +} + +function getCurlCommand({ full = true } = {}) { + const args = []; + const selectedFiles = files.length ? files : [{ displayName: "build.zip" }]; + const previewLimit = full ? selectedFiles.length : 4; + selectedFiles.slice(0, previewLimit).forEach((item) => args.push(` -F ${shellQuote(`files=@${item.displayName}`)}`)); + const hiddenFileCount = !full && selectedFiles.length > previewLimit ? selectedFiles.length - previewLimit : 0; + args.push(` -F ${shellQuote(`retention=${el.expiry?.value || defaultRetention}`)}`); + if (el.password?.value) args.push(` -F ${shellQuote("password=YOUR_PASSWORD")}`); + if (el.allowZip && !el.allowZip.checked) args.push(` -F ${shellQuote("allow_zip=false")}`); + + const commandLines = ["curl"]; + if (el.apiKeyMode?.checked) commandLines.push(` -H ${shellQuote("Authorization: Bearer YOUR_API_KEY")}`); + commandLines.push(...args, ` ${window.location.origin}/upload`); + const command = commandLines.join(" \\\n"); + return hiddenFileCount ? `${command}\n# and ${hiddenFileCount} other files included when copying` : command; +} + +function updateTerminal() { + if (!el.terminal) return; + const command = getCurlCommand({ full: false }); + el.terminal.innerHTML = `warpbox@cli:~$ ${htmlEscape(command)}`; +} + +async function copyText(kind, value, openUrl = "") { + if (!value) { + showToast(`No ${kind.toLowerCase()} yet.`, "warning"); + return; + } + try { + await navigator.clipboard.writeText(value); + showToast(`${kind} copied to clipboard.`); + setStatus(`Copied ${kind.toLowerCase()}`); + } catch (_) { + showCopyFallback(kind, value, openUrl); + } +} + +function showCopyFallback(kind, value, openUrl) { + openPopup(`${kind} copy failed`, ` +

    Clipboard access failed

    +

    The browser refused clipboard access. Copy it manually from the field below.

    + +
    + ${openUrl ? `Open` : ""} + +
    `); +} + +function quotaWarningHtml(message) { + const tooLarge = oversizedFiles(); + const parts = []; + if (tooLarge.length) { + parts.push("

    Single-file limit exceeded. Remove these files before uploading.

    "); + parts.push(`
      ${tooLarge.map((item) => `
    1. ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)} / max ${formatBytes(maxFileBytes)}
    2. `).join("")}
    `); + } + if (isOverBoxQuota()) { + parts.push(`

    Box quota exceeded. Current total is ${formatBytes(totalBytes())}. The limit is ${formatBytes(maxBoxBytes)}. Remove ${formatBytes(totalBytes() - maxBoxBytes)} or more.

    `); + } + if (!parts.length) parts.push(`

    ${htmlEscape(message)}

    `); + return parts.join(""); +} + +function showWarningDialog(title, message) { + openPopup(title, ` +

    ${htmlEscape(title)}

    + ${quotaWarningHtml(message)} +
    `); +} + +function openPopup(title, html, about = false) { + if (!el.docPopup || !el.docPopupTitle || !el.docPopupBody) return; + el.docPopupTitle.textContent = title; + el.docPopupBody.innerHTML = html; + el.docPopup.classList.toggle("is-about-popup", about); + el.docPopup.classList.add("is-visible"); + el.modalBackdrop?.classList.add("is-visible"); +} + +function closeDoc() { + el.docPopup?.classList.remove("is-visible", "is-about-popup"); + el.modalBackdrop?.classList.remove("is-visible"); +} + +const docs = { + cli: { + title: "CLI Guide", + html: ` +

    Upload with cURL

    +

    WarpBox accepts normal multipart form uploads through the compatibility endpoint:

    +
    curl \\
    +  -F 'files=@./my-file.zip' \\
    +  -F 'retention=1h' \\
    +  ${window.location.origin}/upload
    +

    Browser flow

    +

    The browser uses the manifest API: it creates a box, uploads each file, and marks failed uploads so the download page does not wait forever.

    + `, + }, + faq: { + title: "Help & FAQ", + html: ` +

    Help & FAQ

    +
    +

    Keyboard shortcuts

    + +
    +
    +

    Can I password protect uploads?

    Yes. Set a password in Box Options before starting the upload.

    +

    What happens if one file fails?

    The failed row stays red, successful files remain available, and WarpBox marks the failed file in the manifest.

    +

    Are all options server-backed?

    Expiry, password, ZIP download, and one-time download are sent to the backend. Notes like box name, custom slug, and API key mode are saved locally until backend support exists.

    +
    + `, + }, + dailyQuota: { + title: "Upload limits", + html: ` +

    Upload limits

    +
    +
    +
    Box size${maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit"}
    +
    +
    +
    +
    Single file${maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit"}
    +
    +
    +
    +

    These values come from the running WarpBox configuration.

    + `, + }, + about: { + title: "About WarpBox", + about: true, + html: ` +

    WarpBox

    +

    WarpBox was made by Daniel Legt.

    +

    Temporary file boxes, terminal-friendly uploads, and old-web UI charm.

    + `, + }, + examples: { + title: "Examples", + html: ` +

    Upload examples

    +

    Basic CLI upload

    +
    curl \\
    +  -F 'files=@./photo.png' \\
    +  -F 'retention=24h' \\
    +  ${window.location.origin}/upload
    +

    Multiple files with password

    +
    curl \\
    +  -F 'files=@./one.png' \\
    +  -F 'files=@./two.zip' \\
    +  -F 'retention=1h' \\
    +  -F 'password=secret-pass' \\
    +  ${window.location.origin}/upload
    + `, + }, +}; + +function openDoc(name) { + const doc = docs[name]; + if (!doc) return; + openPopup(doc.title, doc.html, doc.about); + setStatus(`${doc.title} opened`); +} + +document.addEventListener("click", (event) => { + const menuButton = event.target.closest(".menu-button"); + if (menuButton) { + const item = menuButton.closest(".menu-item"); + const isOpen = item.classList.contains("is-open"); + document.querySelectorAll(".menu-item.is-open").forEach((node) => { + node.classList.remove("is-open"); + node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); + }); + item.classList.toggle("is-open", !isOpen); + menuButton.setAttribute("aria-expanded", String(!isOpen)); + return; + } + + const action = event.target.closest("[data-action]")?.dataset.action; + if (action) { + document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open")); + if (action === "browse") el.fileInput?.click(); + if (action === "start-upload") startUpload(); + if (action === "copy-link") copyText("Share URL", shareUrl, shareUrl); + if (action === "clear") confirmClearQueue(); + if (action === "toggle-delete-once" && el.expiry?.querySelector(`option[value="${oneTimeRetentionKey}"]`)) { + el.expiry.value = isOneTimeDownloadSelected() ? defaultRetention : oneTimeRetentionKey; + syncZipForRetention(); + saveSettings(); + syncMenuChecks(); + updateTerminal(); + } + if (action === "random-password") randomPassword(); + if (action === "random-box-name") randomBoxName(); + if (action === "clear-password" && el.password && !uploadLocked) { + el.password.value = ""; + saveSettings(); + updateTerminal(); + } + if (action === "toggle-page" && el.downloadPage && !uploadLocked) { + el.downloadPage.checked = !el.downloadPage.checked; + saveSettings(); + syncMenuChecks(); + } + if (action === "help" || action === "side-help") openDoc("faq"); + if (action === "terminal-help") el.terminal?.focus(); + if (action === "coming-soon") showToast("That shortcut is decorative for now."); + if (action === "side-close" || action === "side-folder-close" || action === "fake-close" || action === "minimize" || action === "toggle-fit") showToast("Window controls are decorative on this page."); + return; + } + + const expiry = event.target.closest("[data-expiry]")?.dataset.expiry; + if (expiry && el.expiry) { + el.expiry.value = expiry; + syncZipForRetention(); + saveSettings(); + syncMenuChecks(); + updateTerminal(); + setStatus(`Expiry set to ${event.target.textContent.trim()}`); + return; + } + + const doc = event.target.closest("[data-doc]")?.dataset.doc; + if (doc) { + openDoc(doc); + return; + } + + const remove = event.target.closest("[data-remove]"); + if (remove) { + removeFile(Number(remove.dataset.remove)); + return; + } + + if (event.target.id === "duplicate-append") appendPendingDuplicates(); + if (event.target.id === "duplicate-skip") { + pendingDuplicateFiles = []; + closeDoc(); + showToast("Duplicate files skipped."); + } + if (event.target.id === "confirm-clear-yes") { + closeDoc(); + clearQueue(); + } + if (event.target.id === "confirm-clear-no" || event.target.id === "fallback-close") closeDoc(); + + if (!event.target.closest(".menu-item")) { + document.querySelectorAll(".menu-item.is-open").forEach((node) => { + node.classList.remove("is-open"); + node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); + }); + } +}); + +el.fileInput?.addEventListener("change", () => addFiles(el.fileInput.files)); + +[el.dropSurface, el.dropzone].filter(Boolean).forEach((target) => { + target.addEventListener("dragover", (event) => { + event.preventDefault(); + el.dropzone?.classList.add("is-dragging"); + }); + target.addEventListener("dragleave", () => el.dropzone?.classList.remove("is-dragging")); + target.addEventListener("drop", (event) => { + event.preventDefault(); + el.dropzone?.classList.remove("is-dragging"); + addFiles(event.dataTransfer.files); + }); +}); + +el.dropzone?.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + el.fileInput?.click(); + } +}); + +el.form?.addEventListener("submit", (event) => { + event.preventDefault(); + startUpload(); +}); + +el.copyButton?.addEventListener("click", () => copyText("Share URL", shareUrl, shareUrl)); +el.copyCurlButton?.addEventListener("click", () => copyText("cURL command", getCurlCommand({ full: true }))); +el.docPopupClose?.addEventListener("click", closeDoc); +el.modalBackdrop?.addEventListener("click", closeDoc); + +[el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean).forEach((control) => { + control.addEventListener("input", () => { + if (control === el.boxName) syncSlugFromName(); + if (control === el.customSlug) el.customSlug.dataset.auto = "false"; + if (control === el.apiKeyInput) validateApiKeyField(); + saveSettings(); + updateTerminal(); + }); + control.addEventListener("change", () => { + if (control === el.expiry) syncZipForRetention(); + if (control === el.apiKeyMode) syncApiKeyField(); + saveSettings(); + syncMenuChecks(); + updateTerminal(); + }); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeDoc(); + document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open")); + } + if (event.key === "F1") { + event.preventDefault(); + openDoc("faq"); + } + if (event.ctrlKey && !event.shiftKey && !event.altKey) { + const key = event.key.toLowerCase(); + if (key === "o") { + event.preventDefault(); + el.fileInput?.click(); + } + if (key === "u") { + event.preventDefault(); + startUpload(); + } + if (key === "k") { + event.preventDefault(); + copyText("cURL command", getCurlCommand({ full: true })); + } + if (key === "l") { + event.preventDefault(); + copyText("Share URL", shareUrl, shareUrl); + } + } +}); + +window.addEventListener("beforeunload", () => { + files.forEach((item) => { + if (item.previewURL) URL.revokeObjectURL(item.previewURL); + }); +}); + +loadSettings(); +updateLimitHint(); +syncMenuChecks(); +renderFiles(); +updateTerminal(); diff --git a/templates/index.html b/templates/index.html index 0e2ab03..8edfdbc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,107 +3,244 @@ - Warpbox + WarpBox - -
    -
    -
    -
    - -

    WarpBox Upload

    -
    - -
    - -
    -
    + + +
    + + +
    +