2026-05-31 13:02:58 +03:00
( 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" ) ;
2026-06-02 22:13:54 +03:00
const newUpload = document . querySelector ( "#new-upload" ) ;
2026-06-02 17:41:41 +03:00
const RESUMABLE _SESSIONS _KEY = "warpbox-resumable-sessions" ;
2026-05-31 13:02:58 +03:00
if ( ! form || ! dropZone || ! fileInput ) {
return ;
}
2026-05-31 15:30:53 +03:00
// 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 */
}
} ) ;
}
2026-05-31 13:02:58 +03:00
let latestBoxURL = "" ;
let selectedFiles = [ ] ;
2026-06-02 17:41:41 +03:00
let uploadLocked = false ;
2026-06-02 22:13:54 +03:00
let recoveredDraft = null ;
let resumeMode = false ;
2026-05-31 13:02:58 +03:00
[ "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" ) ;
} ) ;
} ) ;
2026-06-02 17:41:41 +03:00
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 ) ;
}
} ) ;
2026-05-31 13:02:58 +03:00
dropZone . addEventListener ( "drop" , ( event ) => {
if ( event . dataTransfer && event . dataTransfer . files . length > 0 ) {
2026-06-02 17:41:41 +03:00
addSelectedFiles ( event . dataTransfer . files ) ;
2026-05-31 13:02:58 +03:00
}
} ) ;
2026-06-02 17:41:41 +03:00
fileInput . addEventListener ( "change" , ( ) => {
addSelectedFiles ( fileInput . files ) ;
fileInput . value = "" ;
} ) ;
2026-05-31 13:02:58 +03:00
form . addEventListener ( "submit" , async ( event ) => {
event . preventDefault ( ) ;
2026-06-02 17:41:41 +03:00
if ( selectedFiles . length === 0 ) {
2026-05-31 13:02:58 +03:00
updateStatus ( "Choose at least one file first." ) ;
return ;
}
const submit = form . querySelector ( "button[type='submit']" ) ;
2026-06-02 17:41:41 +03:00
const formData = uploadFormData ( ) ;
2026-06-02 22:13:54 +03:00
if ( resumeMode && recoveredDraft ) {
renderResumeQueue ( recoveredDraft . session , selectedFiles ) ;
} else {
renderQueue ( selectedFiles , "queued" ) ;
}
2026-05-31 13:02:58 +03:00
setLoading ( true , submit ) ;
try {
2026-06-02 17:41:41 +03:00
const payload = await uploadResumable ( form . action , formData , selectedFiles ) ;
2026-05-31 13:02:58 +03:00
renderResult ( payload ) ;
form . reset ( ) ;
2026-06-02 17:41:41 +03:00
selectedFiles = [ ] ;
2026-06-02 22:13:54 +03:00
resumeMode = false ;
recoveredDraft = null ;
2026-06-02 17:41:41 +03:00
fileInput . value = "" ;
2026-06-02 22:13:54 +03:00
if ( uploadQueue ) {
uploadQueue . hidden = true ;
uploadQueue . replaceChildren ( ) ;
}
if ( newUpload ) {
newUpload . hidden = true ;
}
if ( fileSummary ) {
fileSummary . textContent = "Upload complete." ;
}
2026-05-31 13:02:58 +03:00
} catch ( error ) {
updateStatus ( error . message || "Upload failed" ) ;
} finally {
setLoading ( false , submit ) ;
}
} ) ;
if ( copyURL ) {
copyURL . addEventListener ( "click" , ( ) => {
window . Warpbox . copyText ( latestBoxURL , copyURL , "Copied" ) ;
} ) ;
}
2026-06-02 22:13:54 +03:00
if ( newUpload ) {
newUpload . addEventListener ( "click" , ( ) => {
cancelRecoveredDraft ( ) . catch ( ( error ) => {
updateStatus ( error . message || "Upload draft could not be deleted" ) ;
} ) ;
} ) ;
}
recoverResumableSessions ( ) ;
2026-06-02 17:41:41 +03:00
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 ( ) {
2026-05-31 13:02:58 +03:00
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 ) {
2026-06-02 22:13:54 +03:00
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. ` ;
}
2026-05-31 13:02:58 +03:00
}
2026-06-02 22:13:54 +03:00
if ( resumeMode && recoveredDraft ) {
renderResumeQueue ( recoveredDraft . session , selectedFiles ) ;
} else if ( count > 0 ) {
2026-05-31 13:02:58 +03:00
renderQueue ( selectedFiles , "queued" ) ;
} else if ( uploadQueue ) {
uploadQueue . hidden = true ;
uploadQueue . replaceChildren ( ) ;
}
2026-06-02 22:13:54 +03:00
if ( newUpload ) {
newUpload . hidden = ! ( resumeMode && recoveredDraft ) ;
}
2026-05-31 13:02:58 +03:00
}
function setLoading ( isLoading , submit ) {
2026-06-02 17:41:41 +03:00
uploadLocked = isLoading ;
2026-05-31 13:02:58 +03:00
if ( progress ) {
progress . hidden = ! isLoading ;
}
if ( submit ) {
submit . disabled = isLoading ;
submit . textContent = isLoading ? "Uploading..." : "Upload files" ;
}
2026-06-02 22:13:54 +03:00
if ( newUpload ) {
newUpload . disabled = isLoading ;
}
2026-05-31 13:02:58 +03:00
updateStatus ( isLoading ? "Transferring files..." : "" ) ;
setTotalProgress ( isLoading ? 0 : 100 ) ;
}
function updateStatus ( message ) {
if ( uploadStatus ) {
uploadStatus . textContent = message ;
}
}
function renderResult ( payload ) {
if ( ! result || ! resultList || ! resultMeta || ! openBox ) {
return ;
}
2026-06-02 22:13:54 +03:00
latestBoxURL = window . Warpbox . absoluteURL ( payload . boxUrl ) ;
2026-05-31 13:02:58 +03:00
result . hidden = false ;
2026-06-02 22:13:54 +03:00
openBox . href = latestBoxURL ;
2026-05-31 13:02:58 +03:00
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 ) {
2026-06-02 22:13:54 +03:00
anchor . href = window . Warpbox . absoluteURL ( payload . manageUrl ) ;
2026-05-31 13:02:58 +03:00
}
}
resultList . replaceChildren ( ) ;
payload . files . forEach ( ( file ) => {
resultList . append ( createFileRow ( {
name : file . name ,
2026-06-02 22:13:54 +03:00
meta : ` ${ file . size } · ${ window . Warpbox . absoluteURL ( file . url ) } ` ,
2026-05-31 13:02:58 +03:00
progress : 100 ,
status : "complete" ,
} ) ) ;
} ) ;
2026-06-02 22:13:54 +03:00
result . scrollIntoView ( { behavior : "smooth" , block : "start" } ) ;
2026-05-31 13:02:58 +03:00
}
function uploadWithProgress ( url , formData , files ) {
return new Promise ( ( resolve , reject ) => {
const request = new XMLHttpRequest ( ) ;
request . open ( "POST" , url ) ;
request . setRequestHeader ( "Accept" , "application/json" ) ;
request . upload . addEventListener ( "progress" , ( event ) => {
if ( ! event . lengthComputable ) {
updateStatus ( "Uploading..." ) ;
return ;
}
const percent = Math . round ( ( event . loaded / event . total ) * 100 ) ;
updateStatus ( ` ${ percent } % ` ) ;
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 ) ;
} ) ;
}
2026-06-02 17:41:41 +03:00
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 ;
2026-06-02 22:13:54 +03:00
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 ) ;
}
2026-06-02 17:41:41 +03:00
if ( session ) {
2026-06-02 22:13:54 +03:00
validateResumeSelection ( session , createPayload ) ;
2026-06-02 17:41:41 +03:00
session = await addMissingResumableFiles ( session , createPayload ) ;
2026-06-02 22:13:54 +03:00
if ( resumeMode && recoveredDraft && recoveredDraft . session . sessionId === session . sessionId ) {
recoveredDraft . session = session ;
}
if ( persistable ) {
saveResumableSession ( session , createPayload ) ;
}
2026-06-02 17:41:41 +03:00
}
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 ) ) ;
} ) ;
setTotalProgress ( percentForBytes ( completedByFile . reduce ( ( sum , bytes ) => sum + bytes , 0 ) , 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 ) ;
2026-06-02 22:13:54 +03:00
await uploadChunkWithRetry ( session , sessionFile , chunkIndex , file . slice ( start , end ) , ( loaded ) => {
2026-06-02 17:41:41 +03:00
const currentTotal = completedByFile . reduce ( ( sum , bytes ) => sum + bytes , 0 ) + loaded ;
setTotalProgress ( percentForBytes ( currentTotal , totalBytes ) ) ;
setSingleFileProgress ( fileIndex , file , percentForBytes ( completedByFile [ fileIndex ] + loaded , file . size ) ) ;
updateStatus ( ` ${ percentForBytes ( currentTotal , totalBytes ) } % ` ) ;
} ) ;
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 ) ;
}
2026-06-02 22:13:54 +03:00
updateStatus ( "Finalizing upload..." ) ;
const resultPayload = await completeResumableSession ( session . sessionId , session . resumeToken ) ;
const wasResumeMode = resumeMode ;
2026-06-02 17:41:41 +03:00
if ( persistable ) {
removeResumableSession ( session . sessionId ) ;
}
2026-06-02 22:13:54 +03:00
if ( resumeMode && recoveredDraft && recoveredDraft . session . sessionId === session . sessionId ) {
resumeMode = false ;
recoveredDraft = null ;
}
2026-06-02 17:41:41 +03:00
setTotalProgress ( 100 ) ;
2026-06-02 22:13:54 +03:00
if ( ! wasResumeMode ) {
setFileProgress ( files , 100 ) ;
}
2026-06-02 17:41:41 +03:00
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" ) ;
}
2026-06-02 22:13:54 +03:00
async function fetchResumableStatus ( sessionID , resumeToken ) {
2026-06-02 17:41:41 +03:00
const response = await fetch ( ` /api/v1/uploads/resumable/ ${ encodeURIComponent ( sessionID ) } ` , {
2026-06-02 22:13:54 +03:00
headers : resumableHeaders ( resumeToken ) ,
2026-06-02 17:41:41 +03:00
} ) ;
return readUploadJSON ( response , "Upload session could not be resumed" ) ;
}
2026-06-02 22:13:54 +03:00
async function addResumableFiles ( sessionID , resumeToken , files ) {
2026-06-02 17:41:41 +03:00
const response = await fetch ( ` /api/v1/uploads/resumable/ ${ encodeURIComponent ( sessionID ) } /files ` , {
method : "POST" ,
headers : {
2026-06-02 22:13:54 +03:00
... resumableHeaders ( resumeToken ) ,
2026-06-02 17:41:41 +03:00
"Accept" : "application/json" ,
"Content-Type" : "application/json" ,
} ,
body : JSON . stringify ( { files } ) ,
} ) ;
return readUploadJSON ( response , "Upload session files could not be added" ) ;
}
2026-06-02 22:13:54 +03:00
function uploadChunk ( sessionID , resumeToken , fileID , chunkIndex , chunk , onProgress ) {
2026-06-02 17:41:41 +03:00
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" ) ;
2026-06-02 22:13:54 +03:00
request . setRequestHeader ( "X-Warpbox-Resume-Token" , resumeToken || "" ) ;
2026-06-02 17:41:41 +03:00
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 ) ;
} ) ;
}
2026-06-02 22:13:54 +03:00
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 ) {
2026-06-02 17:41:41 +03:00
const response = await fetch ( ` /api/v1/uploads/resumable/ ${ encodeURIComponent ( sessionID ) } /complete ` , {
method : "POST" ,
2026-06-02 22:13:54 +03:00
headers : resumableHeaders ( resumeToken ) ,
2026-06-02 17:41:41 +03:00
} ) ;
return readUploadJSON ( response , "Upload could not be completed" ) ;
}
2026-06-02 22:13:54 +03:00
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 ) ) ;
}
2026-06-02 17:41:41 +03:00
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 ;
}
2026-06-02 22:13:54 +03:00
const session = await fetchResumableStatus ( record . sessionId , record . resumeToken ) . catch ( ( ) => null ) ;
2026-06-02 17:41:41 +03:00
if ( ! session || session . status !== "uploading" ) {
removeResumableSession ( record . sessionId ) ;
continue ;
}
2026-06-02 22:13:54 +03:00
session . resumeToken = record . resumeToken ;
2026-06-02 17:41:41 +03:00
const sessionKeys = new Set ( session . files . map ( ( file ) => resumableFileKey ( file ) ) ) ;
const selectedContainsSessionFile = Array . from ( sessionKeys ) . some ( ( key ) => selectedKeys . has ( key ) ) ;
2026-06-02 22:13:54 +03:00
if ( selectedContainsSessionFile ) {
2026-06-02 17:41:41 +03:00
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 ;
}
2026-06-02 22:13:54 +03:00
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. ` ) ;
}
}
2026-06-02 17:41:41 +03:00
}
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 ,
2026-06-02 22:13:54 +03:00
resumeToken : session . resumeToken || "" ,
2026-06-02 17:41:41 +03:00
optionKey : resumableOptionKey ( payload ) ,
2026-06-02 22:13:54 +03:00
options : {
expiresMinutes : payload . expiresMinutes ,
maxDownloads : payload . maxDownloads ,
obfuscateMetadata : ! ! payload . obfuscateMetadata ,
collectionId : payload . collectionId || "" ,
} ,
2026-06-02 17:41:41 +03:00
files : session . files . map ( ( file ) => ( {
name : file . name ,
size : file . size ,
2026-06-02 22:13:54 +03:00
contentType : file . contentType || "application/octet-stream" ,
2026-06-02 17:41:41 +03:00
fingerprint : file . fingerprint || "" ,
2026-06-02 22:13:54 +03:00
uploadedChunks : file . uploadedChunks || [ ] ,
chunkCount : file . chunkCount || 0 ,
2026-06-02 17:41:41 +03:00
} ) ) ,
updatedAt : new Date ( ) . toISOString ( ) ,
} ) ;
localStorage . setItem ( RESUMABLE _SESSIONS _KEY , JSON . stringify ( records . slice ( - 25 ) ) ) ;
} catch ( error ) {
/* ignore persistence failures */
}
}
2026-06-02 22:13:54 +03:00
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. ` ;
}
}
2026-06-02 17:41:41 +03:00
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 */
}
}
2026-06-02 22:13:54 +03:00
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 ( ) ;
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 ( ) ;
}
2026-06-02 17:41:41 +03:00
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 ) ;
}
2026-06-02 22:13:54 +03:00
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 ) ) ) ;
}
2026-06-02 17:41:41 +03:00
function percentForBytes ( bytes , total ) {
if ( ! total ) {
return 100 ;
}
return Math . max ( 0 , Math . min ( 100 , Math . round ( ( bytes / total ) * 100 ) ) ) ;
}
2026-05-31 13:02:58 +03:00
function renderQueue ( files , status ) {
if ( ! uploadQueue ) {
return ;
}
uploadQueue . hidden = files . length === 0 ;
uploadQueue . replaceChildren ( ) ;
2026-06-02 17:41:41 +03:00
files . forEach ( ( file , index ) => {
2026-05-31 13:02:58 +03:00
uploadQueue . append ( createFileRow ( {
name : file . name ,
meta : window . Warpbox . formatBytes ( file . size ) ,
progress : status === "queued" ? 0 : 100 ,
status ,
2026-06-02 17:41:41 +03:00
index ,
removable : status === "queued" ,
2026-05-31 13:02:58 +03:00
} ) ) ;
} ) ;
}
function createFileRow ( file ) {
const row = document . createElement ( "div" ) ;
2026-06-02 22:13:54 +03:00
row . className = ` result-item upload-file-row upload-file- ${ file . status || "queued" } ` ;
2026-05-31 13:02:58 +03:00
row . dataset . fileName = file . name ;
2026-06-02 22:13:54 +03:00
if ( typeof file . index === "number" ) {
row . dataset . fileIndex = file . index ;
}
2026-05-31 13:02:58 +03:00
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 ) ;
2026-06-02 22:13:54 +03:00
if ( file . status === "waiting" ) {
const badge = document . createElement ( "small" ) ;
badge . className = "upload-file-state" ;
badge . textContent = "Needs local file" ;
side . append ( badge ) ;
}
2026-06-02 17:41:41 +03:00
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 ) ;
}
2026-05-31 13:02:58 +03:00
row . append ( body , side ) ;
return row ;
}
2026-06-02 17:41:41 +03:00
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 ( "" ) ;
}
2026-05-31 13:02:58 +03:00
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 } ) ` ;
}
} ) ;
}
2026-06-02 17:41:41 +03:00
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 } ) ` ;
}
}
2026-05-31 13:02:58 +03:00
} ) ( ) ;