Page : progression détaillée, annulation, file d'attente
Some checks failed
Publier l'image Docker / test (push) Failing after 0s
Publier l'image Docker / publish (push) Has been skipped

- Progression en temps réel par fichier : transféré / total, %, débit
  (moyenne glissante), ETA.
- Annulation : bouton « Annuler » par fichier (en attente ou en cours) +
  « Tout annuler ». xhr.abort() -> le serveur nettoie déjà le fichier partiel.
- File d'attente : au plus 3 transferts simultanés, le reste patiente
  (évite de saturer le lien sur un dépôt de dossier). Bandeau récap.

Changements purement page ; aucune modif serveur. 62 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-10 17:59:18 +02:00
parent 0f5bdb7080
commit 5a1219f00d

View file

@ -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 @@
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
</div>
<div id="queueRow" class="card" hidden>
<span id="queueInfo" class="muted"></span>
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
</div>
<div id="list"></div>
</main>
@ -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 =
'<div class="row"><span class="name"></span><span class="muted"></span></div>' +
'<div class="row">' +
'<span class="name"></span>' +
'<span class="detail"><span class="total"></span> ' +
'<button type="button" class="cancel" hidden>Annuler</button></span>' +
'</div>' +
'<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span></div>';
'<div class="row"><span class="status muted">En attente…</span>' +
'<span class="detail progress"></span></div>';
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) {