/** * Shared S3 upload utilities. * * Usage: * const uploader = new S3Uploader({ * filenameEl: document.getElementById('upload-filename'), * percentEl: document.getElementById('upload-percent'), * progressBar: document.getElementById('progress-bar'), * }); * uploader.upload(presignedUrl, file, s3Key, fallbackContentType) * .then(s3Key => { ... }) * .catch(err => { ... }); * uploader.cancel(); */ function S3Uploader(opts) { this.filenameEl = opts.filenameEl; this.percentEl = opts.percentEl; this.progressBar = opts.progressBar; this.speedEl = opts.speedEl || null; this._xhr = null; } /** Start an upload. Returns a Promise resolving to the s3Key. */ S3Uploader.prototype.upload = function(url, file, s3Key, fallbackContentType, cacheControl) { var self = this; var startTime = Date.now(); if (self.filenameEl) self.filenameEl.textContent = file.name; if (self.percentEl) self.percentEl.textContent = '0%'; if (self.progressBar) self.progressBar.style.width = '0%'; if (self.speedEl) self.speedEl.textContent = ''; return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); self._xhr = xhr; xhr.upload.addEventListener('progress', function(e) { if (e.lengthComputable) { var percent = Math.round((e.loaded / e.total) * 100); if (self.percentEl) self.percentEl.textContent = percent + '%'; if (self.progressBar) self.progressBar.style.width = percent + '%'; if (self.speedEl && e.loaded > 0) { var elapsed = (Date.now() - startTime) / 1000; if (elapsed > 0.5) { var speed = e.loaded / elapsed; var remaining = (e.total - e.loaded) / speed; var speedStr = speed > 1024 * 1024 ? (speed / (1024 * 1024)).toFixed(1) + ' MB/s' : (speed / 1024).toFixed(0) + ' KB/s'; var etaStr = remaining < 60 ? Math.ceil(remaining) + 's' : Math.ceil(remaining / 60) + 'm ' + Math.ceil(remaining % 60) + 's'; self.speedEl.textContent = speedStr + ' \u2014 ' + etaStr + ' remaining'; } } } }); xhr.addEventListener('load', function() { self._xhr = null; if (xhr.status >= 200 && xhr.status < 300) { resolve(s3Key); } else { reject(new Error('Upload failed: ' + xhr.status)); } }); xhr.addEventListener('error', function() { self._xhr = null; reject(new Error('Network error during upload')); }); xhr.addEventListener('abort', function() { self._xhr = null; reject(new Error('Upload cancelled')); }); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream'); if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl); xhr.send(file); }); }; /** Abort any in-progress upload. */ S3Uploader.prototype.cancel = function() { if (this._xhr) { this._xhr.abort(); this._xhr = null; } }; /* Dropzone helpers — wire up drag/drop on an element */ function initDropzone(dropzoneEl, fileInputEl, onFile) { dropzoneEl.addEventListener('dragover', function(e) { e.preventDefault(); dropzoneEl.classList.add('dragover'); }); dropzoneEl.addEventListener('dragleave', function() { dropzoneEl.classList.remove('dragover'); }); dropzoneEl.addEventListener('drop', function(e) { e.preventDefault(); dropzoneEl.classList.remove('dragover'); if (e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]); }); dropzoneEl.addEventListener('click', function() { fileInputEl.click(); }); fileInputEl.addEventListener('change', function() { if (fileInputEl.files[0]) onFile(fileInputEl.files[0]); }); }