feat: enhance file upload progress tracking and user experience

- Add detailed upload progress tracking with speed and time remaining
- Implement dynamic waiting messages during upload initialization
- Create utility functions for file size and speed formatting
- Improve progress bar UI with more informative status details
- Add interval-based speed and progress updates for smoother UI
This commit is contained in:
Greirson Lee-Thorp
2025-02-04 17:00:31 -08:00
parent 38fc5994dd
commit 8ab70f45c8
2 changed files with 252 additions and 22 deletions

View File

@@ -48,15 +48,7 @@
<button id="uploadButton" class="upload-button" style="display: none;">Upload Files</button>
</div>
<style>
.button-group {
display: flex;
gap: 10px;
justify-content: center;
}
</style>
<script>
<script defer>
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
@@ -66,23 +58,178 @@
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Utility function to format file sizes
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
class FileUploader {
constructor(file, batchId) {
this.file = file;
this.batchId = batchId;
this.uploadId = null;
this.position = 0;
this.bytesReceived = 0;
this.progressElement = null;
this.retries = 0;
this.startTime = null;
this.lastUpdate = null;
this.lastBytes = 0;
this.speedSamples = [];
this.currentSpeed = 0;
this.speedUpdateTimer = null;
this.progressUpdateTimer = null;
this.waitingMessages = [
"Preparing upload...",
"Establishing connection...",
"Starting transfer...",
"Waiting for first chunk..."
];
this.waitingMessageIndex = 0;
this.waitingMessageInterval = null;
this.dotsCount = 1;
this.dotsIncreasing = true;
}
cycleWaitingMessage() {
if (!this.progressElement || !this.progressElement.infoSpan) return;
if (this.waitingMessageIndex < this.waitingMessages.length) {
this.progressElement.infoSpan.textContent = this.waitingMessages[this.waitingMessageIndex];
this.waitingMessageIndex++;
// When we finish the initial messages, add a delay before switching to dot animation
if (this.waitingMessageIndex >= this.waitingMessages.length) {
clearInterval(this.waitingMessageInterval);
// Keep the last message visible for 2 seconds before starting dot animation
setTimeout(() => {
this.waitingMessageInterval = setInterval(() => this.cycleWaitingMessage(), 500);
}, 2000);
}
} else {
// Create the dots string
const dots = '.'.repeat(this.dotsCount);
this.progressElement.infoSpan.textContent = `Still working${dots}`;
// Update dots count
if (this.dotsIncreasing) {
this.dotsCount++;
if (this.dotsCount > 6) {
this.dotsCount = 1;
}
}
}
}
startWaitingMessages() {
if (this.waitingMessageInterval) {
clearInterval(this.waitingMessageInterval);
}
// Start with 2 second interval for main messages
this.waitingMessageInterval = setInterval(() => this.cycleWaitingMessage(), 2000);
this.cycleWaitingMessage(); // Show first message immediately
}
stopWaitingMessages() {
if (this.waitingMessageInterval) {
clearInterval(this.waitingMessageInterval);
this.waitingMessageInterval = null;
}
}
calculateSpeed(bytesReceived) {
const now = Date.now();
// Only calculate speed if at least 1 second has passed since last update
if (!this.lastUpdate || (now - this.lastUpdate) >= 1000) {
if (this.lastUpdate) {
const timeDiff = (now - this.lastUpdate) / 1000; // Convert to seconds
const bytesDiff = bytesReceived - this.lastBytes;
const instantSpeed = bytesDiff / timeDiff; // Bytes per second
// Keep last 3 samples for smoother average
this.speedSamples.push(instantSpeed);
if (this.speedSamples.length > 3) {
this.speedSamples.shift();
}
// Calculate weighted moving average with more weight on recent samples
const weights = [0.5, 0.3, 0.2];
const samples = this.speedSamples.slice().reverse(); // Most recent first
this.currentSpeed = samples.reduce((acc, speed, i) => {
return acc + (speed * (weights[i] || 0));
}, 0);
}
this.lastUpdate = now;
this.lastBytes = bytesReceived;
}
return this.currentSpeed;
}
formatSpeed(bytesPerSecond) {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
calculateTimeRemaining(bytesReceived, speed) {
if (speed === 0) return 'calculating...';
const bytesRemaining = this.file.size - bytesReceived;
const secondsRemaining = bytesRemaining / speed;
if (secondsRemaining < 60) {
return `${Math.ceil(secondsRemaining)}s`;
} else if (secondsRemaining < 3600) {
return `${Math.ceil(secondsRemaining / 60)}m`;
} else {
return `${Math.ceil(secondsRemaining / 3600)}h`;
}
}
async start() {
try {
this.startTime = Date.now();
// Initialize speed update timer
this.speedUpdateTimer = setInterval(() => {
if (this.bytesReceived > 0) {
this.calculateSpeed(this.bytesReceived);
}
}, 1000);
// Add progress update timer for more frequent UI updates
this.progressUpdateTimer = setInterval(() => {
if (this.bytesReceived > 0) {
const progress = (this.bytesReceived / this.file.size) * 100;
this.updateProgress(progress);
}
}, 200); // Update every 200ms
await this.initUpload();
await this.uploadChunks();
// Clear the timers when upload is complete
this.clearTimers();
return true;
} catch (error) {
this.clearTimers();
console.error('Upload failed:', error);
if (this.progressElement) {
this.progressElement.infoSpan.textContent = `Error: ${error.message}`;
this.progressElement.infoSpan.style.color = 'var(--danger-color)';
}
this.stopWaitingMessages();
return false;
}
}
@@ -141,16 +288,19 @@
}
async uploadChunk(chunk) {
const controller = new AbortController();
const response = await fetch(`/upload/chunk/${this.uploadId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: chunk
body: chunk,
signal: controller.signal
});
if (!response.ok) throw new Error('Chunk upload failed');
const data = await response.json();
this.bytesReceived = Math.floor((data.progress / 100) * this.file.size);
this.updateProgress(data.progress);
}
@@ -160,10 +310,15 @@
const label = document.createElement('div');
label.className = 'progress-label';
const pathSpan = document.createElement('span');
pathSpan.className = 'progress-path';
const displayPath = this.file.relativePath ?
`📁 ${this.file.relativePath.split('/')[0]}/` :
`📁 ${this.file.relativePath}${this.file.name}` :
`📄 ${this.file.name}`;
label.textContent = displayPath;
pathSpan.textContent = displayPath;
label.appendChild(pathSpan);
const progress = document.createElement('div');
progress.className = 'progress';
@@ -172,24 +327,73 @@
bar.className = 'progress-bar';
bar.style.width = '0%';
const statusDiv = document.createElement('div');
statusDiv.className = 'progress-status';
const infoSpan = document.createElement('div');
infoSpan.className = 'progress-info';
const details = document.createElement('div');
details.className = 'progress-details';
statusDiv.appendChild(infoSpan);
statusDiv.appendChild(details);
progress.appendChild(bar);
container.appendChild(label);
container.appendChild(progress);
container.appendChild(statusDiv);
document.getElementById('uploadProgress').appendChild(container);
this.progressElement = { container, bar };
this.progressElement = { container, bar, infoSpan, details };
// Start cycling waiting messages
this.startWaitingMessages();
}
updateProgress(percent) {
if (this.progressElement) {
this.progressElement.bar.style.width = `${percent}%`;
// Use the current speed value instead of calculating it every time
const speed = this.currentSpeed;
// Stop waiting messages once we start receiving data
if (this.bytesReceived > 0) {
this.stopWaitingMessages();
}
const timeRemaining = this.calculateTimeRemaining(this.bytesReceived, speed);
this.progressElement.bar.style.width = `${percent.toFixed(1)}%`;
// Only show speed and time if we've received data
if (this.bytesReceived > 0) {
this.progressElement.infoSpan.textContent = `${this.formatSpeed(speed)} · ${timeRemaining}`;
}
// Update details with progress and file size
this.progressElement.details.textContent =
`${formatFileSize(this.bytesReceived)} of ${formatFileSize(this.file.size)} (${percent.toFixed(1)}%)`;
if (percent === 100) {
this.stopWaitingMessages();
this.clearTimers();
setTimeout(() => {
this.progressElement.container.remove();
}, 1000);
}
}
}
clearTimers() {
if (this.speedUpdateTimer) {
clearInterval(this.speedUpdateTimer);
this.speedUpdateTimer = null;
}
if (this.progressUpdateTimer) {
clearInterval(this.progressUpdateTimer);
this.progressUpdateTimer = null;
}
}
}
// UI Event Handlers
@@ -370,14 +574,6 @@
uploadButton.style.display = files.length > 0 ? 'block' : 'none';
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function startUploads() {
uploadButton.disabled = true;
document.getElementById('uploadProgress').innerHTML = '';

View File

@@ -159,6 +159,12 @@ button:disabled {
margin-right: auto;
}
.button-group {
display: flex;
gap: 10px;
justify-content: center;
}
/* Progress Bar Styles */
#uploadProgress {
margin: 20px 0;
@@ -181,11 +187,26 @@ button:disabled {
font-size: 0.9rem;
}
.progress-info {
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.8;
}
.progress-path {
color: var(--text-color);
opacity: 0.9;
font-weight: 500;
word-break: break-all;
}
.progress {
background: var(--progress-bg);
border-radius: 10px;
height: 8px;
overflow: hidden;
margin-top: 8px;
margin-bottom: 8px;
}
.progress-bar {
@@ -194,6 +215,19 @@ button:disabled {
transition: width 0.3s ease;
}
.progress-status {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.8;
}
.progress-details {
text-align: right;
}
/* Modal Styles */
.modal {
position: fixed;