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 @@ -

Upload with cURL

-

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
+
+ +

Print only the share URL

+

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)"'
+}
+
+ +

Add password or retention

+

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 @@

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());
-
+