Skip to content

Commit beb03e2

Browse files
authored
Merge pull request DumbWareio#22 from greirson/progress-bar
Feat: Enhanced Upload Progress Bar UI/UX
2 parents 3d10957 + 3177ac0 commit beb03e2

File tree

2 files changed

+252
-22
lines changed

2 files changed

+252
-22
lines changed

public/index.html

+218-22
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,7 @@ <h1>{{SITE_TITLE}}</h1>
4848
<button id="uploadButton" class="upload-button" style="display: none;">Upload Files</button>
4949
</div>
5050

51-
<style>
52-
.button-group {
53-
display: flex;
54-
gap: 10px;
55-
justify-content: center;
56-
}
57-
</style>
58-
59-
<script>
51+
<script defer>
6052
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
6153
const MAX_RETRIES = 3;
6254
const RETRY_DELAY = 1000;
@@ -67,23 +59,178 @@ <h1>{{SITE_TITLE}}</h1>
6759
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
6860
}
6961

62+
// Utility function to format file sizes
63+
function formatFileSize(bytes) {
64+
if (bytes === 0) return '0 Bytes';
65+
const k = 1024;
66+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
67+
const i = Math.floor(Math.log(bytes) / Math.log(k));
68+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
69+
}
70+
7071
class FileUploader {
7172
constructor(file, batchId) {
7273
this.file = file;
7374
this.batchId = batchId;
7475
this.uploadId = null;
7576
this.position = 0;
77+
this.bytesReceived = 0;
7678
this.progressElement = null;
7779
this.retries = 0;
80+
this.startTime = null;
81+
this.lastUpdate = null;
82+
this.lastBytes = 0;
83+
this.speedSamples = [];
84+
this.currentSpeed = 0;
85+
this.speedUpdateTimer = null;
86+
this.progressUpdateTimer = null;
87+
this.waitingMessages = [
88+
"Preparing upload...",
89+
"Establishing connection...",
90+
"Starting transfer...",
91+
"Waiting for first chunk..."
92+
];
93+
this.waitingMessageIndex = 0;
94+
this.waitingMessageInterval = null;
95+
this.dotsCount = 1;
96+
this.dotsIncreasing = true;
97+
}
98+
99+
cycleWaitingMessage() {
100+
if (!this.progressElement || !this.progressElement.infoSpan) return;
101+
102+
if (this.waitingMessageIndex < this.waitingMessages.length) {
103+
this.progressElement.infoSpan.textContent = this.waitingMessages[this.waitingMessageIndex];
104+
this.waitingMessageIndex++;
105+
106+
// When we finish the initial messages, add a delay before switching to dot animation
107+
if (this.waitingMessageIndex >= this.waitingMessages.length) {
108+
clearInterval(this.waitingMessageInterval);
109+
// Keep the last message visible for 2 seconds before starting dot animation
110+
setTimeout(() => {
111+
this.waitingMessageInterval = setInterval(() => this.cycleWaitingMessage(), 500);
112+
}, 2000);
113+
}
114+
} else {
115+
// Create the dots string
116+
const dots = '.'.repeat(this.dotsCount);
117+
this.progressElement.infoSpan.textContent = `Still working${dots}`;
118+
119+
// Update dots count
120+
if (this.dotsIncreasing) {
121+
this.dotsCount++;
122+
if (this.dotsCount > 6) {
123+
this.dotsCount = 1;
124+
}
125+
}
126+
}
127+
}
128+
129+
startWaitingMessages() {
130+
if (this.waitingMessageInterval) {
131+
clearInterval(this.waitingMessageInterval);
132+
}
133+
// Start with 2 second interval for main messages
134+
this.waitingMessageInterval = setInterval(() => this.cycleWaitingMessage(), 2000);
135+
this.cycleWaitingMessage(); // Show first message immediately
136+
}
137+
138+
stopWaitingMessages() {
139+
if (this.waitingMessageInterval) {
140+
clearInterval(this.waitingMessageInterval);
141+
this.waitingMessageInterval = null;
142+
}
143+
}
144+
145+
calculateSpeed(bytesReceived) {
146+
const now = Date.now();
147+
148+
// Only calculate speed if at least 1 second has passed since last update
149+
if (!this.lastUpdate || (now - this.lastUpdate) >= 1000) {
150+
if (this.lastUpdate) {
151+
const timeDiff = (now - this.lastUpdate) / 1000; // Convert to seconds
152+
const bytesDiff = bytesReceived - this.lastBytes;
153+
const instantSpeed = bytesDiff / timeDiff; // Bytes per second
154+
155+
// Keep last 3 samples for smoother average
156+
this.speedSamples.push(instantSpeed);
157+
if (this.speedSamples.length > 3) {
158+
this.speedSamples.shift();
159+
}
160+
161+
// Calculate weighted moving average with more weight on recent samples
162+
const weights = [0.5, 0.3, 0.2];
163+
const samples = this.speedSamples.slice().reverse(); // Most recent first
164+
this.currentSpeed = samples.reduce((acc, speed, i) => {
165+
return acc + (speed * (weights[i] || 0));
166+
}, 0);
167+
}
168+
this.lastUpdate = now;
169+
this.lastBytes = bytesReceived;
170+
}
171+
172+
return this.currentSpeed;
173+
}
174+
175+
formatSpeed(bytesPerSecond) {
176+
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
177+
let value = bytesPerSecond;
178+
let unitIndex = 0;
179+
180+
while (value >= 1024 && unitIndex < units.length - 1) {
181+
value /= 1024;
182+
unitIndex++;
183+
}
184+
185+
return `${value.toFixed(1)} ${units[unitIndex]}`;
186+
}
187+
188+
calculateTimeRemaining(bytesReceived, speed) {
189+
if (speed === 0) return 'calculating...';
190+
const bytesRemaining = this.file.size - bytesReceived;
191+
const secondsRemaining = bytesRemaining / speed;
192+
193+
if (secondsRemaining < 60) {
194+
return `${Math.ceil(secondsRemaining)}s`;
195+
} else if (secondsRemaining < 3600) {
196+
return `${Math.ceil(secondsRemaining / 60)}m`;
197+
} else {
198+
return `${Math.ceil(secondsRemaining / 3600)}h`;
199+
}
78200
}
79201

80202
async start() {
81203
try {
204+
this.startTime = Date.now();
205+
// Initialize speed update timer
206+
this.speedUpdateTimer = setInterval(() => {
207+
if (this.bytesReceived > 0) {
208+
this.calculateSpeed(this.bytesReceived);
209+
}
210+
}, 1000);
211+
212+
// Add progress update timer for more frequent UI updates
213+
this.progressUpdateTimer = setInterval(() => {
214+
if (this.bytesReceived > 0) {
215+
const progress = (this.bytesReceived / this.file.size) * 100;
216+
this.updateProgress(progress);
217+
}
218+
}, 200); // Update every 200ms
219+
82220
await this.initUpload();
83221
await this.uploadChunks();
222+
223+
// Clear the timers when upload is complete
224+
this.clearTimers();
84225
return true;
85226
} catch (error) {
227+
this.clearTimers();
86228
console.error('Upload failed:', error);
229+
if (this.progressElement) {
230+
this.progressElement.infoSpan.textContent = `Error: ${error.message}`;
231+
this.progressElement.infoSpan.style.color = 'var(--danger-color)';
232+
}
233+
this.stopWaitingMessages();
87234
return false;
88235
}
89236
}
@@ -142,16 +289,19 @@ <h1>{{SITE_TITLE}}</h1>
142289
}
143290

144291
async uploadChunk(chunk) {
292+
const controller = new AbortController();
145293
const response = await fetch(`/upload/chunk/${this.uploadId}`, {
146294
method: 'POST',
147295
headers: {
148296
'Content-Type': 'application/octet-stream'
149297
},
150-
body: chunk
298+
body: chunk,
299+
signal: controller.signal
151300
});
152301

153302
if (!response.ok) throw new Error('Chunk upload failed');
154303
const data = await response.json();
304+
this.bytesReceived = Math.floor((data.progress / 100) * this.file.size);
155305
this.updateProgress(data.progress);
156306
}
157307

@@ -161,10 +311,15 @@ <h1>{{SITE_TITLE}}</h1>
161311

162312
const label = document.createElement('div');
163313
label.className = 'progress-label';
314+
315+
const pathSpan = document.createElement('span');
316+
pathSpan.className = 'progress-path';
164317
const displayPath = this.file.relativePath ?
165-
`📁 ${this.file.relativePath.split('/')[0]}/` :
318+
`📁 ${this.file.relativePath}${this.file.name}` :
166319
`📄 ${this.file.name}`;
167-
label.textContent = displayPath;
320+
pathSpan.textContent = displayPath;
321+
322+
label.appendChild(pathSpan);
168323

169324
const progress = document.createElement('div');
170325
progress.className = 'progress';
@@ -173,24 +328,73 @@ <h1>{{SITE_TITLE}}</h1>
173328
bar.className = 'progress-bar';
174329
bar.style.width = '0%';
175330

331+
const statusDiv = document.createElement('div');
332+
statusDiv.className = 'progress-status';
333+
334+
const infoSpan = document.createElement('div');
335+
infoSpan.className = 'progress-info';
336+
337+
const details = document.createElement('div');
338+
details.className = 'progress-details';
339+
340+
statusDiv.appendChild(infoSpan);
341+
statusDiv.appendChild(details);
342+
176343
progress.appendChild(bar);
177344
container.appendChild(label);
178345
container.appendChild(progress);
346+
container.appendChild(statusDiv);
179347

180348
document.getElementById('uploadProgress').appendChild(container);
181-
this.progressElement = { container, bar };
349+
this.progressElement = { container, bar, infoSpan, details };
350+
351+
// Start cycling waiting messages
352+
this.startWaitingMessages();
182353
}
183354

184355
updateProgress(percent) {
185356
if (this.progressElement) {
186-
this.progressElement.bar.style.width = `${percent}%`;
357+
// Use the current speed value instead of calculating it every time
358+
const speed = this.currentSpeed;
359+
360+
// Stop waiting messages once we start receiving data
361+
if (this.bytesReceived > 0) {
362+
this.stopWaitingMessages();
363+
}
364+
365+
const timeRemaining = this.calculateTimeRemaining(this.bytesReceived, speed);
366+
367+
this.progressElement.bar.style.width = `${percent.toFixed(1)}%`;
368+
369+
// Only show speed and time if we've received data
370+
if (this.bytesReceived > 0) {
371+
this.progressElement.infoSpan.textContent = `${this.formatSpeed(speed)} · ${timeRemaining}`;
372+
}
373+
374+
// Update details with progress and file size
375+
this.progressElement.details.textContent =
376+
`${formatFileSize(this.bytesReceived)} of ${formatFileSize(this.file.size)} (${percent.toFixed(1)}%)`;
377+
187378
if (percent === 100) {
379+
this.stopWaitingMessages();
380+
this.clearTimers();
188381
setTimeout(() => {
189382
this.progressElement.container.remove();
190383
}, 1000);
191384
}
192385
}
193386
}
387+
388+
clearTimers() {
389+
if (this.speedUpdateTimer) {
390+
clearInterval(this.speedUpdateTimer);
391+
this.speedUpdateTimer = null;
392+
}
393+
if (this.progressUpdateTimer) {
394+
clearInterval(this.progressUpdateTimer);
395+
this.progressUpdateTimer = null;
396+
}
397+
}
194398
}
195399

196400
// UI Event Handlers
@@ -375,14 +579,6 @@ <h1>{{SITE_TITLE}}</h1>
375579
uploadButton.style.display = (!AUTO_UPLOAD && files.length > 0) ? 'block' : 'none';
376580
}
377581

378-
function formatFileSize(bytes) {
379-
if (bytes === 0) return '0 Bytes';
380-
const k = 1024;
381-
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
382-
const i = Math.floor(Math.log(bytes) / Math.log(k));
383-
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
384-
}
385-
386582
async function startUploads() {
387583
uploadButton.disabled = true;
388584
document.getElementById('uploadProgress').innerHTML = '';

public/styles.css

+34
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ button:disabled {
159159
margin-right: auto;
160160
}
161161

162+
.button-group {
163+
display: flex;
164+
gap: 10px;
165+
justify-content: center;
166+
}
167+
162168
/* Progress Bar Styles */
163169
#uploadProgress {
164170
margin: 20px 0;
@@ -181,11 +187,26 @@ button:disabled {
181187
font-size: 0.9rem;
182188
}
183189

190+
.progress-info {
191+
font-size: 0.8rem;
192+
color: var(--text-color);
193+
opacity: 0.8;
194+
}
195+
196+
.progress-path {
197+
color: var(--text-color);
198+
opacity: 0.9;
199+
font-weight: 500;
200+
word-break: break-all;
201+
}
202+
184203
.progress {
185204
background: var(--progress-bg);
186205
border-radius: 10px;
187206
height: 8px;
188207
overflow: hidden;
208+
margin-top: 8px;
209+
margin-bottom: 8px;
189210
}
190211

191212
.progress-bar {
@@ -194,6 +215,19 @@ button:disabled {
194215
transition: width 0.3s ease;
195216
}
196217

218+
.progress-status {
219+
display: flex;
220+
justify-content: space-between;
221+
align-items: center;
222+
font-size: 0.8rem;
223+
color: var(--text-color);
224+
opacity: 0.8;
225+
}
226+
227+
.progress-details {
228+
text-align: right;
229+
}
230+
197231
/* Modal Styles */
198232
.modal {
199233
position: fixed;

0 commit comments

Comments
 (0)