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) => `
These files have the same names as files already in the queue.
+Skip them, or append numbers so they become names like file (2).zip.
This removes the current queue, resets progress, and unlocks the Start upload button.
+The browser refused clipboard access. Copy it manually from the field below.
+ +Single-file limit exceeded. Remove these files before uploading.
"); + 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, ` +WarpBox accepts normal multipart form uploads through the compatibility endpoint:
+curl \\
+ -F 'files=@./my-file.zip' \\
+ -F 'retention=1h' \\
+ ${window.location.origin}/upload
+ 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: ` +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.
These values come from the running WarpBox configuration.
+ `, + }, + about: { + title: "About WarpBox", + about: true, + html: ` +WarpBox was made by Daniel Legt.
+Temporary file boxes, terminal-friendly uploads, and old-web UI charm.
+ `, + }, + examples: { + title: "Examples", + html: ` +curl \\
+ -F 'files=@./photo.png' \\
+ -F 'retention=24h' \\
+ ${window.location.origin}/upload
+ 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 @@
-