mediaDrop/public/upload.html
Nicolas f637ad4eeb feat(upload): reprise des morceaux en échec via « Réessayer »
Ajoute GET /upload/:id (morceaux reçus). Le client conserve la session
après échec et ne renvoie que les morceaux manquants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:23:53 +02:00

1105 lines
36 KiB
HTML
Raw Permalink 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);
}
.dropzone strong {
color: var(--text);
}
#zones {
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);
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="zones"></div>
<div id="queueRow" class="card" hidden>
<span id="queueInfo" class="muted"></span>
<span class="queue-actions">
<button id="queueRetry" type="button" class="btn secondary" hidden>Tout réessayer</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 id="list"></div>
</main>
<script>
const banner = document.getElementById('banner');
const zones = document.getElementById('zones');
const queueRow = document.getElementById('queueRow');
const queueInfo = document.getElementById('queueInfo');
const queueCancel = document.getElementById('queueCancel');
const queuePause = document.getElementById('queuePause');
const queueRetry = document.getElementById('queueRetry');
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
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 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, targetLabel) {
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>';
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é
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);
if (state === 'failed') failedJobs.add(job);
else failedJobs.delete(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)
const failedJobs = new Set(); // jobs en échec, relançables (« Réessayer » / « Tout réessayer »)
let allPaused = false; // « Tout mettre en pause » actif : la file ne démarre plus rien
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;
}
const failed = failedJobs.size;
const hasJobs = jobs.size > 0;
if (!hasJobs) {
allPaused = false;
queuePause.textContent = 'Tout mettre en pause';
}
queueRow.hidden = !hasJobs && failed === 0;
queuePause.hidden = !hasJobs;
queueCancel.hidden = !hasJobs;
queueRetry.hidden = failed === 0;
queueInfo.textContent = [
hasJobs ? running + ' en cours' : '',
paused ? paused + ' en pause' : '',
waiting ? waiting + ' en attente' : '',
failed ? failed + ' en échec' : '',
]
.filter(Boolean)
.join(' · ');
}
function pump() {
if (allPaused) {
updateQueueRow();
return;
}
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, dropTarget) {
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.
const job = {
file,
relativePath,
target: dropTarget.name,
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();
};
// Reprise après un échec : la session serveur est conservée dans `job.sessionId`,
// on récupère les morceaux déjà reçus pour ne renvoyer que les manquants.
let meta;
let alreadyReceived = [];
if (job.sessionId) {
try {
const status = await fetch('/upload/' + job.sessionId);
if (aborted) return;
if (status.ok) {
meta = await status.json();
alreadyReceived = Array.isArray(meta.received) ? meta.received : [];
} else {
job.sessionId = null; // session expirée côté serveur : on repart de zéro
}
} catch {
if (aborted) return;
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
fail(job, 'Erreur réseau (reprise)');
return;
}
}
const resumed = Boolean(meta);
while (!resumed) {
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;
job.sessionId = sessionId;
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));
const skip = new Set(alreadyReceived);
for (const i of skip) {
loadedPer[i] = Math.min(chunkSize, file.size - i * chunkSize);
}
let doneCount = skip.size;
if (doneCount > 0) emit();
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;
if (status === 404) job.sessionId = null; // plus rien à reprendre côté serveur
failureMessage =
status === 404
? 'Session expirée côté serveur, réessayez'
: 'Erreur morceau ' +
index +
' (' +
(status || 'réseau') +
') — « Réessayer » reprend ici';
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).filter((i) => !skip.has(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));
});
if (doneCount === chunkCount) finalize('ok'); // reprise : tout était déjà reçu
chunkPool.tick();
const outcome = await finished;
chunkPool.remove(entry);
if (outcome === 'abort') return;
if (outcome === 'fail') {
// La session serveur est conservée : « Réessayer » reprend aux morceaux manquants.
for (const x of inflight) x.abort();
finish(job);
if (failureMessage) fail(job, failureMessage);
return;
}
try {
const res = await fetch('/upload/' + sessionId + '/complete', { method: 'POST' });
const body = await res.json().catch(() => ({}));
finish(job);
if (res.ok || res.status === 404) job.sessionId = null;
applyResult(job, res.status, body);
} catch {
finish(job);
fail(job, 'Erreur réseau (finalisation)');
}
}
function cancelAll() {
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();
}
function retryAll() {
for (const job of Array.from(failedJobs)) retryJob(job);
}
queuePause.addEventListener('click', toggleAllPaused);
queueRetry.addEventListener('click', retryAll);
queueCancel.addEventListener('click', cancelAll);
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items, target) {
let batch = items;
if (!target.allowDirectories && batch.some((entry) => entry.path)) {
showBanner('« ' + target.label + ' » 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);
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;
}
// 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';
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) => {
zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.add('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() : [];
})
.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>
</body>
</html>