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`, ``, { 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`, `
+
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 was made by Daniel Legt.
-Temporary file boxes, terminal-friendly uploads, and old-web UI charm.
+WarpBox was made by Daniel Legt.
+Temporary file boxes, terminal-friendly uploads, and old-web UI charm.
+Version: v1.3.8a
+WarpBox accepts normal multipart form uploads through the compatibility endpoint:
-curl \
+curl \
-F 'files=@./my-file.zip' \
-F 'retention=1h' \
{{ 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.
+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 @@
curl \
+curl \
-F 'files=@./photo.png' \
-F 'retention=24h' \
{{ origin }}/upload
-
+
curl \
+curl \
-F 'files=@./one.png' \
-F 'files=@./two.zip' \
-F 'retention=1h' \
-F 'password=secret-pass' \
{{ origin }}/upload
-
+
package main
+package main
import (
"bytes"
@@ -48,9 +48,9 @@ func main() {
out, _ := io.ReadAll(resp.Body)
fmt.Println(string(out))
}
-
+
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());
}
}
-
+
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 @@