feat(upload): add pause and cancel controls for active uploads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m3s
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:
@@ -48,6 +48,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-active-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.upload-active-actions[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.upload-options .form-footer .upload-new-button {
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user