diff --git a/public/index.html b/public/index.html
index 7652ec8..f16372d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -137,6 +137,7 @@
.item .row {
display: flex;
justify-content: space-between;
+ align-items: center;
gap: 1rem;
font-size: 0.9rem;
}
@@ -145,6 +146,32 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+ .item .detail {
+ color: var(--muted);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ }
+ .cancel {
+ flex: none;
+ font: inherit;
+ line-height: 1;
+ padding: 0.15rem 0.4rem;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ }
+ .cancel:hover {
+ color: var(--err);
+ border-color: var(--err);
+ }
+ #queueRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ }
.bar {
height: 6px;
border-radius: 999px;
@@ -210,6 +237,11 @@
+
+
+
+
+
@@ -225,8 +257,13 @@
const dirRow = document.getElementById('dirRow');
const dirBtn = document.getElementById('dirBtn');
const dirPicker = document.getElementById('dirPicker');
+ const queueRow = document.getElementById('queueRow');
+ const queueInfo = document.getElementById('queueInfo');
+ const queueCancel = document.getElementById('queueCancel');
const list = document.getElementById('list');
+ const MAX_PARALLEL = 3;
+
let authenticated = false;
let currentTarget = null;
let currentTargetAllowsDirs = false;
@@ -306,17 +343,34 @@
return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
}
+ function formatEta(seconds) {
+ if (!isFinite(seconds) || seconds < 0) return '';
+ if (seconds < 90) return '~' + Math.round(seconds) + ' s';
+ if (seconds < 5400) return '~' + Math.round(seconds / 60) + ' min';
+ return '~' + (seconds / 3600).toFixed(1) + ' h';
+ }
+
function addItem(file, label) {
const item = document.createElement('div');
item.className = 'item';
item.innerHTML =
- '
' +
+ '' +
+ '' +
+ ' ' +
+ '' +
+ '
' +
'
' +
- 'En attente…
';
+ 'En attente…' +
+ '
';
item.querySelector('.name').textContent = label || file.name;
- item.querySelector('.row .muted').textContent = formatSize(file.size);
+ item.querySelector('.total').textContent = formatSize(file.size);
list.prepend(item);
- return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status') };
+ return {
+ bar: item.querySelector('.bar > span'),
+ status: item.querySelector('.status'),
+ progress: item.querySelector('.progress'),
+ cancelBtn: item.querySelector('.cancel'),
+ };
}
function setStatus(ui, text, kind) {
@@ -324,27 +378,102 @@
ui.status.className = 'status ' + (kind || 'muted');
}
- function upload(file, relativePath) {
- const ui = addItem(file, relativePath);
+ // File d'attente : au plus MAX_PARALLEL transferts simultanés.
+ const queue = [];
+ const active = new Set();
- const form = new FormData();
- form.append('file', file);
+ function updateQueueRow() {
+ const running = active.size;
+ const waiting = queue.length;
+ if (running + waiting === 0) {
+ queueRow.hidden = true;
+ return;
+ }
+ queueRow.hidden = false;
+ queueInfo.textContent =
+ running + ' en cours' + (waiting ? ' · ' + waiting + ' en attente' : '');
+ }
+
+ function pump() {
+ while (active.size < MAX_PARALLEL && queue.length > 0) {
+ startUpload(queue.shift());
+ }
+ updateQueueRow();
+ }
+
+ function enqueue(file, relativePath) {
+ const ui = addItem(file, relativePath);
+ const job = { file, relativePath, ui };
+ queue.push(job);
+ ui.cancelBtn.hidden = false;
+ ui.cancelBtn.onclick = () => {
+ const i = queue.indexOf(job);
+ if (i < 0) return;
+ queue.splice(i, 1);
+ ui.cancelBtn.hidden = true;
+ setStatus(ui, 'Annulé', 'err');
+ updateQueueRow();
+ };
+ updateQueueRow();
+ }
+
+ function startUpload(job) {
+ const { file, relativePath, ui } = job;
const params = new URLSearchParams();
if (currentTarget) params.set('target', currentTarget);
if (relativePath) params.set('path', relativePath);
const query = params.toString();
+ const form = new FormData();
+ form.append('file', file);
+
const xhr = new XMLHttpRequest();
+ job.xhr = xhr;
+ active.add(job);
xhr.open('POST', query ? '/upload?' + query : '/upload');
+ let startedAt = 0;
+ let lastAt = 0;
+ let lastLoaded = 0;
+ let speed = 0;
+
xhr.upload.addEventListener('progress', (event) => {
- if (event.lengthComputable) {
- ui.bar.style.width = (event.loaded / event.total) * 100 + '%';
+ if (!event.lengthComputable) return;
+ const now = performance.now();
+ if (!startedAt) {
+ startedAt = now;
+ lastAt = now;
}
+ const dt = (now - lastAt) / 1000;
+ if (dt >= 0.25) {
+ const inst = (event.loaded - lastLoaded) / dt;
+ speed = speed ? speed * 0.7 + inst * 0.3 : inst;
+ lastAt = now;
+ lastLoaded = event.loaded;
+ }
+ ui.bar.style.width = (event.loaded / event.total) * 100 + '%';
+ const pct = Math.floor((event.loaded / event.total) * 100);
+ const eta = speed > 0 ? formatEta((event.total - event.loaded) / speed) : '';
+ ui.progress.textContent =
+ formatSize(event.loaded) +
+ ' / ' +
+ formatSize(event.total) +
+ ' · ' +
+ pct +
+ ' %' +
+ (speed > 0 ? ' · ' + formatSize(speed) + '/s' : '') +
+ (eta ? ' · ' + eta : '');
});
+ const finish = () => {
+ active.delete(job);
+ ui.cancelBtn.hidden = true;
+ pump();
+ };
+
xhr.addEventListener('load', () => {
+ finish();
let body = {};
try {
body = JSON.parse(xhr.responseText);
@@ -353,6 +482,7 @@
}
if (xhr.status >= 200 && xhr.status < 300) {
ui.bar.style.width = '100%';
+ ui.progress.textContent = formatSize(file.size);
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
} else if (xhr.status === 401) {
render({ authenticated: false });
@@ -363,12 +493,35 @@
}
});
- xhr.addEventListener('error', () => setStatus(ui, 'Erreur réseau', 'err'));
+ xhr.addEventListener('error', () => {
+ finish();
+ setStatus(ui, 'Erreur réseau', 'err');
+ });
+ xhr.addEventListener('abort', () => {
+ finish();
+ ui.progress.textContent = '';
+ setStatus(ui, 'Annulé', 'err');
+ });
+
+ ui.cancelBtn.hidden = false;
+ ui.cancelBtn.onclick = () => xhr.abort();
setStatus(ui, 'Envoi…', 'muted');
xhr.send(form);
}
+ function cancelAll() {
+ while (queue.length) {
+ const job = queue.shift();
+ job.ui.cancelBtn.hidden = true;
+ setStatus(job.ui, 'Annulé', 'err');
+ }
+ for (const job of Array.from(active)) job.xhr.abort();
+ updateQueueRow();
+ }
+
+ queueCancel.addEventListener('click', cancelAll);
+
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items) {
if (!authenticated) {
@@ -382,7 +535,8 @@
} else {
banner.hidden = true;
}
- for (const entry of batch) upload(entry.file, entry.path);
+ for (const entry of batch) enqueue(entry.file, entry.path);
+ pump();
}
function fromFileList(fileList, useRelativePath) {