mediaDrop/public/index.html
Nicolas 8d8cfb6fad
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 8s
UI : liste scrollable, alignement auth, zones de dépôt retirées du DOM hors session
- `#list` plafonné à 55vh avec défilement interne (au lieu d'allonger la page).
- En-tête d'auth : suppression du <span> qui cassait l'alignement ; « Connecté : … »
  et le bouton « Se déconnecter » sont alignés (flex, space-between).
- Les zones de dépôt (`#dropzone` + `#dirRow`, regroupées dans `#dropArea`) sont
  retirées du DOM tant que l'on n'est pas connecté, réinsérées à la connexion.
- Ajout de `[hidden] { display: none !important }` : des règles `display` d'auteur
  (`.btn`, `#queueRow`) neutralisaient l'attribut `hidden` — bouton de déconnexion
  et barre « Tout annuler » restaient visibles hors contexte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:40:49 +02:00

903 lines
29 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 {
max-width: 640px;
margin: 0 auto;
display: grid;
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;
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 .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item .detail {
color: var(--muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.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">Connectez-vous, puis glissez des fichiers médias pour les déposer.</p>
</header>
<div class="card">
<div class="authrow">
<span id="authState" class="muted">Chargement…</span>
<a id="loginLink" class="btn" href="/auth/login" hidden>Se connecter</a>
<form id="logoutForm" method="post" action="/auth/logout" hidden>
<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 authState = document.getElementById('authState');
const banner = document.getElementById('banner');
const loginLink = document.getElementById('loginLink');
const logoutForm = document.getElementById('logoutForm');
const targetCard = document.getElementById('targetCard');
const targetSelect = document.getElementById('targetSelect');
const dropArea = document.getElementById('dropArea');
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;
let authenticated = false;
let currentTarget = null;
let currentTargetAllowsDirs = false;
// Les zones de dépôt ne sont présentes dans le DOM que si l'on est connecté.
const dropAreaAnchor = queueRow;
dropArea.remove();
function setDropAreaPresent(present) {
if (present) {
if (!dropArea.isConnected) dropAreaAnchor.before(dropArea);
} else if (dropArea.isConnected) {
dropArea.remove();
}
}
function showBanner(message) {
banner.textContent = message;
banner.hidden = false;
}
const loginParam = new URLSearchParams(location.search).get('login');
if (loginParam === 'error') showBanner('La connexion a échoué. Réessayez.');
else if (loginParam === 'expired') showBanner('La session de connexion a expiré. Réessayez.');
if (loginParam) history.replaceState({}, '', location.pathname);
function render(me) {
authenticated = Boolean(me && me.authenticated);
loginLink.hidden = authenticated;
logoutForm.hidden = !authenticated;
setDropAreaPresent(authenticated);
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
if (authenticated) {
loadTargets();
} else {
targetCard.hidden = true;
}
}
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 = !authenticated || !currentTargetAllowsDirs;
}
function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => (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);
fetch('/auth/me', { headers: { accept: 'application/json' } })
.then((r) => r.json())
.then(render)
.catch(() => render({ authenticated: false }));
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> ' +
'<button type="button" class="cancel" hidden>Annuler</button></span>' +
'</div>' +
'<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span>' +
'<span class="detail progress"></span></div>';
item.querySelector('.name').textContent = label || file.name;
item.querySelector('.total').textContent = formatSize(file.size);
list.prepend(item);
return {
bar: item.querySelector('.bar > span'),
status: item.querySelector('.status'),
progress: item.querySelector('.progress'),
cancelBtn: item.querySelector('.cancel'),
};
}
function setStatus(ui, text, kind) {
ui.status.textContent = text;
ui.status.className = 'status ' + (kind || 'muted');
}
const queue = [];
const active = new Set();
function updateQueueRow() {
const running = active.size;
const waiting = queue.length;
if (running + waiting === 0) {
queueRow.hidden = true;
return;
}
queueRow.hidden = false;
queueInfo.textContent =
running + ' en cours' + (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 enqueue(file, relativePath) {
const ui = addItem(file, relativePath);
const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD };
queue.push(job);
ui.cancelBtn.hidden = false;
ui.cancelBtn.onclick = () => {
const i = queue.indexOf(job);
if (i < 0) return;
queue.splice(i, 1);
ui.cancelBtn.hidden = true;
setStatus(ui, 'Annulé', 'err');
updateQueueRow();
};
updateQueueRow();
}
// 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.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.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(relativePath) {
const p = new URLSearchParams();
if (currentTarget) p.set('target', currentTarget);
if (relativePath) p.set('path', relativePath);
const s = p.toString();
return s ? '?' + s : '';
}
function finish(job) {
active.delete(job);
job.ui.cancelBtn.hidden = true;
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');
} else if (status === 401) {
render({ authenticated: false });
showBanner('Session expirée — reconnectez-vous.');
setStatus(ui, 'Non authentifié', 'err');
} else {
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
}
}
function startUpload(job) {
active.add(job);
job.ui.cancelBtn.hidden = false;
job.ui.cancelBtn.onclick = () => job.cancel && job.cancel();
setStatus(job.ui, 'Envoi…', 'muted');
if (job.file.size > CHUNK_THRESHOLD) runChunked(job);
else runSimple(job);
}
function runSimple(job) {
const { file, relativePath, ui } = job;
const report = makeReporter(ui, file.size);
const form = new FormData();
form.append('file', file);
const xhr = new XMLHttpRequest();
job.cancel = () => xhr.abort();
xhr.open('POST', '/upload' + uploadQuery(relativePath));
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);
setStatus(ui, 'Erreur réseau', 'err');
});
xhr.addEventListener('abort', () => {
finish(job);
ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err');
});
xhr.send(form);
}
async function runChunked(job) {
const { file, relativePath, ui } = job;
const report = makeReporter(ui, file.size);
const inflight = new Set(); // XHR en vol (pour l'annulation)
let aborted = false;
let sessionId = null;
let resolveAll = null;
const finished = new Promise((r) => {
resolveAll = r;
});
let settled = false;
const finalize = (outcome) => {
if (!settled) {
settled = true;
resolveAll(outcome);
}
};
const dropSession = () => {
if (sessionId) fetch('/upload/' + sessionId, { 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, 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();
finish(job);
ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err');
finalize('abort');
};
let meta;
try {
const res = await fetch('/upload/init' + uploadQuery(relativePath), {
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(() => ({}));
if (!res.ok) {
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
applyResult(job, res.status, meta);
return;
}
} catch {
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
setStatus(ui, 'Erreur réseau (init)', 'err');
return;
}
if (aborted) return;
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;
setStatus(ui, 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')', 'err');
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);
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();
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);
setStatus(ui, 'Erreur réseau (finalisation)', 'err');
}
}
function cancelAll() {
while (queue.length) {
const job = queue.shift();
job.ui.cancelBtn.hidden = true;
setStatus(job.ui, 'Annulé', 'err');
}
for (const job of Array.from(active)) if (job.cancel) job.cancel();
updateQueueRow();
}
queueCancel.addEventListener('click', cancelAll);
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items) {
if (!authenticated) {
showBanner('Connectez-vous avant de déposer des fichiers.');
return;
}
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', () => {
if (authenticated) 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();
if (authenticated) 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 (!authenticated || !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>