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-10 18:14:29 +03:00
const folderPicker = document . querySelector ( "[data-folder-picker]" ) ;
2026-06-02 17:41:41 +03:00
const RESUMABLE _SESSIONS _KEY = "warpbox-resumable-sessions" ;
2026-06-08 11:53:37 +03:00
const SHARE _CACHE = "warpbox-share-target-v1" ;
const SHARE _LATEST _KEY = "/__warpbox_share_target__/latest" ;
2026-06-08 13:34:05 +03:00
const CELLULAR _WARNING _THRESHOLD _BYTES = 200 * 1024 * 1024 ;
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-06-08 11:53:37 +03:00
let sharedTargetDraft = null ;
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" ) ;
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 ) => {
2026-06-10 18:14:29 +03:00
if ( ! hasTransferFiles ( event . dataTransfer ) ) {
2026-06-02 17:41:41 +03:00
return ;
}
event . preventDefault ( ) ;
if ( ! dropZone . contains ( event . target ) ) {
2026-06-10 18:14:29 +03:00
addDroppedFiles ( event . dataTransfer ) ;
2026-06-02 17:41:41 +03:00
}
} ) ;
2026-05-31 13:02:58 +03:00
dropZone . addEventListener ( "drop" , ( event ) => {
2026-06-10 18:14:29 +03:00
if ( hasTransferFiles ( event . dataTransfer ) ) {
addDroppedFiles ( event . dataTransfer ) ;
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
2026-06-10 18:14:29 +03:00
document . addEventListener ( "paste" , ( event ) => {
if ( ! event . clipboardData || ! event . clipboardData . files || event . clipboardData . files . length === 0 ) {
return ;
}
if ( isTextEditingTarget ( event . target ) ) {
return ;
}
event . preventDefault ( ) ;
addSelectedFiles ( event . clipboardData . files , { source : "pasted" } ) ;
} ) ;
if ( folderPicker && typeof window . showDirectoryPicker === "function" ) {
folderPicker . hidden = false ;
folderPicker . addEventListener ( "click" , async ( ) => {
if ( uploadLocked ) {
return ;
}
try {
updateStatus ( "Reading folder..." ) ;
const directory = await window . showDirectoryPicker ( ) ;
const files = await filesFromDirectoryHandle ( directory , directory . name || "" ) ;
addSelectedFiles ( files , { source : "folder" } ) ;
} catch ( error ) {
if ( ! error || error . name !== "AbortError" ) {
updateStatus ( "Folder could not be read." ) ;
}
}
} ) ;
}
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." ) ;
2026-06-08 11:53:37 +03:00
notify ( "warning" , "Choose at least one file first." , {
title : "No files selected" ,
} ) ;
return ;
}
if ( ! validateSelectedFilesWithinLimit ( selectedFiles ) ) {
2026-05-31 13:02:58 +03:00
return ;
}
2026-06-08 13:34:05 +03:00
if ( isSlowOrMeteredConnection ( ) && totalSelectedBytes ( selectedFiles ) >= CELLULAR _WARNING _THRESHOLD _BYTES ) {
const proceed = await confirmCellularUpload ( selectedFiles ) ;
if ( ! proceed ) {
return ;
}
}
2026-05-31 13:02:58 +03:00
const submit = form . querySelector ( "button[type='submit']" ) ;
2026-06-02 17:41:41 +03:00
const formData = uploadFormData ( ) ;
2026-06-10 18:14:29 +03:00
await maybeRequestUploadNotificationPermission ( selectedFiles ) ;
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 ) ;
2026-06-10 18:14:29 +03:00
showUploadNotification ( "Warpbox upload complete" , ` ${ payload . files . length } file ${ payload . files . length === 1 ? "" : "s" } uploaded. ` , payload . boxUrl ) ;
2026-06-08 11:53:37 +03:00
await clearSharedTargetPayload ( ) ;
2026-05-31 13:02:58 +03:00
form . reset ( ) ;
2026-06-02 17:41:41 +03:00
selectedFiles = [ ] ;
2026-06-08 11:53:37 +03:00
sharedTargetDraft = null ;
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 ( ) ;
}
2026-06-02 22:41:59 +03:00
updateNewUploadVisibility ( ) ;
2026-06-02 22:13:54 +03:00
if ( fileSummary ) {
fileSummary . textContent = "Upload complete." ;
}
2026-05-31 13:02:58 +03:00
} catch ( error ) {
updateStatus ( error . message || "Upload failed" ) ;
2026-06-08 11:53:37 +03:00
notifyUploadError ( error ) ;
2026-06-10 18:14:29 +03:00
showUploadNotification ( "Warpbox upload failed" , error . message || "Upload failed" ) ;
2026-05-31 13:02:58 +03:00
} 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" , ( ) => {
2026-06-08 11:53:37 +03:00
if ( sharedTargetDraft ) {
clearSharedTargetPayload ( ) . finally ( ( ) => resetFreshUploadState ( ) ) ;
return ;
}
2026-06-02 22:13:54 +03:00
cancelRecoveredDraft ( ) . catch ( ( error ) => {
updateStatus ( error . message || "Upload draft could not be deleted" ) ;
} ) ;
} ) ;
}
2026-06-08 11:53:37 +03:00
if ( isShareTargetLaunch ( ) ) {
loadSharedTargetFiles ( ) ;
} else {
recoverResumableSessions ( ) ;
}
2026-06-02 22:13:54 +03:00
2026-06-10 18:14:29 +03:00
function addSelectedFiles ( files , options ) {
2026-06-02 17:41:41 +03:00
if ( uploadLocked ) {
return ;
}
2026-06-08 11:53:37 +03:00
const rejected = [ ] ;
2026-06-02 17:41:41 +03:00
Array . from ( files || [ ] ) . forEach ( ( file ) => {
2026-06-08 11:53:37 +03:00
if ( fileExceedsUploadLimit ( file ) ) {
rejected . push ( file ) ;
return ;
}
2026-06-02 17:41:41 +03:00
if ( ! selectedFiles . some ( ( existing ) => fileIdentity ( existing ) === fileIdentity ( file ) ) ) {
selectedFiles . push ( file ) ;
}
} ) ;
2026-06-08 11:53:37 +03:00
if ( rejected . length > 0 ) {
notifyRejectedFiles ( rejected ) ;
}
2026-06-10 18:14:29 +03:00
if ( options && options . source === "pasted" && files && files . length > 0 ) {
updateStatus ( ` ${ files . length } pasted file ${ files . length === 1 ? "" : "s" } ready. ` ) ;
}
if ( options && options . source === "folder" && files && files . length > 0 ) {
updateStatus ( ` ${ files . length } folder file ${ files . length === 1 ? "" : "s" } ready. ` ) ;
}
2026-06-08 11:53:37 +03:00
updateSelectedState ( ) ;
}
2026-06-10 18:14:29 +03:00
async function addDroppedFiles ( dataTransfer ) {
if ( uploadLocked ) {
return ;
}
const files = await filesFromDataTransfer ( dataTransfer ) ;
addSelectedFiles ( files , { source : hasDirectoryItems ( dataTransfer ) ? "folder" : "dropped" } ) ;
}
async function filesFromDataTransfer ( dataTransfer ) {
const items = Array . from ( dataTransfer . items || [ ] ) ;
const entries = items
. map ( ( item ) => typeof item . webkitGetAsEntry === "function" ? item . webkitGetAsEntry ( ) : null )
. filter ( Boolean ) ;
if ( entries . length === 0 ) {
return Array . from ( dataTransfer . files || [ ] ) ;
}
const nested = await Promise . all ( entries . map ( ( entry ) => filesFromEntry ( entry , "" ) ) ) ;
return nested . flat ( ) ;
}
function hasDirectoryItems ( dataTransfer ) {
return Array . from ( dataTransfer . items || [ ] ) . some ( ( item ) => {
const entry = typeof item . webkitGetAsEntry === "function" ? item . webkitGetAsEntry ( ) : null ;
return entry && entry . isDirectory ;
} ) ;
}
function hasTransferFiles ( dataTransfer ) {
if ( ! dataTransfer ) {
return false ;
}
if ( dataTransfer . files && dataTransfer . files . length > 0 ) {
return true ;
}
return Array . from ( dataTransfer . items || [ ] ) . some ( ( item ) => item . kind === "file" ) ;
}
function filesFromEntry ( entry , parentPath ) {
if ( ! entry ) {
return Promise . resolve ( [ ] ) ;
}
const relativePath = parentPath ? ` ${ parentPath } / ${ entry . name } ` : entry . name ;
if ( entry . isFile ) {
return new Promise ( ( resolve ) => {
entry . file ( ( file ) => resolve ( [ withRelativePath ( file , relativePath ) ] ) , ( ) => resolve ( [ ] ) ) ;
} ) ;
}
if ( ! entry . isDirectory ) {
return Promise . resolve ( [ ] ) ;
}
const reader = entry . createReader ( ) ;
const children = [ ] ;
return new Promise ( ( resolve ) => {
const readBatch = ( ) => {
reader . readEntries ( async ( entries ) => {
if ( ! entries . length ) {
const nested = await Promise . all ( children . map ( ( child ) => filesFromEntry ( child , relativePath ) ) ) ;
resolve ( nested . flat ( ) ) ;
return ;
}
children . push ( ... entries ) ;
readBatch ( ) ;
} , ( ) => resolve ( [ ] ) ) ;
} ;
readBatch ( ) ;
} ) ;
}
async function filesFromDirectoryHandle ( directory , parentPath ) {
const files = [ ] ;
for await ( const [ name , handle ] of directory . entries ( ) ) {
const relativePath = parentPath ? ` ${ parentPath } / ${ name } ` : name ;
if ( handle . kind === "file" ) {
const file = await handle . getFile ( ) ;
files . push ( withRelativePath ( file , relativePath ) ) ;
} else if ( handle . kind === "directory" ) {
files . push ( ... await filesFromDirectoryHandle ( handle , relativePath ) ) ;
}
}
return files ;
}
function withRelativePath ( file , relativePath ) {
if ( ! file || ! relativePath ) {
return file ;
}
try {
Object . defineProperty ( file , "warpboxRelativePath" , {
value : normalizeRelativePath ( relativePath ) ,
configurable : true ,
} ) ;
} catch ( error ) {
file . warpboxRelativePath = normalizeRelativePath ( relativePath ) ;
}
return file ;
}
function normalizeRelativePath ( value ) {
return String ( value || "" )
. replace ( /\\/g , "/" )
. split ( "/" )
. filter ( ( part ) => part && part !== "." && part !== ".." )
. join ( "/" ) ;
}
function uploadName ( file ) {
return normalizeRelativePath ( file && ( file . warpboxRelativePath || file . webkitRelativePath || file . name ) ) || ( file && file . name ) || "file" ;
}
function isTextEditingTarget ( target ) {
if ( ! target ) {
return false ;
}
const tag = ( target . tagName || "" ) . toLowerCase ( ) ;
return tag === "input" || tag === "textarea" || target . isContentEditable ;
}
2026-06-08 11:53:37 +03:00
function fileExceedsUploadLimit ( file ) {
return Number . isFinite ( maxUploadBytes ) && maxUploadBytes > 0 && file && file . size > maxUploadBytes ;
}
function validateSelectedFilesWithinLimit ( files ) {
const rejected = Array . from ( files || [ ] ) . filter ( fileExceedsUploadLimit ) ;
if ( rejected . length === 0 ) {
return true ;
}
selectedFiles = selectedFiles . filter ( ( file ) => ! fileExceedsUploadLimit ( file ) ) ;
notifyRejectedFiles ( rejected ) ;
2026-06-02 17:41:41 +03:00
updateSelectedState ( ) ;
2026-06-08 11:53:37 +03:00
return false ;
}
function notifyRejectedFiles ( files ) {
const names = files . slice ( 0 , 3 ) . map ( ( file ) => ` " ${ file . name } " ( ${ window . Warpbox . formatBytes ( file . size ) } ) ` ) . join ( ", " ) ;
const extra = files . length > 3 ? ` , and ${ files . length - 3 } more ` : "" ;
const message = ` ${ names } ${ extra } ${ files . length === 1 ? "is" : "are" } over the ${ maxUploadLabel } upload limit. ` ;
updateStatus ( message ) ;
notify ( "error" , message , {
title : "Upload limit exceeded" ,
duration : 9000 ,
} ) ;
}
function notifyUploadError ( error ) {
const message = error && error . message ? error . message : "Upload failed" ;
const lower = message . toLowerCase ( ) ;
const isLimit = lower . includes ( "limit" ) || lower . includes ( "quota" ) || lower . includes ( "too large" ) || lower . includes ( "exceeds" ) ;
notify ( "error" , message , {
title : isLimit ? "Upload limit reached" : "Upload failed" ,
duration : isLimit ? 9000 : 7200 ,
} ) ;
}
2026-06-10 18:14:29 +03:00
async function maybeRequestUploadNotificationPermission ( files ) {
if ( ! ( "Notification" in window ) || Notification . permission !== "default" || totalSelectedBytes ( files ) < CELLULAR _WARNING _THRESHOLD _BYTES ) {
return ;
}
try {
await Notification . requestPermission ( ) ;
} catch ( error ) {
/* notification permission is optional */
}
}
async function showUploadNotification ( title , body , url ) {
if ( ! ( "Notification" in window ) || Notification . permission !== "granted" ) {
return ;
}
if ( document . visibilityState === "visible" ) {
return ;
}
const options = {
body ,
icon : "/static/android-chrome-192x192.png" ,
badge : "/static/favicon-32x32.png" ,
data : { url : window . Warpbox . absoluteURL ( url || "/" ) } ,
} ;
try {
const registration = await navigator . serviceWorker ? . ready ;
if ( registration && registration . showNotification ) {
await registration . showNotification ( title , options ) ;
return ;
}
} catch ( error ) {
/* fall through to page notification */
}
const notification = new Notification ( title , options ) ;
notification . onclick = ( ) => {
window . focus ( ) ;
if ( url ) {
window . location . href = window . Warpbox . absoluteURL ( url ) ;
}
notification . close ( ) ;
} ;
}
2026-06-08 11:53:37 +03:00
function notify ( variant , message , options ) {
if ( window . Warpbox && typeof window . Warpbox . notify === "function" ) {
window . Warpbox . notify ( { ... ( options || { } ) , variant , message } ) ;
}
}
2026-06-08 13:34:05 +03:00
function isSlowOrMeteredConnection ( ) {
const connection = navigator . connection || navigator . mozConnection || navigator . webkitConnection ;
if ( ! connection ) {
return false ;
}
if ( connection . saveData === true ) {
return true ;
}
return [ "slow-2g" , "2g" , "3g" ] . includes ( connection . effectiveType ) ;
}
function totalSelectedBytes ( files ) {
return files . reduce ( ( sum , file ) => sum + file . size , 0 ) ;
}
function confirmCellularUpload ( files ) {
const list = document . createElement ( "div" ) ;
list . className = "dialog-file-list" ;
files . forEach ( ( file ) => {
const icon = document . createElement ( "span" ) ;
icon . className = "svg-icon svg-icon-document dialog-file-icon" ;
icon . setAttribute ( "aria-hidden" , "true" ) ;
const name = document . createElement ( "span" ) ;
name . className = "dialog-file-name" ;
name . textContent = file . name ;
name . title = file . name ;
const size = document . createElement ( "span" ) ;
size . className = "dialog-file-size" ;
size . textContent = window . Warpbox . formatBytes ( file . size ) ;
const row = document . createElement ( "div" ) ;
row . className = "dialog-file-row" ;
row . append ( icon , name , size ) ;
list . append ( row ) ;
} ) ;
const totalLabel = window . Warpbox . formatBytes ( totalSelectedBytes ( files ) ) ;
const message = ` You're on a slow or metered connection. You're about to upload ${ files . length } file ${ files . length === 1 ? "" : "s" } ( ${ totalLabel } total) — this could take a while or use up your data plan. ` ;
return window . Warpbox . confirmDialog ( message , {
title : "Slow connection detected" ,
variant : "warning" ,
body : list ,
confirmLabel : "Upload anyway" ,
cancelLabel : "Cancel" ,
} ) ;
}
2026-06-08 11:53:37 +03:00
function isShareTargetLaunch ( ) {
const params = new URLSearchParams ( window . location . search || "" ) ;
return params . has ( "share-target" ) ;
}
async function loadSharedTargetFiles ( ) {
if ( ! ( "caches" in window ) || typeof File === "undefined" ) {
updateStatus ( "Shared files could not be loaded in this browser." ) ;
recoverResumableSessions ( ) ;
return ;
}
updateStatus ( "Loading shared files..." ) ;
try {
const cache = await caches . open ( SHARE _CACHE ) ;
const metadataResponse = await cache . match ( SHARE _LATEST _KEY ) ;
if ( ! metadataResponse ) {
updateStatus ( new URLSearchParams ( window . location . search ) . get ( "share-target" ) === "unsupported"
? "Install Warpbox as an app to share files into it from your device."
: "No shared files were found." ) ;
recoverResumableSessions ( ) ;
return ;
}
const metadata = await metadataResponse . json ( ) ;
if ( metadata . error ) {
updateStatus ( metadata . error ) ;
recoverResumableSessions ( ) ;
return ;
}
const files = [ ] ;
for ( const item of metadata . files || [ ] ) {
if ( ! item . key ) {
continue ;
}
const response = await cache . match ( item . key ) ;
if ( ! response ) {
continue ;
}
const blob = await response . blob ( ) ;
files . push ( new File ( [ blob ] , item . name || "shared-file" , {
type : item . type || blob . type || "application/octet-stream" ,
lastModified : item . lastModified || Date . now ( ) ,
} ) ) ;
}
sharedTargetDraft = metadata ;
selectedFiles = files ;
resumeMode = false ;
recoveredDraft = null ;
validateSelectedFilesWithinLimit ( selectedFiles ) ;
if ( selectedFiles . length > 0 ) {
renderQueue ( selectedFiles , "queued" , { shared : true } ) ;
updateStatus ( "Shared files ready." ) ;
} else {
updateStatus ( "No files were included in this share." ) ;
}
updateSelectedState ( ) ;
} catch ( error ) {
updateStatus ( error . message || "Shared files could not be loaded." ) ;
recoverResumableSessions ( ) ;
}
}
async function clearSharedTargetPayload ( ) {
const draft = sharedTargetDraft ;
sharedTargetDraft = null ;
if ( ! draft || ! ( "caches" in window ) ) {
sharedTargetDraft = null ;
return ;
}
try {
const cache = await caches . open ( SHARE _CACHE ) ;
for ( const item of draft . files || [ ] ) {
if ( item . key ) {
await cache . delete ( item . key ) ;
}
}
if ( draft . id ) {
await cache . delete ( "/__warpbox_share_target__/meta/" + encodeURIComponent ( draft . id ) ) ;
}
await cache . delete ( SHARE _LATEST _KEY ) ;
} catch ( error ) {
/* ignore cache cleanup failures */
}
2026-06-02 17:41:41 +03:00
}
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. ` ;
2026-06-08 11:53:37 +03:00
} else if ( sharedTargetDraft ) {
fileSummary . textContent = count === 0
? "No shared files were received."
: ` ${ count } shared file ${ count === 1 ? "" : "s" } ready. Review options, then upload. ` ;
2026-06-02 22:13:54 +03:00
} 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 ) ;
2026-06-08 11:53:37 +03:00
} else if ( sharedTargetDraft && count > 0 ) {
renderQueue ( selectedFiles , "queued" , { shared : true } ) ;
2026-06-02 22:13:54 +03:00
} 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:41:59 +03:00
updateNewUploadVisibility ( ) ;
}
function updateNewUploadVisibility ( ) {
if ( ! newUpload ) {
return ;
2026-06-02 22:13:54 +03:00
}
2026-06-08 11:53:37 +03:00
const visible = Boolean ( ( resumeMode && recoveredDraft ) || sharedTargetDraft ) ;
2026-06-02 22:41:59 +03:00
newUpload . hidden = ! visible ;
newUpload . style . display = visible ? "" : "none" ;
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 ;
}
}
2026-06-02 22:41:59 +03:00
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 ] } ` ;
}
2026-05-31 13:02:58 +03:00
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 ( ) ;
2026-06-02 22:41:59 +03:00
const rateTracker = createTransferRateTracker ( 0 ) ;
2026-05-31 13:02:58 +03:00
request . open ( "POST" , url ) ;
request . setRequestHeader ( "Accept" , "application/json" ) ;
request . upload . addEventListener ( "progress" , ( event ) => {
2026-06-02 22:41:59 +03:00
const rate = rateTracker ( event . loaded || 0 ) ;
2026-05-31 13:02:58 +03:00
if ( ! event . lengthComputable ) {
2026-06-02 22:41:59 +03:00
updateStatus ( rate > 0 ? ` Uploading · ${ formatTransferRate ( rate ) } ` : "Uploading..." ) ;
2026-05-31 13:02:58 +03:00
return ;
}
const percent = Math . round ( ( event . loaded / event . total ) * 100 ) ;
2026-06-02 22:41:59 +03:00
updateUploadProgress ( percent , rate ) ;
2026-05-31 13:02:58 +03:00
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 ) => ( {
2026-06-10 18:14:29 +03:00
name : uploadName ( file ) ,
2026-06-02 17:41:41 +03:00
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 ) ) ;
} ) ;
2026-06-02 22:41:59 +03:00
const initiallyUploadedBytes = completedByFile . reduce ( ( sum , bytes ) => sum + bytes , 0 ) ;
const rateTracker = createTransferRateTracker ( initiallyUploadedBytes ) ;
setTotalProgress ( percentForBytes ( initiallyUploadedBytes , totalBytes ) ) ;
2026-06-02 17:41:41 +03:00
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 ;
2026-06-02 22:41:59 +03:00
const percent = percentForBytes ( currentTotal , totalBytes ) ;
const rate = rateTracker ( currentTotal ) ;
setTotalProgress ( percent ) ;
2026-06-02 17:41:41 +03:00
setSingleFileProgress ( fileIndex , file , percentForBytes ( completedByFile [ fileIndex ] + loaded , file . size ) ) ;
2026-06-02 22:41:59 +03:00
updateUploadProgress ( percent , rate ) ;
2026-06-02 17:41:41 +03:00
} ) ;
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 ( ) ;
2026-06-02 22:41:59 +03:00
updateNewUploadVisibility ( ) ;
2026-06-02 22:13:54 +03:00
updateStatus ( "Drop or reselect missing files to continue. Extra files will be added to this upload." ) ;
}
function resetFreshUploadState ( ) {
selectedFiles = [ ] ;
resumeMode = false ;
recoveredDraft = null ;
2026-06-08 11:53:37 +03:00
sharedTargetDraft = null ;
2026-06-02 22:13:54 +03:00
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 ) => {
2026-06-10 18:14:29 +03:00
localByNameSize . set ( ` ${ uploadName ( file ) } : ${ file . size } ` , { file , index } ) ;
2026-06-02 22:13:54 +03:00
} ) ;
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 ( {
2026-06-10 18:14:29 +03:00
name : uploadName ( file ) ,
2026-06-02 22:13:54 +03:00
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 ( {
2026-06-10 18:14:29 +03:00
name : uploadName ( file ) ,
2026-06-02 22:13:54 +03:00
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-06-08 11:53:37 +03:00
function renderQueue ( files , status , options ) {
2026-05-31 13:02:58 +03:00
if ( ! uploadQueue ) {
return ;
}
2026-06-08 11:53:37 +03:00
const shared = Boolean ( options && options . shared ) ;
2026-05-31 13:02:58 +03:00
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 ( {
2026-06-10 18:14:29 +03:00
name : uploadName ( file ) ,
2026-06-08 11:53:37 +03:00
meta : shared ? ` ${ window . Warpbox . formatBytes ( file . size ) } · Shared from device ` : window . Warpbox . formatBytes ( file . size ) ,
2026-05-31 13:02:58 +03:00
progress : status === "queued" ? 0 : 100 ,
status ,
2026-06-02 17:41:41 +03:00
index ,
removable : status === "queued" ,
2026-06-08 11:53:37 +03:00
shared ,
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-08 11:53:37 +03:00
if ( file . shared ) {
const badge = document . createElement ( "small" ) ;
badge . className = "upload-file-state upload-file-state-shared" ;
badge . textContent = "Shared from device" ;
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 ) => {
2026-06-10 18:14:29 +03:00
formData . append ( "file" , file , uploadName ( file ) ) ;
2026-06-02 17:41:41 +03:00
} ) ;
return formData ;
}
function fileIdentity ( file ) {
2026-06-10 18:14:29 +03:00
return [ uploadName ( file ) , file . size , file . lastModified || 0 ] . join ( ":" ) ;
2026-06-02 17:41:41 +03:00
}
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 ( ) ;
2026-06-10 18:14:29 +03:00
const metadata = new TextEncoder ( ) . encode ( [ uploadName ( file ) , file . size , file . lastModified || 0 , sampleSize ] . join ( ":" ) ) ;
2026-06-02 17:41:41 +03:00
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
} ) ( ) ;