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) {