Compare commits

..

No commits in common. "8d115622ce7ae8e4a73aa75ee829a4df6b98c5a5" and "3ef7b09f84094b387d8574dee2dfda993d2727c4" have entirely different histories.

2 changed files with 118 additions and 153 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,32 +122,15 @@
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);
} }
.dropzone strong { #dropArea {
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);
@ -267,14 +250,28 @@
<div id="banner" hidden></div> <div id="banner" hidden></div>
</div> </div>
<div id="zones"></div> <div id="targetCard" class="card" hidden>
<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="queueCancel" type="button" class="btn secondary">Tout annuler</button>
<button id="queuePause" type="button" class="btn secondary">Tout mettre en pause</button>
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
</span>
</div> </div>
<div id="list"></div> <div id="list"></div>
@ -282,11 +279,16 @@
<script> <script>
const banner = document.getElementById('banner'); const banner = document.getElementById('banner');
const zones = document.getElementById('zones'); const targetCard = document.getElementById('targetCard');
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
@ -296,6 +298,9 @@
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;
@ -309,6 +314,53 @@
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;
@ -327,7 +379,7 @@
return '~' + (seconds / 3600).toFixed(1) + ' h'; return '~' + (seconds / 3600).toFixed(1) + ' h';
} }
function addItem(file, label, targetLabel) { function addItem(file, label) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'item'; item.className = 'item';
item.innerHTML = item.innerHTML =
@ -343,13 +395,6 @@
'<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é
@ -393,7 +438,6 @@
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;
@ -406,8 +450,6 @@
} }
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;
@ -419,10 +461,6 @@
} }
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) {
@ -464,13 +502,13 @@
pump(); pump();
} }
function enqueue(file, relativePath, dropTarget) { function enqueue(file, relativePath) {
const ui = addItem(file, relativePath, targets.length > 1 ? dropTarget.label : ''); const ui = addItem(file, relativePath);
// 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: dropTarget.name, target: currentTarget,
ui, ui,
chunked: file.size > CHUNK_THRESHOLD, chunked: file.size > CHUNK_THRESHOLD,
state: 'queued', state: 'queued',
@ -896,31 +934,18 @@
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, target) { function handleBatch(items) {
let batch = items; let batch = items;
if (!target.allowDirectories && batch.some((entry) => entry.path)) { if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
showBanner('« ' + target.label + ' » n’accepte pas le dépôt de dossiers.'); showBanner('La cible sélectionnée 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, target); for (const entry of batch) enqueue(entry.file, entry.path);
pump(); pump();
} }
@ -970,85 +995,38 @@
return out; return out;
} }
// Une zone de dépôt par cible : clic = sélecteur de fichiers, glisser-déposer = dropzone.addEventListener('click', () => picker.click());
// fichiers et (si la cible l'autorise) dossiers. Un champ natif ne peut pas picker.addEventListener('change', () => {
// choisir fichiers et dossiers à la fois, d'où le dossier par glisser-déposer. handleBatch(fromFileList(picker.files, false));
function createZone(target) { picker.value = '';
const zone = document.createElement('div'); });
zone.className = 'dropzone card'; dirBtn.addEventListener('click', () => dirPicker.click());
dirPicker.addEventListener('change', () => {
handleBatch(fromFileList(dirPicker.files, true));
dirPicker.value = '';
});
const title = document.createElement('strong'); ['dragenter', 'dragover'].forEach((type) => {
title.textContent = target.label; dropzone.addEventListener(type, (event) => {
const hint = document.createElement('span'); event.preventDefault();
hint.className = 'muted'; dropzone.classList.add('hover');
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) => { ['dragleave', 'drop'].forEach((type) => {
zone.addEventListener(type, (event) => { dropzone.addEventListener(type, (event) => {
event.preventDefault(); event.preventDefault();
zone.classList.add('hover'); if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return;
}); dropzone.classList.remove('hover');
}); });
['dragleave', 'drop'].forEach((type) => { });
zone.addEventListener(type, (event) => { dropzone.addEventListener('drop', (event) => {
event.preventDefault(); if (!event.dataTransfer) return;
if (type === 'dragleave' && zone.contains(event.relatedTarget)) return; collectDropItems(event.dataTransfer)
zone.classList.remove('hover'); .then((items) => {
}); if (items.length) handleBatch(items);
});
zone.addEventListener('drop', (event) => {
if (!event.dataTransfer) return;
collectDropItems(event.dataTransfer)
.then((items) => {
if (items.length) handleBatch(items, target);
})
.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) => { .catch(() => showBanner('Impossible de lire le contenu déposé.'));
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="zones"'); expect(res.body).not.toContain('id="dropzone"');
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="zones"'); expect(res.body).toContain('id="dropzone"');
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,19 +86,6 @@ 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({
@ -118,6 +105,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="zones"'); expect(res.body).not.toContain('id="dropzone"');
}); });
}); });