mediaDrop/public/upload.html
Nicolas 5ef89e87a4 feat(ui): pause, réessayer, titres tronqués et pleine largeur
- bouton Pause/Reprendre par fichier (#6)
- bouton Réessayer sur les échecs (#7)
- noms de fichiers tronqués avec ellipse + titre au survol (#5)
- contenu pleine largeur (#4)
- cible figée à l'ajout, nouvelle tentative d'init sur 429 (#1, #3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:32:41 +02:00

1032 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mediaDrop</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--card: #ffffff;
--border: #d7dbe0;
--text: #1c1f23;
--muted: #6b7280;
--accent: #2563eb;
--ok: #15803d;
--err: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a;
--card: #1d2125;
--border: #33393f;
--text: #e8eaed;
--muted: #9aa3ad;
--accent: #60a5fa;
--ok: #4ade80;
--err: #f87171;
}
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
body {
margin: 0;
padding: 2rem 1rem;
font:
15px/1.5 system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
background: var(--bg);
color: var(--text);
}
main {
width: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
p.sub {
margin: 0.25rem 0 0;
color: var(--muted);
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
}
.authrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
display: inline-block;
font: inherit;
padding: 0.5rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
text-decoration: none;
cursor: pointer;
}
.btn.secondary {
background: transparent;
color: var(--accent);
}
.authrow form {
margin: 0;
display: flex;
}
.authrow .btn {
white-space: nowrap;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.4rem;
}
select {
width: 100%;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
}
#dropzone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 2.5rem 1rem;
text-align: center;
color: var(--muted);
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
opacity 0.15s;
}
#dropzone.hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--text);
}
#dropArea {
display: grid;
gap: 1.25rem;
}
#list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.6rem;
max-height: 55vh;
overflow-y: auto;
overscroll-behavior: contain;
}
#list:not(:empty) {
padding-right: 0.25rem;
}
.item {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem;
display: grid;
gap: 0.4rem;
}
.item .row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
font-size: 0.9rem;
}
.item {
min-width: 0;
}
.item .name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item .detail {
flex: none;
color: var(--muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.item .controls {
display: inline-flex;
gap: 0.35rem;
margin-left: 0.4rem;
}
.pause:hover,
.retry:hover {
color: var(--accent);
border-color: var(--accent);
}
.pause,
.retry,
.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;
background: var(--border);
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.15s;
}
.status.ok {
color: var(--ok);
}
.status.err {
color: var(--err);
}
.muted {
color: var(--muted);
}
#banner {
color: var(--err);
font-size: 0.9rem;
}
</style>
</head>
<body>
<main>
<header>
<h1>mediaDrop</h1>
<p class="sub">Glissez des fichiers médias pour les déposer.</p>
</header>
<div class="card">
<div class="authrow">
<span class="muted">Connecté : {{USER_NAME}}</span>
<form method="post" action="/auth/logout">
<button class="btn secondary" type="submit">Se déconnecter</button>
</form>
</div>
<div id="banner" hidden></div>
</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>
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
</div>
<div id="list"></div>
</main>
<script>
const banner = document.getElementById('banner');
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 list = document.getElementById('list');
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
const MAX_CHUNKED_JOBS = 32; // fichiers fragmentés actifs à la fois (< 100 côté serveur)
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable
let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
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;
}
// La session a expiré pendant l'utilisation de la page : la route `GET /`
// sert la page de connexion tant qu'elle n'est pas renouvelée.
function goToLogin() {
location.href = '/?login=expired';
}
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;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
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="detail"><span class="total"></span>' +
'<span class="controls">' +
'<button type="button" class="pause" hidden>Pause</button>' +
'<button type="button" class="retry" hidden>Réessayer</button>' +
'<button type="button" class="cancel" hidden>Annuler</button>' +
'</span></span>' +
'</div>' +
'<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span>' +
'<span class="detail progress"></span></div>';
const nameEl = item.querySelector('.name');
nameEl.textContent = label || file.name;
nameEl.title = label || file.name; // nom complet au survol quand il est tronqué
item.querySelector('.total').textContent = formatSize(file.size);
list.prepend(item);
return {
bar: item.querySelector('.bar > span'),
status: item.querySelector('.status'),
progress: item.querySelector('.progress'),
pauseBtn: item.querySelector('.pause'),
retryBtn: item.querySelector('.retry'),
cancelBtn: item.querySelector('.cancel'),
};
}
function setStatus(ui, text, kind) {
ui.status.textContent = text;
ui.status.className = 'status ' + (kind || 'muted');
}
// États d'un job : queued | running | paused | done | failed | cancelled.
// Les boutons visibles en dépendent.
function setState(job, state) {
job.state = state;
if (state === 'done' || state === 'failed' || state === 'cancelled') jobs.delete(job);
else jobs.add(job);
updateQueueRow();
const { pauseBtn, retryBtn, cancelBtn } = job.ui;
pauseBtn.hidden = state !== 'running' && state !== 'paused';
pauseBtn.textContent = state === 'paused' ? 'Reprendre' : 'Pause';
retryBtn.hidden = state !== 'failed';
cancelBtn.hidden = !['queued', 'running', 'paused'].includes(state);
}
function fail(job, text) {
setStatus(job.ui, text, 'err');
setState(job, 'failed');
}
const queue = [];
const active = new Set();
const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause)
function updateQueueRow() {
let running = 0;
let waiting = 0;
let paused = 0;
for (const job of jobs) {
if (job.state === 'running') running += 1;
else if (job.state === 'paused') paused += 1;
else waiting += 1;
}
if (jobs.size === 0) {
queueRow.hidden = true;
return;
}
queueRow.hidden = false;
queueInfo.textContent =
running +
' en cours' +
(paused ? ' · ' + paused + ' en pause' : '') +
(waiting ? ' · ' + waiting + ' en attente' : '');
}
function pump() {
let simple = 0;
let chunked = 0;
for (const j of active) {
if (j.chunked) chunked += 1;
else simple += 1;
}
const kept = [];
for (const job of queue) {
if (job.chunked ? chunked < MAX_CHUNKED_JOBS : simple < MAX_PARALLEL) {
if (job.chunked) chunked += 1;
else simple += 1;
startUpload(job);
} else {
kept.push(job);
}
}
queue.length = 0;
for (const job of kept) queue.push(job);
updateQueueRow();
}
function cancelJob(job) {
if (job.state === 'queued') {
const i = queue.indexOf(job);
if (i >= 0) queue.splice(i, 1);
setStatus(job.ui, 'Annulé', 'err');
setState(job, 'cancelled');
} else if ((job.state === 'running' || job.state === 'paused') && job.cancel) {
job.cancel();
}
}
function retryJob(job) {
job.ui.bar.style.width = '0';
job.ui.progress.textContent = '';
setStatus(job.ui, 'En attente…', 'muted');
setState(job, 'queued');
queue.push(job);
pump();
}
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: currentTarget,
ui,
chunked: file.size > CHUNK_THRESHOLD,
state: 'queued',
};
ui.cancelBtn.onclick = () => cancelJob(job);
ui.pauseBtn.onclick = () => {
if (job.state === 'paused') job.resume();
else if (job.state === 'running') job.pause();
};
ui.retryBtn.onclick = () => retryJob(job);
queue.push(job);
setState(job, 'queued');
}
// Pool global de morceaux en vol, réparti équitablement entre les fichiers
// fragmentés actifs. Chaque fichier est plafonné à sa part équitable
// (ceil(CHUNK_POOL / nb de fichiers actifs)) : 1 fichier => tout le pool,
// N fichiers => une part chacun. Un fichier encore en cours d'initialisation
// (`priming`) compte dans la part pour lui réserver un slot ; à égalité de
// morceaux en vol, le fichier le moins servi passe en premier.
const chunkPool = {
free: CHUNK_POOL,
entries: [],
add(entry) {
this.entries.push(entry);
this.tick();
},
remove(entry) {
const i = this.entries.indexOf(entry);
if (i >= 0) this.entries.splice(i, 1);
this.tick();
},
tick() {
while (this.free > 0) {
const contenders = this.entries.filter(
(e) => !e.stopped && !e.paused && (e.pending.length > 0 || e.priming),
);
if (contenders.length === 0) return;
const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length));
let best = null;
for (const e of this.entries) {
if (e.stopped || e.paused || e.pending.length === 0 || e.inflight >= share) continue;
if (!best || e.inflight < best.inflight) best = e;
}
if (!best) return;
const target = best;
const index = target.pending.shift();
target.inflight += 1;
this.free -= 1;
target.send(index).finally(() => {
target.inflight -= 1;
this.free += 1;
this.tick();
});
}
},
};
// Taille du pool réglée côté serveur (config.json -> GET /config). Ajuste
// `free` du même delta : sûr même si des morceaux sont déjà en vol.
fetch('/config', { headers: { accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => {
const n = cfg && Number(cfg.chunkPoolSize);
if (Number.isInteger(n) && n > 0 && n !== CHUNK_POOL) {
chunkPool.free += n - CHUNK_POOL;
CHUNK_POOL = n;
chunkPool.tick();
}
})
.catch(() => {});
function makeReporter(ui, total) {
let started = 0;
let lastAt = 0;
let lastLoaded = 0;
let speed = 0;
return function report(loaded) {
const now = performance.now();
if (!started) {
started = now;
lastAt = now;
}
const dt = (now - lastAt) / 1000;
if (dt >= 0.25) {
const inst = (loaded - lastLoaded) / dt;
speed = speed ? speed * 0.7 + inst * 0.3 : inst;
lastAt = now;
lastLoaded = loaded;
}
const pct = total ? Math.floor((loaded / total) * 100) : 0;
ui.bar.style.width = pct + '%';
const eta = speed > 0 && total ? formatEta((total - loaded) / speed) : '';
ui.progress.textContent =
formatSize(loaded) +
' / ' +
formatSize(total) +
' · ' +
pct +
' %' +
(speed > 0 ? ' · ' + formatSize(speed) + '/s' : '') +
(eta ? ' · ' + eta : '');
};
}
function uploadQuery(job) {
const p = new URLSearchParams();
if (job.target) p.set('target', job.target);
if (job.relativePath) p.set('path', job.relativePath);
const s = p.toString();
return s ? '?' + s : '';
}
function finish(job) {
active.delete(job);
pump();
}
function applyResult(job, status, body) {
const ui = job.ui;
if (status >= 200 && status < 300) {
ui.bar.style.width = '100%';
ui.progress.textContent = formatSize(job.file.size);
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
setState(job, 'done');
} else if (status === 401) {
fail(job, 'Session expirée');
goToLogin();
} else {
fail(job, 'Erreur ' + status + ' : ' + (body.message || 'échec'));
}
}
function startUpload(job) {
active.add(job);
setStatus(job.ui, 'Envoi…', 'muted');
setState(job, 'running');
if (job.chunked) runChunked(job);
else runSimple(job);
}
// Dépôt direct (petits fichiers) : une seule requête. Une requête HTTP ne
// pouvant pas être suspendue, la pause l'interrompt et la reprise redémarre
// le fichier depuis le début (il est petit, < CHUNK_THRESHOLD).
function runSimple(job) {
const { file, ui } = job;
const report = makeReporter(ui, file.size);
const form = new FormData();
form.append('file', file);
const xhr = new XMLHttpRequest();
let pausing = false;
job.cancel = () => {
if (job.state === 'paused') {
setStatus(ui, 'Annulé', 'err');
setState(job, 'cancelled');
} else {
xhr.abort();
}
};
job.pause = () => {
pausing = true;
xhr.abort();
};
job.resume = () => {
setStatus(ui, 'En attente…', 'muted');
setState(job, 'queued');
queue.push(job);
pump();
};
xhr.open('POST', '/upload' + uploadQuery(job));
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) report(e.loaded);
});
xhr.addEventListener('load', () => {
finish(job);
let body = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
body = {};
}
applyResult(job, xhr.status, body);
});
xhr.addEventListener('error', () => {
finish(job);
fail(job, 'Erreur réseau');
});
xhr.addEventListener('abort', () => {
finish(job);
ui.progress.textContent = '';
ui.bar.style.width = '0';
if (pausing) {
setStatus(ui, 'En pause (reprise au début du fichier)', 'muted');
setState(job, 'paused');
} else {
setStatus(ui, 'Annulé', 'err');
setState(job, 'cancelled');
}
});
xhr.send(form);
}
async function runChunked(job) {
const { file, ui } = job;
const report = makeReporter(ui, file.size);
const inflight = new Set(); // XHR en vol (pour l'annulation et la pause)
let aborted = false;
let sessionId = null;
let failureMessage = null;
let resolveAll = null;
const finished = new Promise((r) => {
resolveAll = r;
});
let settled = false;
const finalize = (outcome) => {
if (!settled) {
settled = true;
resolveAll(outcome);
}
};
// Nettoyage au mieux : une session orpheline est de toute façon purgée par le serveur.
const dropSession = (id) => {
if (id) fetch('/upload/' + id, { method: 'DELETE' }).catch(() => {});
};
// Inscrit dans le pool dès maintenant, avant l'init réseau : le
// planificateur compte ce fichier dans la part équitable (`priming`) et
// lui réserve un slot même si son init n'a pas encore répondu.
const entry = {
inflight: 0,
stopped: false,
paused: false,
priming: true,
pending: [],
send: null,
};
chunkPool.add(entry);
job.cancel = () => {
aborted = true;
entry.stopped = true;
entry.pending.length = 0;
for (const x of inflight) x.abort();
chunkPool.remove(entry);
dropSession(sessionId);
finish(job);
ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err');
setState(job, 'cancelled');
finalize('abort');
};
// Pause : les morceaux en vol sont interrompus puis replanifiés (voir le
// listener `abort`) ; plus rien n'est envoyé tant que `paused` est vrai.
job.pause = () => {
entry.paused = true;
for (const x of Array.from(inflight)) x.abort();
setStatus(ui, 'En pause', 'muted');
setState(job, 'paused');
};
job.resume = () => {
entry.paused = false;
setStatus(ui, 'Envoi…', 'muted');
setState(job, 'running');
chunkPool.tick();
};
let meta;
for (;;) {
let res;
try {
res = await fetch('/upload/init' + uploadQuery(job), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
}),
});
meta = await res.json().catch(() => ({}));
} catch {
if (aborted) return;
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
fail(job, 'Erreur réseau (init)');
return;
}
if (aborted) {
if (res.ok) dropSession(meta.id);
return;
}
if (res.status === 429) {
// Serveur saturé (autres fenêtres/onglets) : on patiente au lieu d'échouer.
setStatus(ui, 'Serveur occupé, nouvelle tentative…', 'muted');
await new Promise((resolve) => setTimeout(resolve, INIT_RETRY_MS));
if (aborted) return;
continue;
}
if (!res.ok) {
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
applyResult(job, res.status, meta);
return;
}
break;
}
if (job.state === 'running') setStatus(ui, 'Envoi…', 'muted');
sessionId = meta.id;
const chunkSize = meta.chunkSize;
const chunkCount = meta.chunkCount;
const loadedPer = new Array(chunkCount).fill(0);
const attempts = new Array(chunkCount).fill(0);
const emit = () => report(loadedPer.reduce((a, b) => a + b, 0));
let doneCount = 0;
function retry(index, status, releasePermit) {
if (aborted || entry.stopped) {
releasePermit();
return;
}
attempts[index] += 1;
if (attempts[index] > CHUNK_RETRIES) {
entry.stopped = true;
entry.pending.length = 0;
failureMessage =
status === 404
? 'Session expirée côté serveur, réessayez'
: 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')';
finalize('fail');
releasePermit();
return;
}
setTimeout(() => {
if (!aborted && !entry.stopped) {
entry.pending.unshift(index);
chunkPool.tick();
}
}, 400 * attempts[index]);
releasePermit(); // libère le permis ; le morceau est re-planifié après le backoff
}
entry.pending = Array.from({ length: chunkCount }, (_, i) => i);
entry.priming = false;
entry.send = (index) =>
new Promise((releasePermit) => {
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const xhr = new XMLHttpRequest();
inflight.add(xhr);
xhr.open('PUT', '/upload/' + sessionId + '/' + index);
xhr.setRequestHeader('content-type', 'application/octet-stream');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
loadedPer[index] = e.loaded;
emit();
}
});
xhr.addEventListener('load', () => {
inflight.delete(xhr);
if (xhr.status >= 200 && xhr.status < 300) {
loadedPer[index] = end - start;
emit();
doneCount += 1;
if (doneCount === chunkCount) finalize('ok');
releasePermit();
} else if (xhr.status === 401) {
entry.stopped = true;
entry.pending.length = 0;
applyResult(job, 401, {});
finalize('fail');
releasePermit();
} else {
retry(index, xhr.status, releasePermit);
}
});
xhr.addEventListener('error', () => {
inflight.delete(xhr);
retry(index, 0, releasePermit);
});
xhr.addEventListener('abort', () => {
inflight.delete(xhr);
if (!aborted && !entry.stopped) {
// Pause : le morceau interrompu sera renvoyé à la reprise.
loadedPer[index] = 0;
emit();
entry.pending.unshift(index);
}
releasePermit();
});
xhr.send(file.slice(start, end));
});
chunkPool.tick();
const outcome = await finished;
chunkPool.remove(entry);
if (outcome === 'abort') return;
if (outcome === 'fail') {
for (const x of inflight) x.abort();
finish(job);
dropSession(sessionId);
if (failureMessage) fail(job, failureMessage);
return;
}
try {
const res = await fetch('/upload/' + sessionId + '/complete', { method: 'POST' });
const body = await res.json().catch(() => ({}));
finish(job);
applyResult(job, res.status, body);
} catch {
finish(job);
dropSession(sessionId);
fail(job, 'Erreur réseau (finalisation)');
}
}
function cancelAll() {
for (const job of Array.from(jobs)) cancelJob(job);
}
queueCancel.addEventListener('click', cancelAll);
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items) {
let batch = items;
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);
pump();
}
function fromFileList(fileList, useRelativePath) {
return Array.from(fileList, (file) => ({
file,
path: useRelativePath ? file.webkitRelativePath || '' : '',
}));
}
function readAllEntries(reader) {
return new Promise((resolve, reject) => {
const acc = [];
const step = () =>
reader.readEntries((batch) => {
if (batch.length === 0) resolve(acc);
else {
acc.push(...batch);
step();
}
}, reject);
step();
});
}
async function walkEntry(entry, prefix, out) {
if (entry.isFile) {
const file = await new Promise((res, rej) => entry.file(res, rej));
out.push({ file, path: prefix ? prefix + entry.name : '' });
} else if (entry.isDirectory) {
const children = await readAllEntries(entry.createReader());
for (const child of children) await walkEntry(child, prefix + entry.name + '/', out);
}
}
async function collectDropItems(dataTransfer) {
const entries = [];
for (const item of Array.from(dataTransfer.items || [])) {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) entries.push(entry);
}
if (entries.length === 0) {
return fromFileList(dataTransfer.files || [], false);
}
const out = [];
for (const entry of entries) await walkEntry(entry, '', out);
return out;
}
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 = '';
});
['dragenter', 'dragover'].forEach((type) => {
dropzone.addEventListener(type, (event) => {
event.preventDefault();
dropzone.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');
});
});
dropzone.addEventListener('drop', (event) => {
if (!event.dataTransfer) return;
collectDropItems(event.dataTransfer)
.then((items) => {
if (items.length) handleBatch(items);
})
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
});
</script>
</body>
</html>