Page : progression détaillée, annulation, file d'attente
- 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:
parent
0f5bdb7080
commit
5a1219f00d
1 changed files with 166 additions and 12 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue