feat(upload): add pause and cancel controls for active uploads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m3s

- Add CSS grid layout for upload-active-actions and hidden state
- Implement JavaScript logic for pausing and cancelling uploads with confirmation
- Add test to verify home page includes upload control elements
This commit is contained in:
2026-06-16 01:17:32 +03:00
parent dc4aee8ca2
commit 78b767a4a2
4 changed files with 312 additions and 17 deletions

View File

@@ -15,6 +15,11 @@
const manageLink = document.querySelector("#manage-link");
const newUpload = document.querySelector("#new-upload");
const folderPicker = document.querySelector("[data-folder-picker]");
const submitButton = form && form.querySelector("button[type='submit']");
const idleUploadActions = form ? Array.from(form.querySelectorAll(".upload-idle-action")) : [];
const activeUploadActions = document.querySelector("#upload-active-actions");
const cancelUpload = document.querySelector("#cancel-upload");
const pauseUpload = document.querySelector("#pause-upload");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
@@ -52,6 +57,12 @@
let recoveredDraft = null;
let resumeMode = false;
let sharedTargetDraft = null;
let activeUploadRequest = null;
let activeUploadSession = null;
let uploadPaused = false;
let uploadCancelled = false;
let uploadFinalizing = false;
let pauseWaiters = [];
const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10);
const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit");
@@ -145,7 +156,6 @@
}
}
const submit = form.querySelector("button[type='submit']");
const formData = uploadFormData();
await maybeRequestUploadNotificationPermission(selectedFiles);
if (resumeMode && recoveredDraft) {
@@ -153,7 +163,7 @@
} else {
renderQueue(selectedFiles, "queued");
}
setLoading(true, submit);
beginUpload();
try {
const payload = await uploadResumable(form.action, formData, selectedFiles);
@@ -175,14 +185,55 @@
fileSummary.textContent = "Upload complete.";
}
} catch (error) {
if (isUploadCancelledError(error)) {
await discardActiveUploadSession();
await clearSharedTargetPayload();
form.reset();
resetFreshUploadState();
return;
}
updateStatus(error.message || "Upload failed");
notifyUploadError(error);
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
} finally {
setLoading(false, submit);
finishUpload();
}
});
if (pauseUpload) {
pauseUpload.addEventListener("click", () => {
if (!uploadLocked || uploadCancelled) {
return;
}
if (uploadPaused) {
resumeActiveUpload();
} else {
pauseActiveUpload();
}
});
}
if (cancelUpload) {
cancelUpload.addEventListener("click", async () => {
if (!uploadLocked || uploadCancelled) {
return;
}
const confirmed = await window.Warpbox.confirmDialog(
"Cancel this upload? Uploaded chunks will be deleted and the form will be reset for a new upload.",
{
title: "Cancel upload?",
variant: "warning",
confirmLabel: "Cancel Upload",
cancelLabel: "Keep Uploading",
},
);
if (!confirmed || !uploadLocked || uploadFinalizing) {
return;
}
requestUploadCancellation();
});
}
if (copyURL) {
copyURL.addEventListener("click", () => {
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
@@ -622,20 +673,147 @@
newUpload.style.display = visible ? "" : "none";
}
function setLoading(isLoading, submit) {
function beginUpload() {
uploadPaused = false;
uploadCancelled = false;
uploadFinalizing = false;
activeUploadRequest = null;
activeUploadSession = null;
setLoading(true);
}
function finishUpload() {
activeUploadRequest = null;
activeUploadSession = null;
uploadPaused = false;
uploadCancelled = false;
uploadFinalizing = false;
releasePauseWaiters();
setLoading(false);
}
function setLoading(isLoading) {
uploadLocked = isLoading;
if (progress) {
progress.hidden = !isLoading;
}
if (submit) {
submit.disabled = isLoading;
submit.textContent = isLoading ? "Uploading..." : "Upload files";
idleUploadActions.forEach((button) => {
button.style.display = isLoading ? "none" : "";
});
if (activeUploadActions) {
activeUploadActions.hidden = !isLoading;
}
if (submitButton) {
submitButton.disabled = isLoading;
submitButton.textContent = "Upload files";
}
if (newUpload) {
newUpload.disabled = isLoading;
}
if (cancelUpload) {
cancelUpload.disabled = !isLoading;
cancelUpload.textContent = "Cancel Upload";
}
updatePauseButton();
updateStatus(isLoading ? "Transferring files..." : "");
setTotalProgress(isLoading ? 0 : 100);
if (!isLoading) {
updateNewUploadVisibility();
}
}
function pauseActiveUpload() {
uploadPaused = true;
updatePauseButton();
updateStatus("Upload paused.");
if (activeUploadRequest) {
activeUploadRequest.abort();
}
}
function resumeActiveUpload() {
uploadPaused = false;
updatePauseButton();
updateStatus("Resuming upload...");
releasePauseWaiters();
}
function requestUploadCancellation() {
if (uploadFinalizing) {
return;
}
uploadCancelled = true;
uploadPaused = false;
updateStatus("Cancelling upload...");
if (cancelUpload) {
cancelUpload.disabled = true;
cancelUpload.textContent = "Cancelling...";
}
if (pauseUpload) {
pauseUpload.disabled = true;
}
releasePauseWaiters();
if (activeUploadRequest) {
activeUploadRequest.abort();
}
}
function updatePauseButton() {
if (!pauseUpload) {
return;
}
pauseUpload.disabled = !uploadLocked || uploadCancelled || uploadFinalizing;
pauseUpload.textContent = uploadPaused ? "Resume Upload" : "Pause Upload";
pauseUpload.classList.toggle("button-primary", uploadPaused);
pauseUpload.classList.toggle("button-outline", !uploadPaused);
}
function releasePauseWaiters() {
const waiters = pauseWaiters;
pauseWaiters = [];
waiters.forEach((resolve) => resolve());
}
async function waitForUploadReady() {
while (uploadPaused && !uploadCancelled) {
await new Promise((resolve) => pauseWaiters.push(resolve));
}
if (uploadCancelled) {
throw uploadControlError("UploadCancelledError", "Upload cancelled");
}
}
function uploadControlError(name, message) {
const error = new Error(message);
error.name = name;
return error;
}
function isUploadPausedError(error) {
return Boolean(error && error.name === "UploadPausedError");
}
function isUploadCancelledError(error) {
return Boolean(error && error.name === "UploadCancelledError");
}
async function discardActiveUploadSession() {
const session = activeUploadSession;
activeUploadSession = null;
if (!session || !session.sessionId) {
return;
}
await cancelResumableSession(session.sessionId, session.resumeToken).catch(() => {});
removeResumableSession(session.sessionId);
}
function beginUploadFinalization() {
uploadFinalizing = true;
updateStatus("Finalizing upload...");
if (cancelUpload) {
cancelUpload.disabled = true;
}
updatePauseButton();
}
function updateStatus(message) {
@@ -709,8 +887,20 @@
}
function uploadWithProgress(url, formData, files) {
return uploadWithProgressAttempt(url, formData, files).catch(async (error) => {
if (isUploadPausedError(error)) {
await waitForUploadReady();
return uploadWithProgress(url, formData, files);
}
throw error;
});
}
async function uploadWithProgressAttempt(url, formData, files) {
await waitForUploadReady();
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
activeUploadRequest = request;
const rateTracker = createTransferRateTracker(0);
request.open("POST", url);
request.setRequestHeader("Accept", "application/json");
@@ -728,6 +918,9 @@
});
request.addEventListener("load", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
@@ -744,19 +937,37 @@
resolve(payload);
});
request.addEventListener("error", () => reject(new Error("Network error during upload")));
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
request.addEventListener("error", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
reject(new Error("Network error during upload"));
});
request.addEventListener("abort", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
if (uploadCancelled) {
reject(uploadControlError("UploadCancelledError", "Upload cancelled"));
} else if (uploadPaused) {
reject(uploadControlError("UploadPausedError", "Upload paused"));
} else {
reject(new Error("Upload aborted"));
}
});
request.send(formData);
});
}
async function uploadResumable(fallbackUrl, formData, files) {
await waitForUploadReady();
if (!window.fetch || typeof Blob === "undefined") {
return uploadWithProgress(fallbackUrl, formData, files);
}
updateStatus("Fingerprinting files...");
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
await waitForUploadReady();
const createPayload = {
files: files.map((file, index) => ({
name: uploadName(file),
@@ -779,6 +990,7 @@
session = await findResumableSession(createPayload);
}
if (session) {
activeUploadSession = session;
validateResumeSelection(session, createPayload);
session = await addMissingResumableFiles(session, createPayload);
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
@@ -787,6 +999,7 @@
if (persistable) {
saveResumableSession(session, createPayload);
}
activeUploadSession = session;
}
if (!session || session.status !== "uploading") {
try {
@@ -800,7 +1013,9 @@
if (persistable) {
saveResumableSession(session, createPayload);
}
activeUploadSession = session;
}
await waitForUploadReady();
const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index]));
if (sessionFiles.some((file) => !file)) {
throw new Error("Upload session could not match the selected files");
@@ -822,6 +1037,7 @@
const sessionFile = sessionFiles[fileIndex];
const uploaded = new Set(sessionFile.uploadedChunks || []);
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
await waitForUploadReady();
if (uploaded.has(chunkIndex)) {
continue;
}
@@ -845,12 +1061,14 @@
setSingleFileProgress(fileIndex, file, 100);
}
updateStatus("Finalizing upload...");
await waitForUploadReady();
beginUploadFinalization();
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
const wasResumeMode = resumeMode;
if (persistable) {
removeResumableSession(session.sessionId);
}
activeUploadSession = null;
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
resumeMode = false;
recoveredDraft = null;
@@ -897,6 +1115,7 @@
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
activeUploadRequest = request;
request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`);
request.setRequestHeader("Accept", "application/json");
request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || "");
@@ -906,6 +1125,9 @@
}
});
request.addEventListener("load", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
if (request.status < 200 || request.status >= 300) {
let payload = {};
try {
@@ -918,8 +1140,24 @@
}
resolve();
});
request.addEventListener("error", () => reject(new Error("Network error during chunk upload")));
request.addEventListener("abort", () => reject(new Error("Chunk upload aborted")));
request.addEventListener("error", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
reject(new Error("Network error during chunk upload"));
});
request.addEventListener("abort", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
if (uploadCancelled) {
reject(uploadControlError("UploadCancelledError", "Upload cancelled"));
} else if (uploadPaused) {
reject(uploadControlError("UploadPausedError", "Upload paused"));
} else {
reject(new Error("Chunk upload aborted"));
}
});
request.send(chunk);
});
}
@@ -928,16 +1166,26 @@
const delays = [1000, 2000, 5000, 10000, 20000];
let lastError = null;
for (let attempt = 0; attempt <= delays.length; attempt++) {
await waitForUploadReady();
try {
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
} catch (error) {
if (isUploadCancelledError(error)) {
throw error;
}
if (isUploadPausedError(error)) {
await waitForUploadReady();
await wait(150);
attempt -= 1;
continue;
}
lastError = error;
if (attempt >= delays.length) {
break;
}
const seconds = Math.round(delays[attempt] / 1000);
updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`);
await wait(delays[attempt]);
await waitForUploadDelay(delays[attempt]);
}
}
throw lastError || new Error("Chunk upload failed");
@@ -972,6 +1220,14 @@
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function waitForUploadDelay(ms) {
const deadline = performance.now() + ms;
while (performance.now() < deadline) {
await wait(Math.min(100, Math.max(0, deadline - performance.now())));
await waitForUploadReady();
}
}
async function readUploadJSON(response, fallback) {
let payload = {};
try {