Compare commits

..

2 commits

Author SHA1 Message Date
8d115622ce Merge pull request 'feat(ui): une zone de dépôt par cible, pause globale' (#15) from feat/upload-ui-v2 into main
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 26s
2026-10-14 07:43:14 +02:00
58588b9dbd feat(ui): une zone de dépôt par cible, pause globale (#10, #11, #13)
- une zone de dépôt par cible à la place du menu déroulant, avec pastille
  de catégorie sur chaque fichier quand il y a plusieurs cibles (#11)
- un seul sélecteur : le doublon « Parcourir un dossier » disparaît, les
  dossiers passent par glisser-déposer (#10)
- bouton « Tout mettre en pause / Tout reprendre » à côté de « Tout annuler » (#13)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:39:02 +02:00
2 changed files with 153 additions and 118 deletions

View file

@ -110,7 +110,7 @@
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
#dropzone { .dropzone {
border: 2px dashed var(--border); border: 2px dashed var(--border);
border-radius: 10px; border-radius: 10px;
padding: 2.5rem 1rem; padding: 2.5rem 1rem;
@ -122,15 +122,32 @@
background 0.15s, background 0.15s,
opacity 0.15s; opacity 0.15s;
} }
#dropzone.hover { .dropzone.hover {
border-color: var(--accent); border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--text); color: var(--text);
} }
#dropArea { .dropzone strong {
color: var(--text);
}
#zones {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem; gap: 1.25rem;
} }
.queue-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
flex: none;
font-size: 0.75rem;
padding: 0.05rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--accent);
color: var(--accent);
}
#list { #list {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@ -250,28 +267,14 @@
<div id="banner" hidden></div> <div id="banner" hidden></div>
</div> </div>
<div id="targetCard" class="card" hidden> <div id="zones"></div>
<label for="targetSelect">Dossier de dépôt</label>
<select id="targetSelect"></select>
</div>
<div id="dropArea">
<div id="dropzone" class="card">
<strong>Déposez fichiers ou dossiers ici</strong><br />
<span class="muted">ou cliquez pour parcourir</span>
<input id="picker" type="file" multiple hidden />
</div>
<div id="dirRow" class="card" hidden>
<button id="dirBtn" type="button" class="btn secondary">Parcourir un dossier…</button>
<span class="muted"> — ou glissez un dossier dans la zone ci-dessus</span>
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
</div>
</div>
<div id="queueRow" class="card" hidden> <div id="queueRow" class="card" hidden>
<span id="queueInfo" class="muted"></span> <span id="queueInfo" class="muted"></span>
<span class="queue-actions">
<button id="queuePause" type="button" class="btn secondary">Tout mettre en pause</button>
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button> <button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
</span>
</div> </div>
<div id="list"></div> <div id="list"></div>
@ -279,16 +282,11 @@
<script> <script>
const banner = document.getElementById('banner'); const banner = document.getElementById('banner');
const targetCard = document.getElementById('targetCard'); const zones = document.getElementById('zones');
const targetSelect = document.getElementById('targetSelect');
const dropzone = document.getElementById('dropzone');
const picker = document.getElementById('picker');
const dirRow = document.getElementById('dirRow');
const dirBtn = document.getElementById('dirBtn');
const dirPicker = document.getElementById('dirPicker');
const queueRow = document.getElementById('queueRow'); const queueRow = document.getElementById('queueRow');
const queueInfo = document.getElementById('queueInfo'); const queueInfo = document.getElementById('queueInfo');
const queueCancel = document.getElementById('queueCancel'); const queueCancel = document.getElementById('queueCancel');
const queuePause = document.getElementById('queuePause');
const list = document.getElementById('list'); const list = document.getElementById('list');
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
@ -298,9 +296,6 @@
const CHUNK_RETRIES = 3; const CHUNK_RETRIES = 3;
const INIT_RETRY_MS = 2000; // serveur saturé (429) : délai avant de réessayer l'init const INIT_RETRY_MS = 2000; // serveur saturé (429) : délai avant de réessayer l'init
let currentTarget = null;
let currentTargetAllowsDirs = false;
function showBanner(message) { function showBanner(message) {
banner.textContent = message; banner.textContent = message;
banner.hidden = false; banner.hidden = false;
@ -314,53 +309,6 @@
let targets = []; let targets = [];
function applyTargetSelection() {
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
currentTarget = target ? target.name : null;
currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
dirRow.hidden = !currentTargetAllowsDirs;
// Filtre le sélecteur natif de fichiers selon les types acceptés par la cible
// (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
const mimes = target && target.allowedMimeTypes;
picker.accept =
Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*')
? mimes.join(',')
: '';
}
function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => {
if (r.status === 401) {
goToLogin();
return [];
}
return r.ok ? r.json() : [];
})
.then((data) => {
targets = Array.isArray(data) ? data : [];
targetSelect.replaceChildren();
targets.forEach((target) => {
const option = document.createElement('option');
option.value = target.name;
option.textContent = target.label;
targetSelect.appendChild(option);
});
targetCard.hidden = targets.length < 2;
applyTargetSelection();
})
.catch(() => {
targets = [];
currentTarget = null;
currentTargetAllowsDirs = false;
targetCard.hidden = true;
dirRow.hidden = true;
});
}
targetSelect.addEventListener('change', applyTargetSelection);
loadTargets();
function formatSize(bytes) { function formatSize(bytes) {
const units = ['o', 'Ko', 'Mo', 'Go', 'To']; const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
let value = bytes; let value = bytes;
@ -379,7 +327,7 @@
return '~' + (seconds / 3600).toFixed(1) + ' h'; return '~' + (seconds / 3600).toFixed(1) + ' h';
} }
function addItem(file, label) { function addItem(file, label, targetLabel) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'item'; item.className = 'item';
item.innerHTML = item.innerHTML =
@ -395,6 +343,13 @@
'<div class="bar"><span></span></div>' + '<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span>' + '<div class="row"><span class="status muted">En attente…</span>' +
'<span class="detail progress"></span></div>'; '<span class="detail progress"></span></div>';
if (targetLabel) {
// Pastille de catégorie, utile seulement quand plusieurs cibles existent.
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = targetLabel;
item.querySelector('.row').prepend(badge);
}
const nameEl = item.querySelector('.name'); const nameEl = item.querySelector('.name');
nameEl.textContent = label || file.name; nameEl.textContent = label || file.name;
nameEl.title = label || file.name; // nom complet au survol quand il est tronqué nameEl.title = label || file.name; // nom complet au survol quand il est tronqué
@ -438,6 +393,7 @@
const active = new Set(); const active = new Set();
const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause) const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause)
let allPaused = false; // « Tout mettre en pause » actif : la file ne démarre plus rien
function updateQueueRow() { function updateQueueRow() {
let running = 0; let running = 0;
@ -450,6 +406,8 @@
} }
if (jobs.size === 0) { if (jobs.size === 0) {
queueRow.hidden = true; queueRow.hidden = true;
allPaused = false;
queuePause.textContent = 'Tout mettre en pause';
return; return;
} }
queueRow.hidden = false; queueRow.hidden = false;
@ -461,6 +419,10 @@
} }
function pump() { function pump() {
if (allPaused) {
updateQueueRow();
return;
}
let simple = 0; let simple = 0;
let chunked = 0; let chunked = 0;
for (const j of active) { for (const j of active) {
@ -502,13 +464,13 @@
pump(); pump();
} }
function enqueue(file, relativePath) { function enqueue(file, relativePath, dropTarget) {
const ui = addItem(file, relativePath); const ui = addItem(file, relativePath, targets.length > 1 ? dropTarget.label : '');
// La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file. // La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file.
const job = { const job = {
file, file,
relativePath, relativePath,
target: currentTarget, target: dropTarget.name,
ui, ui,
chunked: file.size > CHUNK_THRESHOLD, chunked: file.size > CHUNK_THRESHOLD,
state: 'queued', state: 'queued',
@ -934,18 +896,31 @@
for (const job of Array.from(jobs)) cancelJob(job); for (const job of Array.from(jobs)) cancelJob(job);
} }
// Bascule globale : met en pause les envois en cours et fige la file (aucun fichier en
// attente ne démarre), puis « Tout reprendre » relance le tout.
function toggleAllPaused() {
allPaused = !allPaused;
queuePause.textContent = allPaused ? 'Tout reprendre' : 'Tout mettre en pause';
for (const job of Array.from(jobs)) {
if (allPaused && job.state === 'running') job.pause();
else if (!allPaused && job.state === 'paused') job.resume();
}
pump();
}
queuePause.addEventListener('click', toggleAllPaused);
queueCancel.addEventListener('click', cancelAll); 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, target) {
let batch = items; let batch = items;
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) { if (!target.allowDirectories && batch.some((entry) => entry.path)) {
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.'); showBanner('« ' + target.label + ' » n’accepte pas le dépôt de dossiers.');
batch = batch.filter((entry) => !entry.path); batch = batch.filter((entry) => !entry.path);
} else { } else {
banner.hidden = true; banner.hidden = true;
} }
for (const entry of batch) enqueue(entry.file, entry.path); for (const entry of batch) enqueue(entry.file, entry.path, target);
pump(); pump();
} }
@ -995,38 +970,85 @@
return out; return out;
} }
dropzone.addEventListener('click', () => picker.click()); // Une zone de dépôt par cible : clic = sélecteur de fichiers, glisser-déposer =
picker.addEventListener('change', () => { // fichiers et (si la cible l'autorise) dossiers. Un champ natif ne peut pas
handleBatch(fromFileList(picker.files, false)); // choisir fichiers et dossiers à la fois, d'où le dossier par glisser-déposer.
picker.value = ''; function createZone(target) {
}); const zone = document.createElement('div');
dirBtn.addEventListener('click', () => dirPicker.click()); zone.className = 'dropzone card';
dirPicker.addEventListener('change', () => {
handleBatch(fromFileList(dirPicker.files, true)); const title = document.createElement('strong');
dirPicker.value = ''; title.textContent = target.label;
const hint = document.createElement('span');
hint.className = 'muted';
hint.textContent = target.allowDirectories
? 'Déposez fichiers ou dossiers ici, ou cliquez pour parcourir'
: 'Déposez des fichiers ici, ou cliquez pour parcourir';
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.hidden = true;
// Filtre le sélecteur natif selon les types acceptés par la cible (le serveur
// reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
const mimes = target.allowedMimeTypes;
input.accept =
Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*')
? mimes.join(',')
: '';
zone.append(title, document.createElement('br'), hint, input);
zone.addEventListener('click', () => input.click());
// Le clic synthétique du champ remonterait jusqu'à la zone et rouvrirait le sélecteur.
input.addEventListener('click', (event) => event.stopPropagation());
input.addEventListener('change', () => {
handleBatch(fromFileList(input.files, false), target);
input.value = '';
}); });
['dragenter', 'dragover'].forEach((type) => { ['dragenter', 'dragover'].forEach((type) => {
dropzone.addEventListener(type, (event) => { zone.addEventListener(type, (event) => {
event.preventDefault(); event.preventDefault();
dropzone.classList.add('hover'); zone.classList.add('hover');
}); });
}); });
['dragleave', 'drop'].forEach((type) => { ['dragleave', 'drop'].forEach((type) => {
dropzone.addEventListener(type, (event) => { zone.addEventListener(type, (event) => {
event.preventDefault(); event.preventDefault();
if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return; if (type === 'dragleave' && zone.contains(event.relatedTarget)) return;
dropzone.classList.remove('hover'); zone.classList.remove('hover');
}); });
}); });
dropzone.addEventListener('drop', (event) => { zone.addEventListener('drop', (event) => {
if (!event.dataTransfer) return; if (!event.dataTransfer) return;
collectDropItems(event.dataTransfer) collectDropItems(event.dataTransfer)
.then((items) => { .then((items) => {
if (items.length) handleBatch(items); if (items.length) handleBatch(items, target);
}) })
.catch(() => showBanner('Impossible de lire le contenu déposé.')); .catch(() => showBanner('Impossible de lire le contenu déposé.'));
}); });
return zone;
}
function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => {
if (r.status === 401) {
goToLogin();
return [];
}
return r.ok ? r.json() : [];
})
.then((data) => {
targets = Array.isArray(data) ? data : [];
zones.replaceChildren(...targets.map(createZone));
if (targets.length === 0) showBanner('Aucune cible de dépôt disponible.');
})
.catch(() => showBanner('Impossible de charger les cibles de dépôt.'));
}
loadTargets();
</script> </script>
</body> </body>
</html> </html>

View file

@ -54,7 +54,7 @@ describe('GET /', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html'); expect(res.headers['content-type']).toContain('text/html');
expect(res.body).toContain('href="/auth/login"'); expect(res.body).toContain('href="/auth/login"');
expect(res.body).not.toContain('id="dropzone"'); expect(res.body).not.toContain('id="zones"');
expect(res.body).not.toContain('Se déconnecter'); expect(res.body).not.toContain('Se déconnecter');
}); });
@ -68,7 +68,7 @@ describe('GET /', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toContain('Connecté : Alice'); expect(res.body).toContain('Connecté : Alice');
expect(res.body).toContain('Se déconnecter'); expect(res.body).toContain('Se déconnecter');
expect(res.body).toContain('id="dropzone"'); expect(res.body).toContain('id="zones"');
expect(res.body).not.toContain('{{USER_NAME}}'); expect(res.body).not.toContain('{{USER_NAME}}');
expect(res.body).not.toContain('href="/auth/login"'); expect(res.body).not.toContain('href="/auth/login"');
}); });
@ -86,6 +86,19 @@ describe('GET /', () => {
expect(res.body).not.toContain('max-width: 640px'); expect(res.body).not.toContain('max-width: 640px');
}); });
it('page de dépôt : une zone par cible, plus de menu déroulant ni de second sélecteur', async () => {
const cookie = await login(app, idp);
const res = await app.inject({
method: 'GET',
url: '/',
cookies: { [SESSION_COOKIE]: cookie },
});
expect(res.body).toContain('id="zones"');
expect(res.body).toContain('id="queuePause"');
expect(res.body).not.toContain('targetSelect');
expect(res.body).not.toContain('dirPicker');
});
it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => { it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => {
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' }); const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
const res = await app.inject({ const res = await app.inject({
@ -105,6 +118,6 @@ describe('GET /', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toContain('href="/auth/login"'); expect(res.body).toContain('href="/auth/login"');
expect(res.body).not.toContain('id="dropzone"'); expect(res.body).not.toContain('id="zones"');
}); });
}); });