diff --git a/static/css/app.css b/static/css/app.css
index f444481..9565727 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -109,7 +109,7 @@ textarea {
}
::-webkit-scrollbar-track {
- background: repeating-linear-gradient(45deg, #c0c0c0 0 2px, #b5b5b5 2px 4px);
+ background: repeating-linear-gradient(45deg, #808080 0 2px, #8f8f8f 2px 4px);
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
@@ -126,8 +126,57 @@ textarea {
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
}
+::-webkit-scrollbar-button:single-button {
+ width: 17px;
+ height: 17px;
+ background-color: #c0c0c0;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 7px 7px;
+}
+
+::-webkit-scrollbar-button:single-button:vertical:decrement {
+ background-image: linear-gradient(45deg, transparent 50%, #000000 50%), linear-gradient(135deg, #000000 50%, transparent 50%);
+ background-position: 5px 6px, 8px 6px;
+ background-size: 4px 4px, 4px 4px;
+}
+
+::-webkit-scrollbar-button:single-button:vertical:increment {
+ background-image: linear-gradient(225deg, transparent 50%, #000000 50%), linear-gradient(315deg, #000000 50%, transparent 50%);
+ background-position: 5px 7px, 8px 7px;
+ background-size: 4px 4px, 4px 4px;
+}
+
+::-webkit-scrollbar-button:single-button:horizontal:decrement {
+ background-image: linear-gradient(135deg, transparent 50%, #000000 50%), linear-gradient(45deg, #000000 50%, transparent 50%);
+ background-position: 6px 5px, 6px 8px;
+ background-size: 4px 4px, 4px 4px;
+}
+
+::-webkit-scrollbar-button:single-button:horizontal:increment {
+ background-image: linear-gradient(315deg, transparent 50%, #000000 50%), linear-gradient(225deg, #000000 50%, transparent 50%);
+ background-position: 7px 5px, 7px 8px;
+ background-size: 4px 4px, 4px 4px;
+}
+
+::-webkit-scrollbar-thumb:hover,
+::-webkit-scrollbar-button:single-button:hover {
+ background-color: #d0d0d0;
+}
+
+::-webkit-scrollbar-thumb:active,
+::-webkit-scrollbar-button:single-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;
+}
+
::-webkit-scrollbar-corner {
background: #c0c0c0;
+ border-top: 1px solid #808080;
+ border-left: 1px solid #808080;
}
.win98-button {
@@ -231,10 +280,12 @@ textarea:disabled {
.popup-body li { margin: 0 0 4px; }
.popup-body .code-block {
margin: 6px 0 10px;
- padding: 8px 8px 22px;
width: 100%;
+ max-width: 100%;
display: block;
overflow: auto;
+ overscroll-behavior: contain;
+ padding: 8px;
color: #00ff66;
background: #000000;
border: 0;
@@ -243,13 +294,20 @@ textarea:disabled {
line-height: 15px;
white-space: pre;
user-select: text;
+ -webkit-user-select: text;
cursor: text;
box-sizing: border-box;
+ contain: layout paint;
}
-.popup-body .code-block::after {
- content: "\A";
- white-space: pre;
+.popup-body .code-block code {
+ display: inline-block;
+ min-width: 100%;
+ color: inherit;
+ font: inherit;
+ white-space: inherit;
+ user-select: text;
+ -webkit-user-select: text;
}
.copy-fallback-text {
diff --git a/static/css/box.css b/static/css/box.css
index 516719a..995e627 100644
--- a/static/css/box.css
+++ b/static/css/box.css
@@ -284,6 +284,14 @@ body.fit-window .box-window {
white-space: pre;
}
+.preview-frame.is-text code {
+ display: inline-block;
+ min-width: 100%;
+ color: inherit;
+ font: inherit;
+ white-space: inherit;
+}
+
.box-empty {
margin: 0;
padding: 12px;
diff --git a/static/css/upload.css b/static/css/upload.css
index 5723901..09f8b3c 100644
--- a/static/css/upload.css
+++ b/static/css/upload.css
@@ -775,7 +775,7 @@ body.fit-window .desktop-wrap {
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
font-size: 13px;
line-height: 16px;
- white-space: pre;
+ white-space: pre-wrap;
}
.terminal-box::after {
@@ -910,10 +910,12 @@ body.fit-window .desktop-wrap {
.popup-body li { margin: 0 0 4px; }
.popup-body .code-block {
margin: 6px 0 10px;
- padding: 8px;
width: 100%;
+ max-width: 100%;
display: block;
overflow: auto;
+ overscroll-behavior: contain;
+ padding: 8px;
color: #00ff66;
background: #000000;
border: 0;
@@ -921,7 +923,10 @@ body.fit-window .desktop-wrap {
font-size: 12px;
line-height: 15px;
white-space: pre;
+ user-select: text;
+ -webkit-user-select: text;
box-sizing: border-box;
+ contain: layout paint;
}
.popup-window.is-about-popup .popup-body {
@@ -1064,13 +1069,18 @@ body.fit-window .desktop-wrap {
.popup-body .code-block {
user-select: text;
+ -webkit-user-select: text;
cursor: text;
- padding-bottom: 22px;
}
-.popup-body .code-block::after {
- content: "\A";
- white-space: pre;
+.popup-body .code-block code {
+ display: inline-block;
+ min-width: 100%;
+ color: inherit;
+ font: inherit;
+ white-space: inherit;
+ user-select: text;
+ -webkit-user-select: text;
}
.kbd {
diff --git a/static/js/box.js b/static/js/box.js
index 267aff2..5ef65cb 100644
--- a/static/js/box.js
+++ b/static/js/box.js
@@ -13,6 +13,7 @@ const toast = document.querySelector("#toast");
const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true";
let contextFile = null;
+let lastStatusSignature = "";
function htmlEscape(value) {
return String(value || "")
@@ -149,7 +150,7 @@ async function previewFile(item) {
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 });
+ openPopup(`${data.name} preview`, `
${htmlEscape(text.slice(0, 120000))}`, { preview: true });
} catch (_) {
showToast("The browser could not load a text preview.", "error");
}
@@ -214,9 +215,13 @@ async function refreshBoxStatus() {
const boxID = boxPanel.dataset.boxId;
const response = await fetch(`/box/${boxID}/status`);
- if (!response.ok) return true;
+ if (!response.ok) return { changed: false, hasLoadingFiles: true };
const result = await response.json();
+ const signature = statusSignature(result);
+ const changed = signature !== lastStatusSignature;
+ lastStatusSignature = signature;
+
if (boxExpiryMeta && typeof result.expires_at === "string") {
boxExpiryMeta.dataset.expiresAt = result.expires_at;
updateExpiryCountdown();
@@ -228,11 +233,73 @@ async function refreshBoxStatus() {
boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
}
- return result.files.some((file) => {
+ const hasLoadingFiles = result.files.some((file) => {
const isUploading = file.status === "pending" || file.status === "uploading";
const isWaitingForThumbnail = file.status === "complete" && !file.thumbnail_status && !file.thumbnail_path;
return isUploading || isWaitingForThumbnail || file.thumbnail_status === "processing";
});
+
+ return { changed, hasLoadingFiles };
+}
+
+function statusSignature(result) {
+ const files = Array.isArray(result.files) ? result.files : [];
+ return JSON.stringify({
+ expiresAt: result.expires_at || "",
+ files: files.map((file) => ({
+ id: file.id,
+ status: file.status,
+ size: file.size,
+ thumbnailPath: file.thumbnail_path || "",
+ thumbnailStatus: file.thumbnail_status || "",
+ downloadPath: file.download_path || "",
+ })),
+ });
+}
+
+function pollingStages(baseMS) {
+ return [
+ { interval: baseMS, attempts: 10 },
+ { interval: baseMS * 2, attempts: 20 },
+ { interval: baseMS * 10, attempts: 100 },
+ ];
+}
+
+function startStagedPolling(baseMS) {
+ const stages = pollingStages(baseMS);
+ let stageIndex = 0;
+ let attemptsInStage = 0;
+ let stopped = false;
+
+ const tick = async () => {
+ if (stopped) return;
+ const stage = stages[stageIndex];
+ try {
+ const result = await refreshBoxStatus();
+ if (result.changed) {
+ stageIndex = 0;
+ attemptsInStage = 0;
+ } else {
+ attemptsInStage += 1;
+ if (attemptsInStage >= stage.attempts) {
+ stageIndex += 1;
+ attemptsInStage = 0;
+ if (stageIndex >= stages.length) {
+ stopped = true;
+ return;
+ }
+ }
+ }
+ } catch (_) {
+ attemptsInStage += 1;
+ }
+
+ if (!stopped) {
+ window.setTimeout(tick, stages[stageIndex].interval);
+ }
+ };
+
+ window.setTimeout(tick, stages[0].interval);
}
document.addEventListener("click", (event) => {
@@ -296,12 +363,5 @@ 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 (_) {
- // Keep polling through temporary network/server hiccups.
- }
- }, pollMS);
+ startStagedPolling(pollMS);
}
diff --git a/static/popups/cli.html b/static/popups/cli.html
index 7f4c31c..131216b 100644
--- a/static/popups/cli.html
+++ b/static/popups/cli.html
@@ -1,15 +1,14 @@
-WarpBox accepts normal multipart form uploads through the compatibility endpoint:
-curl \
+Upload from a terminal
+WarpBox accepts normal multipart uploads at /upload. The server returns JSON with a box_url you can open or share.
+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
+
+
+Reusable shell wrapper
+This version is small, portable, and works well as a personal warpbox command.
+#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 1 ]; then
@@ -25,9 +24,39 @@ for file in "$@"; do
args+=(-F "files=@${file}")
done
-curl "${args[@]}" "${endpoint}"
-
-chmod +x ./warpbox
+curl --fail-with-body "${args[@]}" "${endpoint}"
+
+
+Install it
+Put the wrapper somewhere on your PATH, then call it with one or more files.
+chmod +x ./warpbox
sudo install -m 755 ./warpbox /usr/local/bin/warpbox
-warpbox ./my-file.zip
-
+warpbox ./photo.png ./archive.zip
+
+
+If jq is installed, this variant extracts the returned link and expands it to a full URL.
warpbox() {
+ local endpoint="${WARPBOX_URL:-{{ origin }}}/upload"
+ local retention="${WARPBOX_RETENTION:-1h}"
+ local args=(-F "retention=${retention}")
+
+ for file in "$@"; do
+ args+=(-F "files=@${file}")
+ done
+
+ curl --fail-with-body -sS "${args[@]}" "${endpoint}" |
+ jq -r --arg origin "${WARPBOX_URL:-{{ origin }}}" '"\($origin)\(.box_url)"'
+}
+
+
+You can keep the wrapper simple and pass fixed options through environment variables or extra form fields.
+WARPBOX_RETENTION=24h warpbox ./release.tar.gz
+
+curl \
+ -F 'files=@./private.zip' \
+ -F 'retention=1h' \
+ -F 'password=correct-horse-battery-staple' \
+ {{ origin }}/upload
+
diff --git a/static/popups/examples.html b/static/popups/examples.html
index edb594b..41af39d 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());
-
+