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 {
|
.item .row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +146,32 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
@ -210,6 +237,11 @@
|
||||||
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
|
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
|
||||||
</div>
|
</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>
|
<div id="list"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -225,8 +257,13 @@
|
||||||
const dirRow = document.getElementById('dirRow');
|
const dirRow = document.getElementById('dirRow');
|
||||||
const dirBtn = document.getElementById('dirBtn');
|
const dirBtn = document.getElementById('dirBtn');
|
||||||
const dirPicker = document.getElementById('dirPicker');
|
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 list = document.getElementById('list');
|
||||||
|
|
||||||
|
const MAX_PARALLEL = 3;
|
||||||
|
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let currentTarget = null;
|
let currentTarget = null;
|
||||||
let currentTargetAllowsDirs = false;
|
let currentTargetAllowsDirs = false;
|
||||||
|
|
@ -306,17 +343,34 @@
|
||||||
return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
|
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) {
|
function addItem(file, label) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'item';
|
item.className = 'item';
|
||||||
item.innerHTML =
|
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="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('.name').textContent = label || file.name;
|
||||||
item.querySelector('.row .muted').textContent = formatSize(file.size);
|
item.querySelector('.total').textContent = formatSize(file.size);
|
||||||
list.prepend(item);
|
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) {
|
function setStatus(ui, text, kind) {
|
||||||
|
|
@ -324,27 +378,102 @@
|
||||||
ui.status.className = 'status ' + (kind || 'muted');
|
ui.status.className = 'status ' + (kind || 'muted');
|
||||||
}
|
}
|
||||||
|
|
||||||
function upload(file, relativePath) {
|
// File d'attente : au plus MAX_PARALLEL transferts simultanés.
|
||||||
const ui = addItem(file, relativePath);
|
const queue = [];
|
||||||
|
const active = new Set();
|
||||||
|
|
||||||
const form = new FormData();
|
function updateQueueRow() {
|
||||||
form.append('file', file);
|
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();
|
const params = new URLSearchParams();
|
||||||
if (currentTarget) params.set('target', currentTarget);
|
if (currentTarget) params.set('target', currentTarget);
|
||||||
if (relativePath) params.set('path', relativePath);
|
if (relativePath) params.set('path', relativePath);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
job.xhr = xhr;
|
||||||
|
active.add(job);
|
||||||
xhr.open('POST', query ? '/upload?' + query : '/upload');
|
xhr.open('POST', query ? '/upload?' + query : '/upload');
|
||||||
|
|
||||||
|
let startedAt = 0;
|
||||||
|
let lastAt = 0;
|
||||||
|
let lastLoaded = 0;
|
||||||
|
let speed = 0;
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
if (event.lengthComputable) {
|
if (!event.lengthComputable) return;
|
||||||
ui.bar.style.width = (event.loaded / event.total) * 100 + '%';
|
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', () => {
|
xhr.addEventListener('load', () => {
|
||||||
|
finish();
|
||||||
let body = {};
|
let body = {};
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(xhr.responseText);
|
body = JSON.parse(xhr.responseText);
|
||||||
|
|
@ -353,6 +482,7 @@
|
||||||
}
|
}
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
ui.bar.style.width = '100%';
|
ui.bar.style.width = '100%';
|
||||||
|
ui.progress.textContent = formatSize(file.size);
|
||||||
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
||||||
} else if (xhr.status === 401) {
|
} else if (xhr.status === 401) {
|
||||||
render({ authenticated: false });
|
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');
|
setStatus(ui, 'Envoi…', 'muted');
|
||||||
xhr.send(form);
|
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.
|
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
|
||||||
function handleBatch(items) {
|
function handleBatch(items) {
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
|
|
@ -382,7 +535,8 @@
|
||||||
} else {
|
} else {
|
||||||
banner.hidden = true;
|
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) {
|
function fromFileList(fileList, useRelativePath) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue