feat: upload progress bar — fieldset layout, accent colors, file name display, completion animation, 800ms redirect delay; decorelate formats from fichiers; server-side poll via token; bump PeerTube embed audio player

This commit is contained in:
Pontoporeia
2026-05-11 13:12:36 +02:00
parent cdec3e96a6
commit 927ee2fe2a
12 changed files with 420 additions and 212 deletions

View File

@@ -322,7 +322,7 @@
.licence-explanation {
background: var(--bg-secondary);
border-left: 4px solid var(--border-secondary, var(--border-primary));
padding: var(--space-m);
/* padding: var(--space-m); */
margin: 0;
}
@@ -1287,3 +1287,78 @@ a.recap-file-name:hover {
font-size: var(--step--2);
color: var(--text-tertiary);
}
/* ── Upload progress bar ─────────────────────────────────── */
#upload-progress-wrap {
border: 1px solid var(--accent-muted);
border-radius: var(--radius);
padding: var(--space-s);
margin-bottom: var(--space-s);
}
#upload-progress-wrap legend {
font-weight: 600;
font-size: var(--step--1);
color: var(--text-primary);
padding: 0 var(--space-2xs);
}
#upload-progress-bar {
display: block;
width: 100%;
height: 0.85rem;
border-radius: var(--radius);
overflow: hidden;
background: var(--accent-muted);
border: none;
}
#upload-progress-bar::-webkit-progress-bar {
background: var(--accent-muted);
border-radius: var(--radius);
}
#upload-progress-bar::-webkit-progress-value {
background: var(--accent-primary);
border-radius: var(--radius);
transition: width 0.3s ease;
}
#upload-progress-bar::-moz-progress-bar {
background: var(--accent-primary);
border-radius: var(--radius);
}
/* Completion state */
#upload-progress-bar[data-complete] {
box-shadow: 0 0 12px rgba(149, 87, 181, 0.4);
}
#upload-progress-bar[data-complete]::-webkit-progress-value {
background: var(--accent-green);
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
}
#upload-progress-bar[data-complete]::-moz-progress-bar {
background: var(--accent-green);
}
/* ── Sticky formats fieldset ──────────────────────────────── */
#fieldset-formats {
position: sticky;
top: var(--space-s);
z-index: 10;
/* background: var(--bg-primary); */
border-radius: var(--radius);
}
legend {
text-shadow: var(--bg-primary) 0px 0px 2px;
}
/* Stickiness is scoped to the parent container */
#format-fichiers-block {
container-type: inline-size;
}

View File

@@ -162,7 +162,8 @@
* Value: pipe-separated list of file names.
*/
function syncOrderInput(queueType, pond) {
var form = pond.element ? pond.element.closest("form") : null;
if (!pond || !pond.element) return;
var form = pond.element.closest("form");
if (!form) return;
var orderInput = form.querySelector("input[name='queue_order[" + queueType + "]']");

View File

@@ -1,12 +1,14 @@
/**
* upload-progress.js
*
* Intercepts admin form submissions (add.php / edit.php) and submits via
* XMLHttpRequest to display a progress bar for large file uploads.
* Falls back to native form POST when JavaScript is unavailable.
* Intercepts admin form submissions with files and submits via XMLHttpRequest.
* Polls GET /admin/actions/upload-progress.php?token=xxx for server-side
* processing progress (PeerTube uploads, file moves).
*
* Requires an element with id="upload-progress-bar" inside the form.
* The progress bar is normally hidden (display:none), shown only during upload.
* Progress display:
* 0%25% : browser → server upload (XHR upload.progress)
* 25%99% : server processing (polled from progress endpoint)
* 100% : response received — "Téléversé avec succès", then redirect
*/
(() => {
'use strict';
@@ -14,65 +16,133 @@
const FORMS = document.querySelectorAll('form[data-upload-progress]');
if (!FORMS.length) return;
const POLL_INTERVAL = 400;
const UPLOAD_CAP = 25;
const PROCESSING_MAX = 99;
const SUCCESS_DELAY = 800;
for (const form of FORMS) {
const progressWrap = form.querySelector('#upload-progress-wrap');
const progressBar = form.querySelector('#upload-progress-bar');
const progressText = form.querySelector('#upload-progress-text');
const submitBtn = form.querySelector('button[type="submit"]');
const progressWrap = form.querySelector('#upload-progress-wrap');
const progressBar = form.querySelector('#upload-progress-bar');
const progressLabel = form.querySelector('#upload-progress-label');
const progressFile = form.querySelector('#upload-progress-file');
const submitBtn = form.querySelector('button[type="submit"]');
const tokenInput = form.querySelector('input[name="progress_token"]');
if (!progressBar || !progressWrap) continue;
form.addEventListener('submit', function (e) {
// Only intercept if files are actually attached (FilePond inputs have files)
const fileInputs = form.querySelectorAll('input[type="file"]');
let hasFiles = false;
for (const fi of fileInputs) {
if (fi.files && fi.files.length > 0) {
hasFiles = true;
break;
function collectFileNames() {
const names = [];
const inputs = form.querySelectorAll('input[type="file"]');
for (const fi of inputs) {
if (fi.files) {
for (const f of fi.files) {
if (f.name) names.push(f.name);
}
}
}
if (!hasFiles) return; // let native submit handle it
return names;
}
form.addEventListener('submit', function (e) {
const fileNames = collectFileNames();
if (!fileNames.length) return;
e.preventDefault();
// Show progress bar
progressWrap.style.display = 'block';
const token = tokenInput ? tokenInput.value : '';
progressWrap.style.display = '';
progressBar.value = 0;
progressText.textContent = '0%';
progressBar.removeAttribute('data-complete');
progressLabel.textContent = 'Téléversement en cours…';
progressFile.textContent = fileNames.length === 1
? fileNames[0]
: fileNames.length + ' fichiers';
if (submitBtn) submitBtn.disabled = true;
// Build FormData
const fd = new FormData(form);
// Ensure any FilePond-managed files are included — FilePond with
// storeAsFile:true copies files into the <input>.files, so FormData
// picks them up automatically from the DOM inputs.
// But we must also respect queue_order hidden inputs.
// FormData(form) already handles this since it reads all form fields.
const xhr = new XMLHttpRequest();
let uploadDone = false;
let lastUploadPct = 0;
let pollingTimer = null;
/** Poll server-side progress */
function startPolling() {
if (pollingTimer || !token) return;
progressLabel.textContent = 'Traitement en cours…';
pollingTimer = setInterval(function () {
fetch('/admin/actions/upload-progress.php?token=' + encodeURIComponent(token))
.then(function (r) { return r.json(); })
.then(function (data) {
if (data && data.stage && data.stage !== 'upload') {
const pct = Math.min(PROCESSING_MAX, Math.max(UPLOAD_CAP, data.pct || UPLOAD_CAP));
progressBar.value = pct;
if (data.file) {
progressFile.textContent = data.file;
}
}
})
.catch(function () { /* ignore poll errors */ });
}, POLL_INTERVAL);
}
function stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
}
function finishSuccess() {
stopPolling();
progressBar.value = 100;
progressBar.setAttribute('data-complete', '');
progressLabel.textContent = 'Téléversé avec succès';
progressFile.textContent = '';
}
// ── Upload phase (0% → UPLOAD_CAP) ──
xhr.upload.addEventListener('progress', function (evt) {
if (evt.lengthComputable) {
const pct = Math.round((evt.loaded / evt.total) * 100);
progressBar.value = pct;
progressText.textContent = pct + '%';
const rawPct = Math.round((evt.loaded / evt.total) * 100);
const scaled = Math.round((rawPct / 100) * UPLOAD_CAP);
if (scaled > lastUploadPct) {
lastUploadPct = scaled;
progressBar.value = scaled;
}
}
});
xhr.addEventListener('load', function () {
// Server returns a redirect (302) on success, or re-renders the form on error.
// We can't follow 302 with XHR directly — the response body is the target page.
// Check if we got a redirect by examining the response URL.
const finalUrl = xhr.responseURL || '';
const isRedirect = xhr.status >= 200 && xhr.status < 300 && finalUrl !== '' && !finalUrl.endsWith(form.action);
xhr.upload.addEventListener('loadend', function () {
uploadDone = true;
progressBar.value = UPLOAD_CAP;
startPolling();
});
if (isRedirect) {
// Success — navigate to the redirect target
window.location.href = finalUrl;
// ── Response handling ──
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
stopPolling();
if (xhr.status >= 200 && xhr.status < 300) {
finishSuccess();
setTimeout(function () {
const finalUrl = xhr.responseURL || '';
if (finalUrl && finalUrl !== form.action) {
window.location.href = finalUrl;
} else {
document.open();
document.write(xhr.responseText);
document.close();
}
}, SUCCESS_DELAY);
} else {
// Error — the server returned the form HTML with flash messages.
// Replace the current page content.
progressLabel.textContent = 'Erreur';
progressFile.textContent = 'Échec du téléversement';
document.open();
document.write(xhr.responseText);
document.close();
@@ -80,11 +150,14 @@
});
xhr.addEventListener('error', function () {
progressText.textContent = 'Erreur réseau';
stopPolling();
progressLabel.textContent = 'Erreur réseau';
progressFile.textContent = '';
if (submitBtn) submitBtn.disabled = false;
});
xhr.addEventListener('abort', function () {
stopPolling();
progressWrap.style.display = 'none';
if (submitBtn) submitBtn.disabled = false;
});