- 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>
1054 lines
34 KiB
HTML
1054 lines
34 KiB
HTML
<!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="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 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);
|
||
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)
|
||
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;
|
||
}
|
||
if (jobs.size === 0) {
|
||
queueRow.hidden = true;
|
||
allPaused = false;
|
||
queuePause.textContent = 'Tout mettre en pause';
|
||
return;
|
||
}
|
||
queueRow.hidden = false;
|
||
queueInfo.textContent =
|
||
running +
|
||
' en cours' +
|
||
(paused ? ' · ' + paused + ' en pause' : '') +
|
||
(waiting ? ' · ' + waiting + ' en attente' : '');
|
||
}
|
||
|
||
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();
|
||
};
|
||
|
||
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);
|
||
}
|
||
|
||
// 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) {
|
||
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>
|