Files
warpbox-dev/backend/static/js/40-upload.js
Daniel Legt f698ba516d
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m47s
feat(upload): add transfer rate tracking and 6-hour expiry option
- Implement real-time transfer rate tracking and display upload speed (e.g., Mb/s) in the progress status.
- Add a 6-hour (360 minutes) option to the upload expiry selection ladder.
- Fix an issue where the "new upload" button remained visible by explicitly toggling its display style and adding a CSS fallback for the `hidden` attribute.
2026-06-02 22:41:59 +03:00

1053 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const form = document.querySelector("#upload-form");
const dropZone = document.querySelector(".drop-zone");
const fileInput = document.querySelector("#file-input");
const fileSummary = document.querySelector("#file-summary");
const progress = document.querySelector("#upload-progress");
const uploadStatus = document.querySelector("#upload-status");
const result = document.querySelector("#upload-result");
const resultMeta = document.querySelector("#result-meta");
const resultList = document.querySelector("#result-list");
const uploadQueue = document.querySelector("#upload-queue");
const totalProgressBar = document.querySelector("#total-progress-bar");
const copyURL = document.querySelector("#copy-url");
const openBox = document.querySelector("#open-box");
const manageLink = document.querySelector("#manage-link");
const newUpload = document.querySelector("#new-upload");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
if (!form || !dropZone || !fileInput) {
return;
}
// Remember the last-chosen expiry across uploads (per browser).
const expirySelect = form.querySelector("[data-expiry-select]");
if (expirySelect) {
const EXPIRY_KEY = "warpbox-expiry";
let saved = null;
try {
saved = localStorage.getItem(EXPIRY_KEY);
} catch (e) {
saved = null;
}
if (saved && expirySelect.querySelector('option[value="' + saved + '"]')) {
expirySelect.value = saved;
}
expirySelect.addEventListener("change", () => {
try {
localStorage.setItem(EXPIRY_KEY, expirySelect.value);
} catch (e) {
/* ignore persistence failures */
}
});
}
let latestBoxURL = "";
let selectedFiles = [];
let uploadLocked = false;
let recoveredDraft = null;
let resumeMode = false;
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragging");
});
});
document.addEventListener("dragover", (event) => {
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
event.preventDefault();
}
});
document.addEventListener("drop", (event) => {
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
event.preventDefault();
if (!dropZone.contains(event.target)) {
addSelectedFiles(event.dataTransfer.files);
}
});
dropZone.addEventListener("drop", (event) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
addSelectedFiles(event.dataTransfer.files);
}
});
fileInput.addEventListener("change", () => {
addSelectedFiles(fileInput.files);
fileInput.value = "";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (selectedFiles.length === 0) {
updateStatus("Choose at least one file first.");
return;
}
const submit = form.querySelector("button[type='submit']");
const formData = uploadFormData();
if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles);
} else {
renderQueue(selectedFiles, "queued");
}
setLoading(true, submit);
try {
const payload = await uploadResumable(form.action, formData, selectedFiles);
renderResult(payload);
form.reset();
selectedFiles = [];
resumeMode = false;
recoveredDraft = null;
fileInput.value = "";
if (uploadQueue) {
uploadQueue.hidden = true;
uploadQueue.replaceChildren();
}
updateNewUploadVisibility();
if (fileSummary) {
fileSummary.textContent = "Upload complete.";
}
} catch (error) {
updateStatus(error.message || "Upload failed");
} finally {
setLoading(false, submit);
}
});
if (copyURL) {
copyURL.addEventListener("click", () => {
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
});
}
if (newUpload) {
newUpload.addEventListener("click", () => {
cancelRecoveredDraft().catch((error) => {
updateStatus(error.message || "Upload draft could not be deleted");
});
});
}
recoverResumableSessions();
function addSelectedFiles(files) {
if (uploadLocked) {
return;
}
Array.from(files || []).forEach((file) => {
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
selectedFiles.push(file);
}
});
updateSelectedState();
}
function removeSelectedFile(index) {
if (uploadLocked) {
return;
}
selectedFiles.splice(index, 1);
updateSelectedState();
}
function updateSelectedState() {
const count = selectedFiles.length || 0;
const title = dropZone.querySelector(".drop-title");
if (title) {
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
}
if (fileSummary) {
if (resumeMode && recoveredDraft) {
fileSummary.textContent = count === 0
? "Reselect missing files to resume, or add extra files to this upload."
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`;
} else {
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
}
}
if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles);
} else if (count > 0) {
renderQueue(selectedFiles, "queued");
} else if (uploadQueue) {
uploadQueue.hidden = true;
uploadQueue.replaceChildren();
}
updateNewUploadVisibility();
}
function updateNewUploadVisibility() {
if (!newUpload) {
return;
}
const visible = Boolean(resumeMode && recoveredDraft);
newUpload.hidden = !visible;
newUpload.style.display = visible ? "" : "none";
}
function setLoading(isLoading, submit) {
uploadLocked = isLoading;
if (progress) {
progress.hidden = !isLoading;
}
if (submit) {
submit.disabled = isLoading;
submit.textContent = isLoading ? "Uploading..." : "Upload files";
}
if (newUpload) {
newUpload.disabled = isLoading;
}
updateStatus(isLoading ? "Transferring files..." : "");
setTotalProgress(isLoading ? 0 : 100);
}
function updateStatus(message) {
if (uploadStatus) {
uploadStatus.textContent = message;
}
}
function updateUploadProgress(percent, bytesPerSecond) {
const clamped = Math.max(0, Math.min(100, Math.round(percent || 0)));
const rate = formatTransferRate(bytesPerSecond);
updateStatus(rate ? `${clamped}% · ${rate}` : `${clamped}%`);
}
function createTransferRateTracker(initialBytes) {
const startedAt = performance.now();
const baseline = Math.max(0, initialBytes || 0);
let lastRate = 0;
return function track(currentBytes) {
const elapsedSeconds = (performance.now() - startedAt) / 1000;
const transferred = Math.max(0, (currentBytes || 0) - baseline);
if (elapsedSeconds < 0.25 || transferred <= 0) {
return lastRate;
}
lastRate = transferred / elapsedSeconds;
return lastRate;
};
}
function formatTransferRate(bytesPerSecond) {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) {
return "";
}
const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"];
let value = bytesPerSecond * 8;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
}
function renderResult(payload) {
if (!result || !resultList || !resultMeta || !openBox) {
return;
}
latestBoxURL = window.Warpbox.absoluteURL(payload.boxUrl);
result.hidden = false;
openBox.href = latestBoxURL;
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
if (manageLink) {
const anchor = manageLink.querySelector("a");
manageLink.hidden = !payload.manageUrl;
if (anchor && payload.manageUrl) {
anchor.href = window.Warpbox.absoluteURL(payload.manageUrl);
}
}
resultList.replaceChildren();
payload.files.forEach((file) => {
resultList.append(createFileRow({
name: file.name,
meta: `${file.size} · ${window.Warpbox.absoluteURL(file.url)}`,
progress: 100,
status: "complete",
}));
});
result.scrollIntoView({ behavior: "smooth", block: "start" });
}
function uploadWithProgress(url, formData, files) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
const rateTracker = createTransferRateTracker(0);
request.open("POST", url);
request.setRequestHeader("Accept", "application/json");
request.upload.addEventListener("progress", (event) => {
const rate = rateTracker(event.loaded || 0);
if (!event.lengthComputable) {
updateStatus(rate > 0 ? `Uploading · ${formatTransferRate(rate)}` : "Uploading...");
return;
}
const percent = Math.round((event.loaded / event.total) * 100);
updateUploadProgress(percent, rate);
setTotalProgress(percent);
setFileProgress(files, percent);
});
request.addEventListener("load", () => {
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
} catch (error) {
reject(new Error("Upload response could not be read"));
return;
}
if (request.status < 200 || request.status >= 300) {
reject(new Error(payload.error || "Upload failed"));
return;
}
setTotalProgress(100);
setFileProgress(files, 100);
resolve(payload);
});
request.addEventListener("error", () => reject(new Error("Network error during upload")));
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
request.send(formData);
});
}
async function uploadResumable(fallbackUrl, formData, files) {
if (!window.fetch || typeof Blob === "undefined") {
return uploadWithProgress(fallbackUrl, formData, files);
}
updateStatus("Fingerprinting files...");
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
const createPayload = {
files: files.map((file, index) => ({
name: file.name,
size: file.size,
contentType: file.type || "application/octet-stream",
fingerprint: fingerprints[index],
})),
expiresMinutes: parseInt(formData.get("expires_minutes") || "0", 10) || 0,
maxDownloads: parseInt(formData.get("max_downloads") || "0", 10) || 0,
password: formData.get("password") || "",
obfuscateMetadata: formData.get("obfuscate_metadata") === "on",
collectionId: formData.get("collection_id") || "",
};
const persistable = !createPayload.password;
let session = null;
if (persistable && resumeMode && recoveredDraft) {
session = await fetchResumableStatus(recoveredDraft.session.sessionId, recoveredDraft.session.resumeToken);
session.resumeToken = recoveredDraft.session.resumeToken;
} else if (persistable) {
session = await findResumableSession(createPayload);
}
if (session) {
validateResumeSelection(session, createPayload);
session = await addMissingResumableFiles(session, createPayload);
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
recoveredDraft.session = session;
}
if (persistable) {
saveResumableSession(session, createPayload);
}
}
if (!session || session.status !== "uploading") {
try {
session = await createResumableSession(createPayload);
} catch (error) {
if ((error.message || "").toLowerCase().includes("resumable uploads are disabled")) {
return uploadWithProgress(fallbackUrl, formData, files);
}
throw error;
}
if (persistable) {
saveResumableSession(session, createPayload);
}
}
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");
}
updateStatus("Uploading...");
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
const completedByFile = new Array(files.length).fill(0);
sessionFiles.forEach((sessionFile, index) => {
completedByFile[index] = uploadedBytesForSessionFile(sessionFile, session.chunkSize);
setSingleFileProgress(index, files[index], percentForBytes(completedByFile[index], files[index].size));
});
const initiallyUploadedBytes = completedByFile.reduce((sum, bytes) => sum + bytes, 0);
const rateTracker = createTransferRateTracker(initiallyUploadedBytes);
setTotalProgress(percentForBytes(initiallyUploadedBytes, totalBytes));
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
const file = files[fileIndex];
const sessionFile = sessionFiles[fileIndex];
const uploaded = new Set(sessionFile.uploadedChunks || []);
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
if (uploaded.has(chunkIndex)) {
continue;
}
const start = chunkIndex * session.chunkSize;
const end = Math.min(file.size, start + session.chunkSize);
await uploadChunkWithRetry(session, sessionFile, chunkIndex, file.slice(start, end), (loaded) => {
const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded;
const percent = percentForBytes(currentTotal, totalBytes);
const rate = rateTracker(currentTotal);
setTotalProgress(percent);
setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size));
updateUploadProgress(percent, rate);
});
completedByFile[fileIndex] += end - start;
uploaded.add(chunkIndex);
sessionFile.uploadedChunks = Array.from(uploaded).sort((a, b) => a - b);
if (persistable) {
saveResumableSession(session, createPayload);
}
}
setSingleFileProgress(fileIndex, file, 100);
}
updateStatus("Finalizing upload...");
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
const wasResumeMode = resumeMode;
if (persistable) {
removeResumableSession(session.sessionId);
}
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
resumeMode = false;
recoveredDraft = null;
}
setTotalProgress(100);
if (!wasResumeMode) {
setFileProgress(files, 100);
}
return resultPayload;
}
async function createResumableSession(payload) {
const response = await fetch("/api/v1/uploads/resumable", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
return readUploadJSON(response, "Upload session could not be created");
}
async function fetchResumableStatus(sessionID, resumeToken) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, {
headers: resumableHeaders(resumeToken),
});
return readUploadJSON(response, "Upload session could not be resumed");
}
async function addResumableFiles(sessionID, resumeToken, files) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files`, {
method: "POST",
headers: {
...resumableHeaders(resumeToken),
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ files }),
});
return readUploadJSON(response, "Upload session files could not be added");
}
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
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 || "");
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded);
}
});
request.addEventListener("load", () => {
if (request.status < 200 || request.status >= 300) {
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
} catch (error) {
payload = {};
}
reject(new Error(payload.error || "Chunk upload failed"));
return;
}
resolve();
});
request.addEventListener("error", () => reject(new Error("Network error during chunk upload")));
request.addEventListener("abort", () => reject(new Error("Chunk upload aborted")));
request.send(chunk);
});
}
async function uploadChunkWithRetry(session, sessionFile, chunkIndex, chunk, onProgress) {
const delays = [1000, 2000, 5000, 10000, 20000];
let lastError = null;
for (let attempt = 0; attempt <= delays.length; attempt++) {
try {
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
} catch (error) {
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]);
}
}
throw lastError || new Error("Chunk upload failed");
}
async function completeResumableSession(sessionID, resumeToken) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/complete`, {
method: "POST",
headers: resumableHeaders(resumeToken),
});
return readUploadJSON(response, "Upload could not be completed");
}
async function cancelResumableSession(sessionID, resumeToken) {
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, {
method: "DELETE",
headers: resumableHeaders(resumeToken),
});
if (!response.ok && response.status !== 404) {
await readUploadJSON(response, "Upload draft could not be deleted");
}
}
function resumableHeaders(resumeToken) {
return {
"Accept": "application/json",
"X-Warpbox-Resume-Token": resumeToken || "",
};
}
function wait(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function readUploadJSON(response, fallback) {
let payload = {};
try {
payload = await response.json();
} catch (error) {
payload = {};
}
if (!response.ok) {
throw new Error(payload.error || fallback);
}
return payload;
}
async function findResumableSession(payload) {
const records = loadResumableSessions();
const optionKey = resumableOptionKey(payload);
const selectedKeys = new Set(payload.files.map((file) => resumableFileKey(file)));
for (const record of records) {
if (record.optionKey !== optionKey) {
continue;
}
if (!record.files || !record.files.some((file) => selectedKeys.has(resumableFileKey(file)))) {
continue;
}
const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null);
if (!session || session.status !== "uploading") {
removeResumableSession(record.sessionId);
continue;
}
session.resumeToken = record.resumeToken;
const sessionKeys = new Set(session.files.map((file) => resumableFileKey(file)));
const selectedContainsSessionFile = Array.from(sessionKeys).some((key) => selectedKeys.has(key));
if (selectedContainsSessionFile) {
return session;
}
}
return null;
}
async function addMissingResumableFiles(session, payload) {
const existing = new Set(session.files.map((file) => resumableFileKey(file)));
const missing = payload.files.filter((file) => !existing.has(resumableFileKey(file)));
if (missing.length === 0) {
return session;
}
const updated = await addResumableFiles(session.sessionId, session.resumeToken, missing);
updated.resumeToken = session.resumeToken;
return updated;
}
function validateResumeSelection(session, payload) {
if (!resumeMode || !recoveredDraft || session.sessionId !== recoveredDraft.session.sessionId) {
return;
}
const existingByNameSize = new Map();
(session.files || []).forEach((file) => {
existingByNameSize.set(`${file.name}:${file.size}`, resumableFileKey(file));
});
for (const file of payload.files || []) {
const expectedKey = existingByNameSize.get(`${file.name}:${file.size}`);
if (expectedKey && expectedKey !== resumableFileKey(file)) {
throw new Error(`"${file.name}" does not match the pending upload. Select the exact original file.`);
}
}
}
function matchSessionFile(session, file) {
const key = resumableFileKey(file);
return session.files.find((sessionFile) => resumableFileKey(sessionFile) === key) || null;
}
function resumableOptionKey(payload) {
return [
payload.expiresMinutes,
payload.maxDownloads,
payload.obfuscateMetadata ? "1" : "0",
payload.collectionId || "",
].join(":");
}
function resumableFileKey(file) {
return [file.name, file.size, file.fingerprint || ""].join(":");
}
function loadResumableSessions() {
try {
const value = localStorage.getItem(RESUMABLE_SESSIONS_KEY);
const records = value ? JSON.parse(value) : [];
return Array.isArray(records) ? records : [];
} catch (error) {
return [];
}
}
function saveResumableSession(session, payload) {
try {
const records = loadResumableSessions().filter((record) => record.sessionId !== session.sessionId);
records.push({
sessionId: session.sessionId,
resumeToken: session.resumeToken || "",
optionKey: resumableOptionKey(payload),
options: {
expiresMinutes: payload.expiresMinutes,
maxDownloads: payload.maxDownloads,
obfuscateMetadata: !!payload.obfuscateMetadata,
collectionId: payload.collectionId || "",
},
files: session.files.map((file) => ({
name: file.name,
size: file.size,
contentType: file.contentType || "application/octet-stream",
fingerprint: file.fingerprint || "",
uploadedChunks: file.uploadedChunks || [],
chunkCount: file.chunkCount || 0,
})),
updatedAt: new Date().toISOString(),
});
localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records.slice(-25)));
} catch (error) {
/* ignore persistence failures */
}
}
async function recoverResumableSessions() {
const records = loadResumableSessions()
.filter((record) => record.sessionId && record.resumeToken)
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
if (records.length === 0) {
return;
}
for (const record of records) {
const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null);
if (!session || session.status !== "uploading") {
removeResumableSession(record.sessionId);
continue;
}
session.resumeToken = record.resumeToken;
recoveredDraft = { session, record };
selectedFiles = [];
renderRecoveredQueue([{ session, record }]);
updateRecoveredSummary(session);
showRecoveryModal(recoveredDraft);
return;
}
}
function updateRecoveredSummary(session) {
updateStatus("Unfinished upload found. Choose how to continue.");
if (fileSummary) {
const totalFiles = (session.files || []).length;
const completedFiles = completedSessionFiles(session).length;
fileSummary.textContent = `Recovered ${totalFiles} pending file${totalFiles === 1 ? "" : "s"}; ${completedFiles} fully uploaded.`;
}
}
function removeResumableSession(sessionID) {
try {
const records = loadResumableSessions().filter((record) => record.sessionId !== sessionID);
localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records));
} catch (error) {
/* ignore persistence failures */
}
}
function completedSessionFiles(session) {
return (session.files || []).filter((file) => (file.uploadedChunks || []).length >= file.chunkCount);
}
function showRecoveryModal(draft) {
const old = document.querySelector(".upload-recovery-overlay");
if (old) {
old.remove();
}
const completeCount = completedSessionFiles(draft.session).length;
const totalCount = (draft.session.files || []).length;
const overlay = document.createElement("div");
overlay.className = "upload-recovery-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "upload-recovery-title");
const modal = document.createElement("div");
modal.className = "upload-recovery-modal card";
const content = document.createElement("div");
content.className = "card-content";
const title = document.createElement("h2");
title.id = "upload-recovery-title";
title.textContent = "Unfinished upload found";
const copy = document.createElement("p");
copy.textContent = `Warpbox found a private draft with ${totalCount} file${totalCount === 1 ? "" : "s"}. ${completeCount} file${completeCount === 1 ? " is" : "s are"} already fully uploaded.`;
const actions = document.createElement("div");
actions.className = "upload-recovery-actions";
const startOver = document.createElement("button");
startOver.type = "button";
startOver.className = "button button-danger";
startOver.textContent = "New Upload";
startOver.addEventListener("click", async () => {
startOver.disabled = true;
try {
await cancelRecoveredDraft();
overlay.remove();
} catch (error) {
startOver.disabled = false;
updateStatus(error.message || "Upload draft could not be deleted");
}
});
const resume = document.createElement("button");
resume.type = "button";
resume.className = "button button-primary";
resume.textContent = "Resume";
resume.addEventListener("click", () => {
resumeRecoveredDraft();
overlay.remove();
});
actions.append(startOver, resume);
content.append(title, copy, actions);
modal.append(content);
overlay.append(modal);
document.body.append(overlay);
}
async function cancelRecoveredDraft() {
if (!recoveredDraft) {
resetFreshUploadState();
return;
}
const draft = recoveredDraft;
updateStatus("Deleting unfinished upload...");
await cancelResumableSession(draft.session.sessionId, draft.session.resumeToken);
removeResumableSession(draft.session.sessionId);
resetFreshUploadState();
}
function resumeRecoveredDraft() {
if (!recoveredDraft) {
return;
}
resumeMode = true;
selectedFiles = [];
renderResumeQueue(recoveredDraft.session, selectedFiles);
updateSelectedState();
updateNewUploadVisibility();
updateStatus("Drop or reselect missing files to continue. Extra files will be added to this upload.");
}
function resetFreshUploadState() {
selectedFiles = [];
resumeMode = false;
recoveredDraft = null;
fileInput.value = "";
result.hidden = true;
if (resultList) {
resultList.replaceChildren();
}
setTotalProgress(0);
updateStatus("");
updateSelectedState();
}
function uploadedBytesForSessionFile(file, chunkSize) {
return (file.uploadedChunks || []).reduce((sum, index) => {
const start = index * chunkSize;
const end = Math.min(file.size, start + chunkSize);
return sum + Math.max(0, end - start);
}, 0);
}
function renderRecoveredQueue(items) {
if (!uploadQueue) {
return;
}
const rows = [];
items.forEach(({ session }) => {
(session.files || []).forEach((file) => {
const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize);
const complete = (file.uploadedChunks || []).length >= file.chunkCount;
rows.push({
name: file.name,
size: file.size,
uploadedBytes,
meta: complete
? `${window.Warpbox.formatBytes(file.size)} · uploaded`
: `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · Drop/reselect this file to continue`,
progress: percentForBytes(uploadedBytes, file.size),
status: complete ? "complete" : "waiting",
readonly: true,
});
});
});
uploadQueue.hidden = rows.length === 0;
uploadQueue.replaceChildren();
rows.forEach((row) => uploadQueue.append(createFileRow(row)));
const totalBytes = rows.reduce((sum, row) => sum + (row.size || 0), 0);
if (totalBytes > 0) {
setTotalProgress(percentForBytes(rows.reduce((sum, row) => sum + (row.uploadedBytes || 0), 0), totalBytes));
} else if (rows.length > 0) {
const completed = rows.filter((row) => row.status === "complete").length;
setTotalProgress(percentForBytes(completed, rows.length));
}
}
function renderResumeQueue(session, localFiles) {
if (!uploadQueue) {
return;
}
const rows = [];
const localByNameSize = new Map();
(localFiles || []).forEach((file, index) => {
localByNameSize.set(`${file.name}:${file.size}`, { file, index });
});
const usedLocalIndexes = new Set();
(session.files || []).forEach((file) => {
const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize);
const complete = (file.uploadedChunks || []).length >= file.chunkCount;
const localMatch = localByNameSize.get(`${file.name}:${file.size}`) || null;
if (localMatch) {
usedLocalIndexes.add(localMatch.index);
}
rows.push({
name: file.name,
size: file.size,
uploadedBytes,
meta: complete
? `${window.Warpbox.formatBytes(file.size)} · uploaded`
: localMatch
? `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · ready to resume`
: `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · waiting for local file`,
progress: percentForBytes(uploadedBytes, file.size),
status: complete ? "complete" : localMatch ? "queued" : "waiting",
readonly: !localMatch,
index: localMatch ? localMatch.index : undefined,
removable: Boolean(localMatch && !complete),
});
});
(localFiles || []).forEach((file, index) => {
if (usedLocalIndexes.has(index)) {
return;
}
rows.push({
name: file.name,
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
progress: 0,
status: "queued",
index,
removable: true,
});
});
uploadQueue.hidden = rows.length === 0;
uploadQueue.replaceChildren();
rows.forEach((row) => uploadQueue.append(createFileRow(row)));
}
function percentForBytes(bytes, total) {
if (!total) {
return 100;
}
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
}
function renderQueue(files, status) {
if (!uploadQueue) {
return;
}
uploadQueue.hidden = files.length === 0;
uploadQueue.replaceChildren();
files.forEach((file, index) => {
uploadQueue.append(createFileRow({
name: file.name,
meta: window.Warpbox.formatBytes(file.size),
progress: status === "queued" ? 0 : 100,
status,
index,
removable: status === "queued",
}));
});
}
function createFileRow(file) {
const row = document.createElement("div");
row.className = `result-item upload-file-row upload-file-${file.status || "queued"}`;
row.dataset.fileName = file.name;
if (typeof file.index === "number") {
row.dataset.fileIndex = file.index;
}
const body = document.createElement("span");
const name = document.createElement("strong");
name.className = "file-name";
name.textContent = file.name;
name.title = file.name;
const meta = document.createElement("code");
meta.textContent = file.meta;
body.append(name, meta);
const side = document.createElement("div");
side.className = "file-progress-side";
const percent = document.createElement("span");
percent.className = "file-progress-percent";
percent.textContent = `${file.progress}%`;
const bar = document.createElement("div");
bar.className = "progress file-progress";
const fill = document.createElement("span");
fill.style.transform = `scaleX(${file.progress / 100})`;
bar.append(fill);
side.append(percent, bar);
if (file.status === "waiting") {
const badge = document.createElement("small");
badge.className = "upload-file-state";
badge.textContent = "Needs local file";
side.append(badge);
}
if (file.removable) {
const remove = document.createElement("button");
remove.className = "upload-file-remove";
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${file.name}`);
remove.textContent = "×";
remove.addEventListener("click", () => removeSelectedFile(file.index || 0));
side.append(remove);
}
row.append(body, side);
return row;
}
function uploadFormData() {
const formData = new FormData(form);
formData.delete("file");
selectedFiles.forEach((file) => {
formData.append("file", file, file.name);
});
return formData;
}
function fileIdentity(file) {
return [file.name, file.size, file.lastModified || 0].join(":");
}
async function fileFingerprint(file) {
if (!window.crypto || !window.crypto.subtle || !file.slice || typeof TextEncoder === "undefined") {
return fileIdentity(file);
}
const sampleSize = Math.min(file.size, 1024 * 1024);
const sample = await file.slice(0, sampleSize).arrayBuffer();
const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":"));
const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
combined.set(metadata, 0);
combined.set(new Uint8Array(sample), metadata.byteLength);
const digest = await window.crypto.subtle.digest("SHA-256", combined);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function setTotalProgress(percent) {
if (totalProgressBar) {
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
}
}
function setFileProgress(files, totalPercent) {
if (!uploadQueue) {
return;
}
const count = files.length || 1;
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
const percent = row.querySelector(".file-progress-percent");
const fill = row.querySelector(".file-progress span");
if (percent) {
percent.textContent = `${progress}%`;
}
if (fill) {
fill.style.transform = `scaleX(${progress / 100})`;
}
});
}
function setSingleFileProgress(index, file, progress) {
if (!uploadQueue) {
return;
}
const row = uploadQueue.querySelector(`.upload-file-row[data-file-index="${index}"]`);
if (!row) {
return;
}
const percent = row.querySelector(".file-progress-percent");
const fill = row.querySelector(".file-progress span");
const normalized = Math.max(0, Math.min(100, progress));
if (percent) {
percent.textContent = `${normalized}%`;
}
if (fill) {
fill.style.transform = `scaleX(${normalized / 100})`;
}
}
})();