Compare commits
No commits in common. "8d115622ce7ae8e4a73aa75ee829a4df6b98c5a5" and "3ef7b09f84094b387d8574dee2dfda993d2727c4" have entirely different histories.
8d115622ce
...
3ef7b09f84
2 changed files with 118 additions and 153 deletions
|
|
@ -110,7 +110,7 @@
|
|||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.dropzone {
|
||||
#dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 2.5rem 1rem;
|
||||
|
|
@ -122,32 +122,15 @@
|
|||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
.dropzone.hover {
|
||||
#dropzone.hover {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.dropzone strong {
|
||||
color: var(--text);
|
||||
}
|
||||
#zones {
|
||||
#dropArea {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
|
@ -267,14 +250,28 @@
|
|||
<div id="banner" hidden></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>
|
||||
<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>
|
||||
</span>
|
||||
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
|
||||
</div>
|
||||
|
||||
<div id="list"></div>
|
||||
|
|
@ -282,11 +279,16 @@
|
|||
|
||||
<script>
|
||||
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 queueInfo = document.getElementById('queueInfo');
|
||||
const queueCancel = document.getElementById('queueCancel');
|
||||
const queuePause = document.getElementById('queuePause');
|
||||
const list = document.getElementById('list');
|
||||
|
||||
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
|
||||
|
|
@ -296,6 +298,9 @@
|
|||
const CHUNK_RETRIES = 3;
|
||||
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) {
|
||||
banner.textContent = message;
|
||||
banner.hidden = false;
|
||||
|
|
@ -309,6 +314,53 @@
|
|||
|
||||
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) {
|
||||
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
|
||||
let value = bytes;
|
||||
|
|
@ -327,7 +379,7 @@
|
|||
return '~' + (seconds / 3600).toFixed(1) + ' h';
|
||||
}
|
||||
|
||||
function addItem(file, label, targetLabel) {
|
||||
function addItem(file, label) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item';
|
||||
item.innerHTML =
|
||||
|
|
@ -343,13 +395,6 @@
|
|||
'<div class="bar"><span></span></div>' +
|
||||
'<div class="row"><span class="status muted">En attente…</span>' +
|
||||
'<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');
|
||||
nameEl.textContent = label || file.name;
|
||||
nameEl.title = label || file.name; // nom complet au survol quand il est tronqué
|
||||
|
|
@ -393,7 +438,6 @@
|
|||
const active = new Set();
|
||||
|
||||
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() {
|
||||
let running = 0;
|
||||
|
|
@ -406,8 +450,6 @@
|
|||
}
|
||||
if (jobs.size === 0) {
|
||||
queueRow.hidden = true;
|
||||
allPaused = false;
|
||||
queuePause.textContent = 'Tout mettre en pause';
|
||||
return;
|
||||
}
|
||||
queueRow.hidden = false;
|
||||
|
|
@ -419,10 +461,6 @@
|
|||
}
|
||||
|
||||
function pump() {
|
||||
if (allPaused) {
|
||||
updateQueueRow();
|
||||
return;
|
||||
}
|
||||
let simple = 0;
|
||||
let chunked = 0;
|
||||
for (const j of active) {
|
||||
|
|
@ -464,13 +502,13 @@
|
|||
pump();
|
||||
}
|
||||
|
||||
function enqueue(file, relativePath, dropTarget) {
|
||||
const ui = addItem(file, relativePath, targets.length > 1 ? dropTarget.label : '');
|
||||
function enqueue(file, relativePath) {
|
||||
const ui = addItem(file, relativePath);
|
||||
// La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file.
|
||||
const job = {
|
||||
file,
|
||||
relativePath,
|
||||
target: dropTarget.name,
|
||||
target: currentTarget,
|
||||
ui,
|
||||
chunked: file.size > CHUNK_THRESHOLD,
|
||||
state: 'queued',
|
||||
|
|
@ -896,31 +934,18 @@
|
|||
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);
|
||||
|
||||
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
|
||||
function handleBatch(items, target) {
|
||||
function handleBatch(items) {
|
||||
let batch = items;
|
||||
if (!target.allowDirectories && batch.some((entry) => entry.path)) {
|
||||
showBanner('« ' + target.label + ' » n’accepte pas le dépôt de dossiers.');
|
||||
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
|
||||
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
|
||||
batch = batch.filter((entry) => !entry.path);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -970,85 +995,38 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
// Une zone de dépôt par cible : clic = sélecteur de fichiers, glisser-déposer =
|
||||
// fichiers et (si la cible l'autorise) dossiers. Un champ natif ne peut pas
|
||||
// choisir fichiers et dossiers à la fois, d'où le dossier par glisser-déposer.
|
||||
function createZone(target) {
|
||||
const zone = document.createElement('div');
|
||||
zone.className = 'dropzone card';
|
||||
dropzone.addEventListener('click', () => picker.click());
|
||||
picker.addEventListener('change', () => {
|
||||
handleBatch(fromFileList(picker.files, false));
|
||||
picker.value = '';
|
||||
});
|
||||
dirBtn.addEventListener('click', () => dirPicker.click());
|
||||
dirPicker.addEventListener('change', () => {
|
||||
handleBatch(fromFileList(dirPicker.files, true));
|
||||
dirPicker.value = '';
|
||||
});
|
||||
|
||||
const title = document.createElement('strong');
|
||||
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) => {
|
||||
dropzone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add('hover');
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach((type) => {
|
||||
zone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
zone.classList.add('hover');
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach((type) => {
|
||||
dropzone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return;
|
||||
dropzone.classList.remove('hover');
|
||||
});
|
||||
['dragleave', 'drop'].forEach((type) => {
|
||||
zone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
if (type === 'dragleave' && zone.contains(event.relatedTarget)) return;
|
||||
zone.classList.remove('hover');
|
||||
});
|
||||
});
|
||||
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() : [];
|
||||
});
|
||||
dropzone.addEventListener('drop', (event) => {
|
||||
if (!event.dataTransfer) return;
|
||||
collectDropItems(event.dataTransfer)
|
||||
.then((items) => {
|
||||
if (items.length) handleBatch(items);
|
||||
})
|
||||
.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();
|
||||
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe('GET /', () => {
|
|||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
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');
|
||||
});
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ describe('GET /', () => {
|
|||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('Connecté : Alice');
|
||||
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('href="/auth/login"');
|
||||
});
|
||||
|
|
@ -86,19 +86,6 @@ describe('GET /', () => {
|
|||
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 () => {
|
||||
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
|
||||
const res = await app.inject({
|
||||
|
|
@ -118,6 +105,6 @@ describe('GET /', () => {
|
|||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('href="/auth/login"');
|
||||
expect(res.body).not.toContain('id="zones"');
|
||||
expect(res.body).not.toContain('id="dropzone"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue