diff --git a/lib/server/handlers.go b/lib/server/handlers.go index 3ebe002..651ab69 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -21,6 +21,13 @@ const boxAuthCookiePrefix = "warpbox_box_" var oneTimeDownloadLocks sync.Map +func formatBrowserTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + func (app *App) handleIndex(ctx *gin.Context) { ctx.HTML(http.StatusOK, "index.html", gin.H{ "RetentionOptions": app.retentionOptions(), @@ -63,6 +70,7 @@ func (app *App) handleShowBox(ctx *gin.Context) { "PollMS": app.config.BoxPollIntervalMS, "RetentionLabel": manifest.RetentionLabel, "ExpiresAt": manifest.ExpiresAt, + "ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt), }) } @@ -140,7 +148,8 @@ func (app *App) handleBoxStatus(ctx *gin.Context) { return } - if _, _, ok := app.authorizeBoxRequest(ctx, boxID, false); !ok { + manifest, _, ok := app.authorizeBoxRequest(ctx, boxID, false) + if !ok { return } @@ -150,7 +159,7 @@ func (app *App) handleBoxStatus(ctx *gin.Context) { return } - ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "files": files}) + ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files}) } func (app *App) handleDownloadBox(ctx *gin.Context) { diff --git a/static/css/app.css b/static/css/app.css index 04d04e4..f444481 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -20,14 +20,14 @@ } :root { - font-family: 'PixelOperator', 'MS Sans Serif', Arial, sans-serif; + font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; font-smooth: never; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; text-rendering: geometricPrecision; image-rendering: pixelated; - --base-font-size: 14px; + --base-font-size: 13px; --ui-scale: 1; --w98-blue: #000078; --w98-blue-gradient: linear-gradient(90deg, #000078 0%, #000078 28%, #0f80cd 50%, #000078 72%, #000078 100%); @@ -64,7 +64,7 @@ body { background-image: url('/static/img/bg/stars1.gif'); background-repeat: repeat; background-size: auto; - font-family: 'PixelOperator', 'MS Sans Serif', Arial, sans-serif; + font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; } main { @@ -152,19 +152,22 @@ textarea { } .win98-button:disabled, +.win98-button[aria-disabled="true"], button:disabled, +button[aria-disabled="true"], input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; } -.win98-button:disabled { +.win98-button:disabled, +.win98-button[aria-disabled="true"] { color: #808080; text-shadow: 1px 1px 0 #ffffff; } -.win98-button:active:not(:disabled), +.win98-button:active:not(:disabled):not([aria-disabled="true"]), .win98-control:active, .menu-button[aria-expanded="true"] { border-top-color: #000000; @@ -175,20 +178,155 @@ textarea:disabled { padding-top: 1px; } +.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; + zoom: var(--ui-scale); +} + +.popup-window.is-visible { + display: flex; + animation: popup-open-v10 180ms steps(5, end); +} + +.popup-body { + flex: 1 1 auto; + min-height: 0; + max-height: calc(100vh - 90px); + margin: 0 6px 6px; + padding: 12px; + overflow: auto; + 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: 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 .code-block { + margin: 6px 0 10px; + padding: 8px 8px 22px; + width: 100%; + display: block; + overflow: auto; + color: #00ff66; + background: #000000; + border: 0; + font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; + font-size: 12px; + line-height: 15px; + white-space: pre; + user-select: text; + cursor: text; + box-sizing: border-box; +} + +.popup-body .code-block::after { + content: "\A"; + white-space: pre; +} + +.copy-fallback-text { + width: 100%; + min-height: 58px; + font-family: 'MonoCraft', 'PixelOperatorMono', monospace; +} + +.popup-window.is-properties-popup { + width: min(520px, calc(100vw - 24px)); +} + +.popup-window.is-preview-popup { + width: min(760px, calc(100vw - 24px)); +} + +.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: 90; + font-size: 12px; + line-height: 14px; + box-shadow: 4px 4px 0 rgba(0,0,0,.45); + zoom: var(--ui-scale); +} + +.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; +} + +@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; } } + @media (min-width: 1800px) { - :root { --base-font-size: 15px; --ui-scale: 1.2; } + :root { --base-font-size: 14px; --ui-scale: 1.2; } } @media (min-width: 2048px) { - :root { --base-font-size: 16px; --ui-scale: 1.36; } + :root { --base-font-size: 15px; --ui-scale: 1.36; } } @media (min-width: 2560px) { - :root { --base-font-size: 18px; --ui-scale: 1.58; } + :root { --base-font-size: 16px; --ui-scale: 1.58; } } @media (min-width: 3200px) { - :root { --base-font-size: 20px; --ui-scale: 1.88; } + :root { --base-font-size: 18px; --ui-scale: 1.88; } } @media (prefers-reduced-motion: reduce) { diff --git a/static/css/box.css b/static/css/box.css index 9f97305..516719a 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -1,52 +1,48 @@ .box-window { - width: min(760px, calc(100vw - 36px)); + width: min(860px, calc(100vw - 36px)); height: min(560px, calc(100vh - 36px)); zoom: var(--ui-scale); } -.box-toolbar { - display: flex; +body.fit-window .box-window { + width: min(980px, calc(100vw / var(--ui-scale) - 20px)); + height: min(720px, calc(100vh / var(--ui-scale) - 20px)); +} + +.box-command-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; gap: 8px; - height: 40px; + min-height: 40px; padding: 6px 8px; } .box-toolbar-button { - width: 116px; + width: auto; + min-width: 158px; + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.box-toolbar-button img { + width: 16px; + height: 16px; + image-rendering: pixelated; } .box-address { - display: grid; - grid-template-columns: 58px minmax(0, 1fr); - align-items: center; - height: 28px; - padding: 0 8px 6px; - gap: 6px; - font-size: 13px; - line-height: 13px; -} - -.box-meta { - display: grid; - grid-template-columns: 58px minmax(0, 1fr); - align-items: center; - height: 24px; - padding: 0 8px 6px; - gap: 6px; - color: #333333; - font-size: 12px; - line-height: 12px; -} - -.box-address code { + grid-column: 1; min-width: 0; - height: 22px; + width: 100%; + height: 24px; display: flex; align-items: center; padding: 0 6px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; color: #000000; background: #ffffff; border-top: 1px solid #808080; @@ -54,6 +50,33 @@ border-right: 1px solid #dfdfdf; border-bottom: 1px solid #dfdfdf; font-family: inherit; + font-size: 13px; + line-height: 13px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.win98-window.popup-window { + display: none; +} + +.win98-window.popup-window.is-visible { + display: flex; +} + +.box-meta { + min-height: 24px; + padding: 0 8px 6px; + color: #333333; + font-size: 12px; + line-height: 12px; +} + +.box-meta span { + display: flex; + align-items: center; + min-height: 18px; } .box-panel { @@ -169,6 +192,98 @@ color: #ffffff; } +.box-context-menu { + position: fixed; + min-width: 168px; + display: none; + padding: 2px; + 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: 95; +} + +.box-context-menu.is-visible { + display: block; +} + +.box-context-menu button { + width: 100%; + min-height: 24px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 8px; + align-items: center; + padding: 2px 7px; + color: #000000; + background: transparent; + border: 0; + font-family: inherit; + font-size: 12px; + line-height: 13px; + text-align: left; +} + +.box-context-menu button:hover, +.box-context-menu button:focus-visible { + color: #ffffff; + background: #000078; + outline: none; +} + +.box-context-menu img { + width: 16px; + height: 16px; + object-fit: contain; + image-rendering: pixelated; +} + +.properties-grid { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 7px 10px; + padding: 10px; + background: #dfdfdf; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; +} + +.properties-grid dt { + font-weight: bold; +} + +.properties-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.preview-frame { + width: min(680px, 100%); + min-height: 260px; + max-height: min(520px, calc(100vh - 160px)); + display: block; + margin: 0 auto; + background: #000000; + border: 1px solid #808080; +} + +.preview-frame.is-text { + min-height: 240px; + padding: 10px; + overflow: auto; + color: #00ff66; + font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; + font-size: 12px; + line-height: 15px; + white-space: pre; +} + .box-empty { margin: 0; padding: 12px; @@ -205,6 +320,15 @@ height: 26px; } + .box-command-row { + grid-template-columns: 1fr 1fr; + } + + .box-address { + grid-column: 1 / -1; + grid-row: 1; + } + .box-panel { margin: 0 6px 8px; } diff --git a/static/css/upload.css b/static/css/upload.css index 9803e8c..5723901 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -114,6 +114,22 @@ body.fit-window .desktop-wrap { text-align: left; } +.menu-action[aria-disabled="true"] { + color: #808080; + text-shadow: 1px 1px 0 #ffffff; +} + +.menu-action[aria-disabled="true"] img { + opacity: .55; + filter: grayscale(1); +} + +.menu-action[aria-disabled="true"]:hover, +.menu-action[aria-disabled="true"]:focus-visible { + color: #808080; + background: transparent; +} + .menu-action img { width: 16px; height: 16px; @@ -672,15 +688,21 @@ body.fit-window .desktop-wrap { } .option-check input[type="checkbox"]:checked + span::after { - content: "✓"; + content: ""; position: absolute; - left: 2px; - top: -3px; + left: 4px; + top: 6px; + width: 2px; + height: 2px; color: #000000; - font-family: Arial, Helvetica, sans-serif; - font-size: 18px; - line-height: 18px; - font-weight: bold; + background: #000000; + box-shadow: + 2px 2px 0 #000000, + 4px 4px 0 #000000, + 6px 2px 0 #000000, + 8px 0 0 #000000, + 10px -2px 0 #000000; + image-rendering: pixelated; } .upload-select, @@ -753,7 +775,7 @@ body.fit-window .desktop-wrap { font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; font-size: 13px; line-height: 16px; - white-space: pre-wrap; + white-space: pre; } .terminal-box::after { @@ -871,6 +893,8 @@ body.fit-window .desktop-wrap { } .popup-body { + flex: 1 1 auto; + min-height: 0; max-height: calc(100vh - 90px); padding: 12px; overflow: auto; @@ -884,17 +908,40 @@ body.fit-window .desktop-wrap { .popup-body ul, .popup-body ol { margin: 0 0 8px 18px; padding: 0; } .popup-body li { margin: 0 0 4px; } -.popup-body pre { +.popup-body .code-block { margin: 6px 0 10px; padding: 8px; + width: 100%; + display: block; overflow: auto; color: #00ff66; background: #000000; border: 0; - font-family: 'PixelOperatorMono', 'Courier New', monospace; + font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace; font-size: 12px; line-height: 15px; - white-space: pre-wrap; + white-space: pre; + box-sizing: border-box; +} + +.popup-window.is-about-popup .popup-body { + display: flex; + flex-direction: column; + justify-content: stretch; + overflow: hidden; +} + +.about-popup-content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.about-popup-content p:last-child { + margin-top: auto; + margin-bottom: 0; + padding-top: 10px; } .popup-close { @@ -1012,16 +1059,16 @@ body.fit-window .desktop-wrap { .copy-fallback-text { width: 100%; min-height: 58px; - font-family: 'PixelOperatorMono', monospace; + font-family: 'MonoCraft', 'PixelOperatorMono', monospace; } -.popup-body pre { +.popup-body .code-block { user-select: text; cursor: text; padding-bottom: 22px; } -.popup-body pre::after { +.popup-body .code-block::after { content: "\A"; white-space: pre; } diff --git a/static/css/window.css b/static/css/window.css index e98c0e3..a261087 100644 --- a/static/css/window.css +++ b/static/css/window.css @@ -3,9 +3,7 @@ flex-direction: column; color: #000000; 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); + background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06)); border-top: 1px solid #ffffff; border-left: 1px solid #ffffff; border-right: 1px solid #000000; @@ -79,7 +77,7 @@ border-right: 1px solid #000000; border-bottom: 1px solid #000000; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; - font-family: Arial, Helvetica, sans-serif; + font-family: inherit; font-size: 12px; line-height: 12px; } diff --git a/static/js/app.js b/static/js/app.js index a683db6..d27d620 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -171,17 +171,25 @@ function setStatus(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); + window.WarpBoxUI.toast(message, type, { target: el.toast }); +} + +function closeMenus() { + document.querySelectorAll(".menu-item.is-open").forEach((node) => { + node.classList.remove("is-open"); + node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); + }); } function disabledReasonFor(target) { - const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone"); + const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone, .option-check, .option-row"); if (!control) return ""; + if (control.classList.contains("option-check") || control.classList.contains("option-row")) { + const nested = control.querySelector("input, select, textarea"); + if (nested?.disabled || nested?.readOnly || nested?.getAttribute("aria-disabled") === "true") { + return nested.dataset.disabledReason || "This option is disabled right now."; + } + } if (control.classList.contains("upload-dropzone") && uploadLocked) { return control.dataset.disabledReason || "The current box is sealed after upload. Press Clear to start a new box."; } @@ -196,6 +204,7 @@ function announceDisabledReason(event) { if (!reason) return false; event.preventDefault(); event.stopPropagation(); + closeMenus(); showToast(reason, "warning"); setStatus(reason); return true; @@ -225,8 +234,10 @@ function setShareUrl(url) { 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.disabled = false; + el.copyButton.setAttribute("aria-disabled", shareUrl ? "false" : "true"); el.copyButton.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first."; + updateDisabledReasons(); updateTerminal(); updateCurrentStep(); } @@ -345,7 +356,8 @@ function createFileRow(item, index) { 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; + remove.disabled = false; + remove.setAttribute("aria-disabled", uploadLocked ? "true" : "false"); remove.dataset.disabledReason = uploadLocked ? "Files cannot be removed after the box is created. Press Clear to start another upload." : ""; const progress = document.createElement("span"); @@ -762,7 +774,8 @@ function updateDisabledReasons() { 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.disabled = false; + el.startButton.setAttribute("aria-disabled", reason ? "true" : "false"); el.startButton.dataset.disabledReason = reason; el.startButton.title = reason; } @@ -772,22 +785,31 @@ function updateDisabledReasons() { if (el.dropzone) { el.dropzone.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : ""); } + document.querySelectorAll('[data-action="start-upload"]').forEach((button) => { + const reason = el.startButton?.dataset.disabledReason || ""; + button.setAttribute("aria-disabled", reason ? "true" : "false"); + button.dataset.disabledReason = reason; + }); + document.querySelectorAll('[data-action="browse"]').forEach((button) => { + const reason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : ""); + button.setAttribute("aria-disabled", reason ? "true" : "false"); + button.dataset.disabledReason = reason; + }); + document.querySelectorAll('[data-action="copy-link"]').forEach((button) => { + button.setAttribute("aria-disabled", shareUrl ? "false" : "true"); + button.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first."; + }); } function saveSettings() { + const apiKey = el.apiKeyMode?.checked && validApiKey(el.apiKeyInput?.value || "") ? el.apiKeyInput.value.trim() : ""; 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 || "", + apiKey, }; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } @@ -797,25 +819,19 @@ function loadSettings() { 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 || ""; + if (el.apiKeyInput) el.apiKeyInput.value = validApiKey(settings.apiKey || "") ? settings.apiKey : ""; syncZipForRetention(); syncApiKeyField(); + saveSettings(); } function syncMenuChecks() { - const downloadCheck = document.querySelector("[data-download-page-check]"); - if (downloadCheck) downloadCheck.textContent = el.downloadPage?.checked ? "✓" : ""; + updateDisabledReasons(); } function syncApiKeyField() { @@ -841,6 +857,7 @@ function validateApiKeyField() { const value = el.apiKeyInput.value.trim(); if (!value) { el.apiKeyState.textContent = "waiting"; + saveSettings(); return; } @@ -850,11 +867,22 @@ function validateApiKeyField() { 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"); + if (validApiKey(value)) { + el.apiKeyState.textContent = "saved locally"; + saveSettings(); + } else { + el.apiKeyInput.value = ""; + el.apiKeyState.textContent = "invalid"; + saveSettings(); + showToast("Invalid API key removed. Paste a valid API key to save it.", "warning"); + } }, 650); } +function validApiKey(value) { + return /^[A-Za-z0-9._-]{12,}$/.test(String(value || "").trim()); +} + function slugify(value) { return String(value || "") .toLowerCase() @@ -966,17 +994,17 @@ function showWarningDialog(title, 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"); + window.WarpBoxUI.openPopup(title, html, { + about, + popup: el.docPopup, + title: el.docPopupTitle, + body: el.docPopupBody, + backdrop: el.modalBackdrop, + }); } function closeDoc() { - el.docPopup?.classList.remove("is-visible", "is-about-popup"); - el.modalBackdrop?.classList.remove("is-visible"); + window.WarpBoxUI.closePopup({ popup: el.docPopup, backdrop: el.modalBackdrop }); } async function showTemplatePopup(title, templateName, data = {}, about = false) { @@ -1018,10 +1046,7 @@ document.addEventListener("click", (event) => { 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"); - }); + closeMenus(); item.classList.toggle("is-open", !isOpen); menuButton.setAttribute("aria-expanded", String(!isOpen)); return; @@ -1029,7 +1054,7 @@ document.addEventListener("click", (event) => { const action = event.target.closest("[data-action]")?.dataset.action; if (action) { - document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open")); + closeMenus(); if (action === "browse") el.fileInput?.click(); if (action === "start-upload") startUpload(); if (action === "copy-link") copyText("Share URL", shareUrl, shareUrl); @@ -1054,7 +1079,6 @@ document.addEventListener("click", (event) => { syncMenuChecks(); } if (action === "help" || action === "side-help") openDoc("faq"); - if (action === "terminal-help") el.terminal?.focus(); if (action === "coming-soon") showToast("Coming Soon, not implemented just yet."); if (action === "fake-close") showToast("Close button denied. The upload window is staying open.", "warning"); if (action === "minimize") showToast("Minimize requested. WarpBox stays visible so your queue is safe."); @@ -1093,10 +1117,7 @@ document.addEventListener("click", (event) => { 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"); - }); + closeMenus(); } }); @@ -1107,10 +1128,7 @@ document.addEventListener("mousedown", (event) => { document.querySelectorAll(".menu-item").forEach((item) => { item.addEventListener("mouseenter", () => { if (!document.querySelector(".menu-item.is-open")) return; - document.querySelectorAll(".menu-item.is-open").forEach((node) => { - node.classList.remove("is-open"); - node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); - }); + closeMenus(); item.classList.add("is-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true"); }); @@ -1148,6 +1166,46 @@ el.copyCurlButton?.addEventListener("click", () => copyText("cURL command", getC el.docPopupClose?.addEventListener("click", closeDoc); el.modalBackdrop?.addEventListener("click", closeDoc); +el.maxViews?.addEventListener("wheel", (event) => { + if (el.maxViews.disabled || el.maxViews.readOnly) return; + event.preventDefault(); + const delta = event.deltaY < 0 ? 1 : -1; + const modifier = event.ctrlKey && event.shiftKey ? 50 : event.shiftKey ? 15 : event.ctrlKey ? 5 : 1; + const min = Number.parseInt(el.maxViews.min || "1", 10); + const max = Number.parseInt(el.maxViews.max || "9999", 10); + const current = Number.parseInt(el.maxViews.value || String(min), 10); + el.maxViews.value = String(Math.max(min, Math.min(max, current + (delta * modifier)))); + saveSettings(); + updateTerminal(); +}); + +el.apiKeyInput?.addEventListener("keydown", (event) => { + const allowed = event.ctrlKey || event.metaKey || event.altKey || [ + "Tab", + "Shift", + "Control", + "Alt", + "Meta", + "Escape", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", + ].includes(event.key); + if (allowed) return; + event.preventDefault(); + showToast("Only pasting the API key is supported.", "warning"); + setStatus("Only pasting the API key is supported"); +}); + +el.apiKeyInput?.addEventListener("paste", () => { + setTimeout(validateApiKeyField, 0); +}); + [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(); @@ -1172,7 +1230,7 @@ el.modalBackdrop?.addEventListener("click", closeDoc); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeDoc(); - document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open")); + closeMenus(); } if (event.key === "F1") { event.preventDefault(); diff --git a/static/js/box.js b/static/js/box.js index 4148c4e..267aff2 100644 --- a/static/js/box.js +++ b/static/js/box.js @@ -1,20 +1,182 @@ const boxPanel = document.querySelector(".box-panel[data-box-id]"); const boxStatus = document.querySelector(".box-statusbar span:first-child"); +const boxAddress = document.querySelector("#box-address"); +const boxExpiryMeta = document.querySelector(".box-meta[data-expires-at]"); +const boxExpiryText = document.querySelector("#box-expiry-text"); +const contextMenu = document.querySelector("#box-context-menu"); +const docPopup = document.querySelector("#doc-popup"); +const docPopupTitle = document.querySelector("#doc-popup-title"); +const docPopupBody = document.querySelector("#doc-popup-body"); +const docPopupClose = document.querySelector("#doc-popup-close"); +const modalBackdrop = document.querySelector("#modal-backdrop"); +const toast = document.querySelector("#toast"); const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true"; -document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => { - item.addEventListener("click", (event) => { - if (item.getAttribute("aria-disabled") === "true") { - event.preventDefault(); - } +let contextFile = null; + +function htmlEscape(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function showToast(message, type = "info") { + window.WarpBoxUI.toast(message, type, { target: toast }); +} + +function openPopup(title, html, options = {}) { + window.WarpBoxUI.openPopup(title, html, { + ...options, + popup: docPopup, + title: docPopupTitle, + body: docPopupBody, + backdrop: modalBackdrop, }); -}); +} + +function closePopup() { + window.WarpBoxUI.closePopup({ popup: docPopup, backdrop: modalBackdrop }); +} + +function currentExpiryDate() { + const value = boxExpiryMeta?.dataset.expiresAt || ""; + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatDuration(ms) { + if (ms <= 0) return "expired"; + const totalSeconds = Math.ceil(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (days) return `${days}d ${hours}h ${minutes}m`; + if (hours) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function updateExpiryCountdown() { + if (!boxExpiryText || !boxExpiryMeta) return; + const expiry = currentExpiryDate(); + if (!expiry) { + boxExpiryText.textContent = "Expires after one-time download"; + return; + } + boxExpiryText.textContent = `Expires in ${formatDuration(expiry.getTime() - Date.now())}`; + boxExpiryText.title = `Expires at ${expiry.toLocaleString()}`; +} + +function closeContextMenu() { + contextMenu?.classList.remove("is-visible"); + contextMenu?.setAttribute("aria-hidden", "true"); +} + +function showContextMenu(file, x, y) { + if (!contextMenu) return; + contextFile = file; + contextMenu.style.left = `${Math.min(x, window.innerWidth - 190)}px`; + contextMenu.style.top = `${Math.min(y, window.innerHeight - 98)}px`; + contextMenu.classList.add("is-visible"); + contextMenu.setAttribute("aria-hidden", "false"); +} + +function fileData(item) { + return { + id: item.dataset.fileId || "", + name: item.dataset.name || item.querySelector(".box-file-name")?.textContent || "", + size: item.dataset.size || "", + mime: item.dataset.mime || "", + status: item.dataset.status || "", + statusLabel: item.querySelector(".box-file-meta")?.textContent || "", + downloadPath: item.dataset.downloadPath || item.getAttribute("href") || "", + thumbnail: item.dataset.thumbnail || "", + canDownload: item.getAttribute("aria-disabled") !== "true" && item.getAttribute("href") !== "#", + }; +} + +function downloadFile(item) { + const data = fileData(item); + if (!data.canDownload) { + showToast(zipOnly ? "Individual file downloads are disabled for one-time boxes. Use Download Zip." : "This file is not ready for download yet.", "warning"); + return; + } + window.location.href = data.downloadPath; + setTimeout(refreshBoxStatus, 900); +} + +function previewURL(data) { + return data.canDownload ? data.downloadPath : ""; +} + +async function previewFile(item) { + const data = fileData(item); + if (zipOnly) { + showToast("Previews are disabled for one-time boxes. Use Download Zip.", "warning"); + return; + } + const url = previewURL(data); + if (!url) { + showToast("This file is not ready to preview yet.", "warning"); + return; + } + + const mime = data.mime.toLowerCase(); + const name = htmlEscape(data.name); + if (mime.startsWith("image/")) { + openPopup(`${data.name} preview`, `${name}`, { preview: true }); + return; + } + if (mime.startsWith("video/")) { + openPopup(`${data.name} preview`, ``, { preview: true }); + return; + } + if (mime.startsWith("audio/")) { + openPopup(`${data.name} preview`, ``, { preview: true }); + return; + } + if (mime === "application/pdf") { + openPopup(`${data.name} preview`, ``, { preview: true }); + return; + } + if (mime.startsWith("text/") || /\.(txt|md|json|csv|log|html|css|js)$/i.test(data.name)) { + try { + const response = await fetch(url); + if (!response.ok) throw new Error("Preview failed"); + const text = await response.text(); + openPopup(`${data.name} preview`, `${htmlEscape(text.slice(0, 120000))}`, { preview: true }); + } catch (_) { + showToast("The browser could not load a text preview.", "error"); + } + return; + } + showToast("This file type cannot be previewed in the browser.", "warning"); +} + +function showProperties(item) { + const data = fileData(item); + const url = data.downloadPath ? new URL(data.downloadPath, window.location.origin).toString() : "Not ready"; + openPopup(`${data.name} Properties`, ` +

${htmlEscape(data.name)}

+
+
Name
${htmlEscape(data.name)}
+
Size
${htmlEscape(data.size || "Unknown")}
+
Type
${htmlEscape(data.mime || "Unknown")}
+
Status
${htmlEscape(data.statusLabel || data.status || "Unknown")}
+
File ID
${htmlEscape(data.id)}
+
Location
${htmlEscape(url)}
+
+ `, { properties: true }); +} function updateBoxFile(file) { const item = document.querySelector(`.box-file[data-file-id="${file.id}"]`); - if (!item) { - return; - } + if (!item) return; const meta = item.querySelector(".box-file-meta"); const icon = item.querySelector(".box-file-icon"); @@ -26,6 +188,11 @@ function updateBoxFile(file) { item.classList.toggle("is-loading", !isComplete && !isFailed); item.classList.toggle("has-thumbnail", Boolean(file.thumbnail_path)); item.dataset.status = file.status; + item.dataset.name = file.name || item.dataset.name || ""; + item.dataset.size = file.size_label || item.dataset.size || ""; + item.dataset.mime = file.mime_type || item.dataset.mime || ""; + item.dataset.downloadPath = file.download_path || item.dataset.downloadPath || ""; + item.dataset.thumbnail = file.thumbnail_path || ""; item.title = file.title; if (isComplete && !zipOnly) { @@ -38,27 +205,22 @@ function updateBoxFile(file) { item.setAttribute("aria-disabled", "true"); } - if (meta) { - meta.textContent = `${file.status_label} · ${file.size_label}`; - } - - if (icon) { - icon.src = file.thumbnail_path || file.icon_path; - } + if (meta) meta.textContent = `${file.status_label} · ${file.size_label}`; + if (icon) icon.src = file.thumbnail_path || file.icon_path; } async function refreshBoxStatus() { - if (!boxPanel) { - return false; - } + if (!boxPanel) return false; const boxID = boxPanel.dataset.boxId; const response = await fetch(`/box/${boxID}/status`); - if (!response.ok) { - return true; - } + if (!response.ok) return true; const result = await response.json(); + if (boxExpiryMeta && typeof result.expires_at === "string") { + boxExpiryMeta.dataset.expiresAt = result.expires_at; + updateExpiryCountdown(); + } result.files.forEach(updateBoxFile); if (boxStatus) { @@ -73,17 +235,73 @@ async function refreshBoxStatus() { }); } +document.addEventListener("click", (event) => { + const action = event.target.closest("[data-action]")?.dataset.action; + if (action === "fake-close") showToast("Close clicked. The download window is emotionally attached.", "warning"); + if (action === "minimize") showToast("Minimize clicked. WarpBox refuses to disappear quietly."); + if (action === "toggle-fit") { + document.body.classList.toggle("fit-window"); + showToast("Maximize clicked. The window is doing its best."); + } + + const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction; + if (contextAction && contextFile) { + event.preventDefault(); + const item = contextFile; + closeContextMenu(); + if (contextAction === "preview") previewFile(item); + if (contextAction === "download") downloadFile(item); + if (contextAction === "properties") showProperties(item); + return; + } + + if (!event.target.closest("#box-context-menu")) closeContextMenu(); +}); + +document.querySelectorAll(".box-file").forEach((item) => { + item.addEventListener("click", (event) => { + if (item.getAttribute("aria-disabled") === "true") { + event.preventDefault(); + showToast(zipOnly ? "Individual file downloads are disabled for one-time boxes. Use Download Zip." : "This file is not ready yet.", "warning"); + return; + } + setTimeout(refreshBoxStatus, 900); + }); + item.addEventListener("contextmenu", (event) => { + event.preventDefault(); + showContextMenu(item, event.clientX, event.clientY); + }); +}); + +boxAddress?.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(window.location.href); + showToast("Current box URL copied."); + } catch (_) { + openPopup("Copy box URL", `

Clipboard access failed. Copy this URL manually.

`); + } +}); + +docPopupClose?.addEventListener("click", closePopup); +modalBackdrop?.addEventListener("click", closePopup); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closePopup(); + closeContextMenu(); + } +}); + +updateExpiryCountdown(); +setInterval(updateExpiryCountdown, 1000); + if (boxPanel) { const pollMS = Number.parseInt(boxPanel.dataset.pollMs, 10) || 5000; const timer = setInterval(async () => { try { const hasLoadingFiles = await refreshBoxStatus(); - if (!hasLoadingFiles) { - clearInterval(timer); - } - } catch (error) { - // Keep polling through temporary network/server hiccups; otherwise - // an in-progress file can appear stuck forever after one bad poll. + if (!hasLoadingFiles) clearInterval(timer); + } catch (_) { + // Keep polling through temporary network/server hiccups. } }, pollMS); } diff --git a/static/js/upload-utils.js b/static/js/upload-utils.js index fd4459b..3da0e58 100644 --- a/static/js/upload-utils.js +++ b/static/js/upload-utils.js @@ -1,8 +1,10 @@ window.WBUtils = (() => { function renderTemplate(template, data = {}) { - return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { - return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : ""; - }); + return window.WarpBoxUI?.renderTemplate + ? window.WarpBoxUI.renderTemplate(template, data) + : String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : ""; + }); } return { renderTemplate }; diff --git a/static/js/warpbox-ui.js b/static/js/warpbox-ui.js new file mode 100644 index 0000000..172f248 --- /dev/null +++ b/static/js/warpbox-ui.js @@ -0,0 +1,48 @@ +window.WarpBoxUI = (() => { + let toastTimer = null; + + function toast(message, type = "info", options = {}) { + const target = options.target || document.querySelector("#toast"); + if (!target) return; + target.textContent = message; + target.classList.remove("toast-info", "toast-warning", "toast-error", "is-visible"); + target.classList.add(`toast-${type}`, "is-visible"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => target.classList.remove("is-visible"), options.duration || 2600); + } + + function popupElements(options = {}) { + return { + popup: options.popup || document.querySelector("#doc-popup"), + title: options.title || document.querySelector("#doc-popup-title"), + body: options.body || document.querySelector("#doc-popup-body"), + backdrop: options.backdrop || document.querySelector("#modal-backdrop"), + }; + } + + function openPopup(titleText, html, options = {}) { + const parts = popupElements(options); + if (!parts.popup || !parts.title || !parts.body) return; + parts.title.textContent = titleText; + parts.body.innerHTML = html; + parts.popup.classList.toggle("is-about-popup", Boolean(options.about)); + parts.popup.classList.toggle("is-properties-popup", Boolean(options.properties)); + parts.popup.classList.toggle("is-preview-popup", Boolean(options.preview)); + parts.popup.classList.add("is-visible"); + parts.backdrop?.classList.add("is-visible"); + } + + function closePopup(options = {}) { + const parts = popupElements(options); + parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup"); + parts.backdrop?.classList.remove("is-visible"); + } + + function renderTemplate(template, data = {}) { + return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : ""; + }); + } + + return { toast, openPopup, closePopup, renderTemplate }; +})(); diff --git a/static/popups/about.html b/static/popups/about.html index c82a9cf..eecc7c7 100644 --- a/static/popups/about.html +++ b/static/popups/about.html @@ -1,3 +1,6 @@ -

WarpBox

-

WarpBox was made by Daniel Legt.

-

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

+
+

WarpBox

+

WarpBox was made by Daniel Legt.

+

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

+

Version: v1.3.8a

+
diff --git a/static/popups/cli.html b/static/popups/cli.html index f55986c..7f4c31c 100644 --- a/static/popups/cli.html +++ b/static/popups/cli.html @@ -1,9 +1,33 @@

Upload with cURL

WarpBox accepts normal multipart form uploads through the compatibility endpoint:

-
curl \
+curl \
   -F 'files=@./my-file.zip' \
   -F 'retention=1h' \
   {{ 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.

+

Make a WarpBox executable

+

Save this as warpbox, make it executable, and put it somewhere on your PATH.

+#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "Usage: warpbox FILE [FILE ...]" >&2 + exit 64 +fi + +endpoint="${WARPBOX_URL:-{{ origin }}}/upload" +retention="${WARPBOX_RETENTION:-1h}" + +args=(-F "retention=${retention}") +for file in "$@"; do + args+=(-F "files=@${file}") +done + +curl "${args[@]}" "${endpoint}" + +chmod +x ./warpbox +sudo install -m 755 ./warpbox /usr/local/bin/warpbox +warpbox ./my-file.zip + diff --git a/static/popups/examples.html b/static/popups/examples.html index 79c6f99..edb594b 100644 --- a/static/popups/examples.html +++ b/static/popups/examples.html @@ -1,20 +1,20 @@

Upload examples

Basic CLI upload

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

Multiple files with password

-
curl \
+curl \
   -F 'files=@./one.png' \
   -F 'files=@./two.zip' \
   -F 'retention=1h' \
   -F 'password=secret-pass' \
   {{ origin }}/upload
-
+

Go

-
package main
+package main
 
 import (
   "bytes"
@@ -48,9 +48,9 @@ func main() {
   out, _ := io.ReadAll(resp.Body)
   fmt.Println(string(out))
 }
-
+

Java 11+ HttpClient

-
import java.net.URI;
+import java.net.URI;
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
@@ -84,9 +84,9 @@ public class UploadWarpBox {
     System.out.println(response.body());
   }
 }
-
+

JavaScript Node.js

-
import { openAsBlob } from 'node:fs';
+import { openAsBlob } from 'node:fs';
 
 const file = await openAsBlob('./photo.png');
 const form = new FormData();
@@ -99,4 +99,4 @@ const res = await fetch('{{ origin }}/upload', {
 });
 
 console.log(await res.text());
-
+ diff --git a/templates/box.html b/templates/box.html index 4435a34..d606d50 100644 --- a/templates/box.html +++ b/templates/box.html @@ -5,7 +5,6 @@ WarpBox - {{ .BoxID }} - @@ -19,37 +18,24 @@

WarpBox Explorer - {{ .BoxID }}

- @@ -99,7 +96,7 @@
Share URL: Not created yet - +
@@ -110,7 +107,7 @@
- +
@@ -145,7 +142,7 @@