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:
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +30,30 @@ func TestSetStaticCacheHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHomeIncludesActiveUploadControls(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Home(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="upload-active-actions"`,
|
||||||
|
`id="cancel-upload"`,
|
||||||
|
`id="pause-upload"`,
|
||||||
|
`Cancel Upload`,
|
||||||
|
`Pause Upload`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(response.Body.String(), want) {
|
||||||
|
t.Fatalf("home page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebManifestIncludesShareTarget(t *testing.T) {
|
func TestWebManifestIncludesShareTarget(t *testing.T) {
|
||||||
data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest"))
|
data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -48,6 +48,16 @@
|
|||||||
width: 100%;
|
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 {
|
.upload-options .form-footer .upload-new-button {
|
||||||
margin-top: -0.25rem;
|
margin-top: -0.25rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
const manageLink = document.querySelector("#manage-link");
|
const manageLink = document.querySelector("#manage-link");
|
||||||
const newUpload = document.querySelector("#new-upload");
|
const newUpload = document.querySelector("#new-upload");
|
||||||
const folderPicker = document.querySelector("[data-folder-picker]");
|
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 RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
||||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||||
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
||||||
@@ -52,6 +57,12 @@
|
|||||||
let recoveredDraft = null;
|
let recoveredDraft = null;
|
||||||
let resumeMode = false;
|
let resumeMode = false;
|
||||||
let sharedTargetDraft = null;
|
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 maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10);
|
||||||
const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit");
|
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();
|
const formData = uploadFormData();
|
||||||
await maybeRequestUploadNotificationPermission(selectedFiles);
|
await maybeRequestUploadNotificationPermission(selectedFiles);
|
||||||
if (resumeMode && recoveredDraft) {
|
if (resumeMode && recoveredDraft) {
|
||||||
@@ -153,7 +163,7 @@
|
|||||||
} else {
|
} else {
|
||||||
renderQueue(selectedFiles, "queued");
|
renderQueue(selectedFiles, "queued");
|
||||||
}
|
}
|
||||||
setLoading(true, submit);
|
beginUpload();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
||||||
@@ -175,14 +185,55 @@
|
|||||||
fileSummary.textContent = "Upload complete.";
|
fileSummary.textContent = "Upload complete.";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUploadCancelledError(error)) {
|
||||||
|
await discardActiveUploadSession();
|
||||||
|
await clearSharedTargetPayload();
|
||||||
|
form.reset();
|
||||||
|
resetFreshUploadState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateStatus(error.message || "Upload failed");
|
updateStatus(error.message || "Upload failed");
|
||||||
notifyUploadError(error);
|
notifyUploadError(error);
|
||||||
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
|
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
|
||||||
} finally {
|
} 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) {
|
if (copyURL) {
|
||||||
copyURL.addEventListener("click", () => {
|
copyURL.addEventListener("click", () => {
|
||||||
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
|
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
|
||||||
@@ -622,20 +673,147 @@
|
|||||||
newUpload.style.display = visible ? "" : "none";
|
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;
|
uploadLocked = isLoading;
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progress.hidden = !isLoading;
|
progress.hidden = !isLoading;
|
||||||
}
|
}
|
||||||
if (submit) {
|
idleUploadActions.forEach((button) => {
|
||||||
submit.disabled = isLoading;
|
button.style.display = isLoading ? "none" : "";
|
||||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
});
|
||||||
|
if (activeUploadActions) {
|
||||||
|
activeUploadActions.hidden = !isLoading;
|
||||||
|
}
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = isLoading;
|
||||||
|
submitButton.textContent = "Upload files";
|
||||||
}
|
}
|
||||||
if (newUpload) {
|
if (newUpload) {
|
||||||
newUpload.disabled = isLoading;
|
newUpload.disabled = isLoading;
|
||||||
}
|
}
|
||||||
|
if (cancelUpload) {
|
||||||
|
cancelUpload.disabled = !isLoading;
|
||||||
|
cancelUpload.textContent = "Cancel Upload";
|
||||||
|
}
|
||||||
|
updatePauseButton();
|
||||||
updateStatus(isLoading ? "Transferring files..." : "");
|
updateStatus(isLoading ? "Transferring files..." : "");
|
||||||
setTotalProgress(isLoading ? 0 : 100);
|
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) {
|
function updateStatus(message) {
|
||||||
@@ -709,8 +887,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uploadWithProgress(url, formData, files) {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = new XMLHttpRequest();
|
const request = new XMLHttpRequest();
|
||||||
|
activeUploadRequest = request;
|
||||||
const rateTracker = createTransferRateTracker(0);
|
const rateTracker = createTransferRateTracker(0);
|
||||||
request.open("POST", url);
|
request.open("POST", url);
|
||||||
request.setRequestHeader("Accept", "application/json");
|
request.setRequestHeader("Accept", "application/json");
|
||||||
@@ -728,6 +918,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
request.addEventListener("load", () => {
|
request.addEventListener("load", () => {
|
||||||
|
if (activeUploadRequest === request) {
|
||||||
|
activeUploadRequest = null;
|
||||||
|
}
|
||||||
let payload = {};
|
let payload = {};
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(request.responseText || "{}");
|
payload = JSON.parse(request.responseText || "{}");
|
||||||
@@ -744,19 +937,37 @@
|
|||||||
resolve(payload);
|
resolve(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
request.addEventListener("error", () => {
|
||||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
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);
|
request.send(formData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadResumable(fallbackUrl, formData, files) {
|
async function uploadResumable(fallbackUrl, formData, files) {
|
||||||
|
await waitForUploadReady();
|
||||||
if (!window.fetch || typeof Blob === "undefined") {
|
if (!window.fetch || typeof Blob === "undefined") {
|
||||||
return uploadWithProgress(fallbackUrl, formData, files);
|
return uploadWithProgress(fallbackUrl, formData, files);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus("Fingerprinting files...");
|
updateStatus("Fingerprinting files...");
|
||||||
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
|
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
|
||||||
|
await waitForUploadReady();
|
||||||
const createPayload = {
|
const createPayload = {
|
||||||
files: files.map((file, index) => ({
|
files: files.map((file, index) => ({
|
||||||
name: uploadName(file),
|
name: uploadName(file),
|
||||||
@@ -779,6 +990,7 @@
|
|||||||
session = await findResumableSession(createPayload);
|
session = await findResumableSession(createPayload);
|
||||||
}
|
}
|
||||||
if (session) {
|
if (session) {
|
||||||
|
activeUploadSession = session;
|
||||||
validateResumeSelection(session, createPayload);
|
validateResumeSelection(session, createPayload);
|
||||||
session = await addMissingResumableFiles(session, createPayload);
|
session = await addMissingResumableFiles(session, createPayload);
|
||||||
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
|
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
|
||||||
@@ -787,6 +999,7 @@
|
|||||||
if (persistable) {
|
if (persistable) {
|
||||||
saveResumableSession(session, createPayload);
|
saveResumableSession(session, createPayload);
|
||||||
}
|
}
|
||||||
|
activeUploadSession = session;
|
||||||
}
|
}
|
||||||
if (!session || session.status !== "uploading") {
|
if (!session || session.status !== "uploading") {
|
||||||
try {
|
try {
|
||||||
@@ -800,7 +1013,9 @@
|
|||||||
if (persistable) {
|
if (persistable) {
|
||||||
saveResumableSession(session, createPayload);
|
saveResumableSession(session, createPayload);
|
||||||
}
|
}
|
||||||
|
activeUploadSession = session;
|
||||||
}
|
}
|
||||||
|
await waitForUploadReady();
|
||||||
const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index]));
|
const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index]));
|
||||||
if (sessionFiles.some((file) => !file)) {
|
if (sessionFiles.some((file) => !file)) {
|
||||||
throw new Error("Upload session could not match the selected files");
|
throw new Error("Upload session could not match the selected files");
|
||||||
@@ -822,6 +1037,7 @@
|
|||||||
const sessionFile = sessionFiles[fileIndex];
|
const sessionFile = sessionFiles[fileIndex];
|
||||||
const uploaded = new Set(sessionFile.uploadedChunks || []);
|
const uploaded = new Set(sessionFile.uploadedChunks || []);
|
||||||
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
|
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
|
||||||
|
await waitForUploadReady();
|
||||||
if (uploaded.has(chunkIndex)) {
|
if (uploaded.has(chunkIndex)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -845,12 +1061,14 @@
|
|||||||
setSingleFileProgress(fileIndex, file, 100);
|
setSingleFileProgress(fileIndex, file, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus("Finalizing upload...");
|
await waitForUploadReady();
|
||||||
|
beginUploadFinalization();
|
||||||
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
|
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
|
||||||
const wasResumeMode = resumeMode;
|
const wasResumeMode = resumeMode;
|
||||||
if (persistable) {
|
if (persistable) {
|
||||||
removeResumableSession(session.sessionId);
|
removeResumableSession(session.sessionId);
|
||||||
}
|
}
|
||||||
|
activeUploadSession = null;
|
||||||
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
|
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
|
||||||
resumeMode = false;
|
resumeMode = false;
|
||||||
recoveredDraft = null;
|
recoveredDraft = null;
|
||||||
@@ -897,6 +1115,7 @@
|
|||||||
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
|
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = new XMLHttpRequest();
|
const request = new XMLHttpRequest();
|
||||||
|
activeUploadRequest = request;
|
||||||
request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`);
|
request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`);
|
||||||
request.setRequestHeader("Accept", "application/json");
|
request.setRequestHeader("Accept", "application/json");
|
||||||
request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || "");
|
request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || "");
|
||||||
@@ -906,6 +1125,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
request.addEventListener("load", () => {
|
request.addEventListener("load", () => {
|
||||||
|
if (activeUploadRequest === request) {
|
||||||
|
activeUploadRequest = null;
|
||||||
|
}
|
||||||
if (request.status < 200 || request.status >= 300) {
|
if (request.status < 200 || request.status >= 300) {
|
||||||
let payload = {};
|
let payload = {};
|
||||||
try {
|
try {
|
||||||
@@ -918,8 +1140,24 @@
|
|||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
request.addEventListener("error", () => reject(new Error("Network error during chunk upload")));
|
request.addEventListener("error", () => {
|
||||||
request.addEventListener("abort", () => reject(new Error("Chunk upload aborted")));
|
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);
|
request.send(chunk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -928,16 +1166,26 @@
|
|||||||
const delays = [1000, 2000, 5000, 10000, 20000];
|
const delays = [1000, 2000, 5000, 10000, 20000];
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
||||||
|
await waitForUploadReady();
|
||||||
try {
|
try {
|
||||||
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
|
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUploadCancelledError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (isUploadPausedError(error)) {
|
||||||
|
await waitForUploadReady();
|
||||||
|
await wait(150);
|
||||||
|
attempt -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt >= delays.length) {
|
if (attempt >= delays.length) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const seconds = Math.round(delays[attempt] / 1000);
|
const seconds = Math.round(delays[attempt] / 1000);
|
||||||
updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`);
|
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");
|
throw lastError || new Error("Chunk upload failed");
|
||||||
@@ -972,6 +1220,14 @@
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
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) {
|
async function readUploadJSON(response, fallback) {
|
||||||
let payload = {};
|
let payload = {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -76,10 +76,14 @@
|
|||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="file-summary">Choose one or more files to begin.</p>
|
<p id="file-summary">Choose one or more files to begin.</p>
|
||||||
<button class="button button-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button>
|
<button class="button button-outline install-pwa-button upload-idle-action" type="button" data-install-pwa hidden>Install Warpbox</button>
|
||||||
<button class="button button-outline folder-picker-button" type="button" data-folder-picker hidden>Choose folder</button>
|
<button class="button button-outline folder-picker-button upload-idle-action" type="button" data-folder-picker hidden>Choose folder</button>
|
||||||
<button class="button button-primary" type="submit">Upload files</button>
|
<button class="button button-primary upload-idle-action" type="submit">Upload files</button>
|
||||||
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
|
<button class="button button-danger upload-new-button upload-idle-action" type="button" id="new-upload" hidden>New upload</button>
|
||||||
|
<div class="upload-active-actions" id="upload-active-actions" hidden>
|
||||||
|
<button class="button button-danger" type="button" id="cancel-upload">Cancel Upload</button>
|
||||||
|
<button class="button button-outline" type="button" id="pause-upload">Pause Upload</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user