diff --git a/TODO.md b/TODO.md index 3ab5925..fe92a64 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,10 @@ - [x] Disable CURLOPT_FOLLOWLOCATION to preserve Location header - [x] Add cancelUpload() helper for Delete-on-error cleanup - [x] PeerTube upload fixed — simple multipart POST /api/v1/videos/upload works -- [x] Add upload-progress.js — XHR form submit with progress bar for admin add/edit forms +- [x] Upload progress: 0-25% browser upload, 25-99% server polling via /admin/actions/upload-progress.php +- [x] Decorelate formats from fichiers: no HTMX toggling; Site web/Vidéo/Audio always visible +- [x] Sticky formats fieldset inside parent container +- [x] Server-side progress: PeerTubeService writes to temp file, client polls progress endpoint ## HTMX Toast Feedback for Settings Checkboxes (contenus.php) diff --git a/app/public/admin/actions/edit.php b/app/public/admin/actions/edit.php index 8e60540..13dba01 100644 --- a/app/public/admin/actions/edit.php +++ b/app/public/admin/actions/edit.php @@ -32,7 +32,12 @@ require_once APP_ROOT . '/src/ErrorHandler.php'; try { $ctrl = ThesisEditController::create(); - $ctrl->save($thesisId, $_POST, $_FILES); + $progressToken = $_POST['progress_token'] ?? bin2hex(random_bytes(8)); + $ctrl->save($thesisId, $_POST, $_FILES, $progressToken); + + // Clean up progress file + require_once APP_ROOT . '/src/PeerTubeService.php'; + PeerTubeService::clearProgress($progressToken); // Regenerate CSRF token after successful save $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); diff --git a/app/public/admin/actions/upload-progress.php b/app/public/admin/actions/upload-progress.php new file mode 100644 index 0000000..53c8912 --- /dev/null +++ b/app/public/admin/actions/upload-progress.php @@ -0,0 +1,43 @@ + + * + * Response: JSON + * { "stage": "upload"|"processing"|"done", "pct": 45, "file": "video.mp4" } + * + * Progress data is written by ThesisEditController / ThesisCreateController + * to a temp file during processing. + */ + +require_once __DIR__ . '/../../../bootstrap.php'; +require_once __DIR__ . '/../../../src/AdminAuth.php'; +AdminAuth::requireLogin(); + +header('Content-Type: application/json'); + +$token = $_GET['token'] ?? ''; +if (!preg_match('/^[a-f0-9]{16}$/', $token)) { + echo json_encode(['stage' => 'error', 'error' => 'Invalid token']); + exit; +} + +$progressFile = sys_get_temp_dir() . '/xamxam_upload_' . $token . '.json'; + +if (!file_exists($progressFile)) { + // No progress file yet — still in upload phase (or token invalid) + echo json_encode(['stage' => 'upload', 'pct' => 0, 'file' => '']); + exit; +} + +$data = json_decode(file_get_contents($progressFile), true); +if (!$data) { + echo json_encode(['stage' => 'upload', 'pct' => 0, 'file' => '']); + exit; +} + +echo json_encode($data); diff --git a/app/public/assets/css/form.css b/app/public/assets/css/form.css index 9ba4870..a154ae0 100644 --- a/app/public/assets/css/form.css +++ b/app/public/assets/css/form.css @@ -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; +} diff --git a/app/public/assets/js/file-upload-filepond.js b/app/public/assets/js/file-upload-filepond.js index 9e685a5..5985ad8 100644 --- a/app/public/assets/js/file-upload-filepond.js +++ b/app/public/assets/js/file-upload-filepond.js @@ -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 + "]']"); diff --git a/app/public/assets/js/upload-progress.js b/app/public/assets/js/upload-progress.js index 964f60f..660acb0 100644 --- a/app/public/assets/js/upload-progress.js +++ b/app/public/assets/js/upload-progress.js @@ -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 .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; }); diff --git a/app/public/partage/fichiers-fragment.php b/app/public/partage/fichiers-fragment.php index 9013845..3225c2b 100644 --- a/app/public/partage/fichiers-fragment.php +++ b/app/public/partage/fichiers-fragment.php @@ -4,15 +4,9 @@ * * Returns the combined Format(s) + Fichiers block. * - * Architecture: - * - Formats checkboxes: static, never swapped. They trigger HTMX swaps - * on individual #slot-siteweb, #slot-video, #slot-audio elements. - * - File inputs (couverture, note d'intention, TFE, annexes): always - * static in the DOM — never destroyed by format toggling. - * - Format-specific extras: each is a standalone HTMX fragment slot. - * When unchecked → empty hidden placeholder. When checked → input - * fields rendered via HTMX. This preserves FilePond instances on - * the main file inputs across format changes. + * All slots (Site web, Vidéo, Audio) are always visible — decorelated from + * the format checkboxes. The checkboxes serve only as metadata selectors; + * they no longer trigger HTMX swaps. * * Expected POST: * formats[] — array of selected format_type IDs @@ -28,21 +22,9 @@ $peerTubeSettings = PeerTubeService::getSettings($_ptDb); $db = $_ptDb->getConnection(); -// Load all format types in display order $allFormats = $db->query('SELECT id, name FROM format_types ORDER BY sort_order, id') ->fetchAll(PDO::FETCH_ASSOC); -// Build name→id map for format logic -$formatIdByName = []; -foreach ($allFormats as $f) { - $formatIdByName[$f['name']] = (int)$f['id']; -} - -$siteWebId = $formatIdByName['Site web'] ?? null; -$videoId = $formatIdByName['Vidéo'] ?? null; -$audioId = $formatIdByName['Audio'] ?? null; -$imageId = $formatIdByName['Image'] ?? null; - $selectedFormats = isset($_POST['formats']) && is_array($_POST['formats']) ? array_map('intval', $_POST['formats']) : []; @@ -50,25 +32,10 @@ $selectedFormats = isset($_POST['formats']) && is_array($_POST['formats']) $adminMode = ($_POST['admin_mode'] ?? '0') === '1'; $editMode = ($_POST['edit_mode'] ?? '0') === '1'; -$hasSiteWeb = $siteWebId && in_array($siteWebId, $selectedFormats, true); -$hasVideo = $videoId && in_array($videoId, $selectedFormats, true); -$hasAudio = $audioId && in_array($audioId, $selectedFormats, true); -$hasImage = $imageId && in_array($imageId, $selectedFormats, true); - -$hasNonWebFormat = !empty(array_filter( - $selectedFormats, - fn($id) => $id !== $siteWebId -)); -$showUploadBlock = $hasNonWebFormat || !$hasSiteWeb; - $websiteUrl = htmlspecialchars($_POST['website_url'] ?? ''); $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); - -$hxPost = $adminMode ? '/admin/fichiers-fragment.php' : '/partage/fichiers-fragment'; - -$hasAnnexesChecked = !empty($_POST['has_annexes']); ?> - +