From e06a317499374c7825bc7d37cd715f77f0288eb0 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Sun, 10 May 2026 15:55:35 +0200 Subject: [PATCH] fix: req annexes, add HTMX inline file validation (MIME/size) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Annexes file input now required when 'has_annexes' checkbox is checked - PHP-side validation: if has_annexes but no files, throw error - HTMX inline file validation: POSTs to validate-file-fragment on file change - Validates MIME type against per-field whitelists (couverture, note_intention, tfe, annexes) - Validates file size with PDF-specific 100MB limit - Supports both single-file and multi-file inputs - Returns green ✓ or red ✕ inline validation messages - Shared validation logic in src/Controllers/validate-file-fragment-shared.php - Admin wrapper: admin/validate-file-fragment.php (with AdminAuth guard) - Partage route: /partage/validate-file-fragment (dispatched via index.php) - CSS: .file-validation-msg, .fv-ok (green), .fv-error (red) - file-field.php: accepts $fieldName for per-input validation type, auto-detects admin/partage validate URL --- TODO.md | 5 + app/public/admin/validate-file-fragment.php | 12 + app/public/assets/css/form.css | 16 + app/public/assets/js/file-upload-queue.js | 348 ++++++++++++------ app/public/partage/fichiers-fragment.php | 2 +- app/public/partage/index.php | 7 + .../Controllers/ThesisCreateController.php | 6 + .../validate-file-fragment-shared.php | 191 ++++++++++ app/templates/admin/acces.php | 13 + app/templates/partials/form/file-field.php | 33 +- 10 files changed, 503 insertions(+), 130 deletions(-) create mode 100644 app/public/admin/validate-file-fragment.php create mode 100644 app/src/Controllers/validate-file-fragment-shared.php diff --git a/TODO.md b/TODO.md index a73c465..016ec22 100644 --- a/TODO.md +++ b/TODO.md @@ -34,3 +34,8 @@ - [x] Relax 3-keyword minimum: admin mode (create) requires 1+, edit requires 1+, student (partage) requires 3 - [x] Add CSS for file preview items (.fp-item, .fp-thumb, .fp-icon, .fp-meta, .fp-name, .fp-size) so annexes/cover/note-intention previews wrap and display correctly - [x] Fix TFE file input accept attribute to include video/audio/archive extensions +- [x] Make annexes file input required when "Ce TFE comporte des annexes" is checked +- [x] Add PHP-side validation: if has_annexes checked but no annexe files provided, throw error +- [x] Add HTMX inline file validation: MIME type + file size checked on change via validate-file-fragment endpoint +- [x] Create shared validation logic (validate-file-fragment-shared.php) used by both admin and partage +- [x] Add CSS for .file-validation-msg, .fv-ok, .fv-error inline validation messages diff --git a/app/public/admin/validate-file-fragment.php b/app/public/admin/validate-file-fragment.php new file mode 100644 index 0000000..6369462 --- /dev/null +++ b/app/public/admin/validate-file-fragment.php @@ -0,0 +1,12 @@ += 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' - : b >= 1048576 ? (b / 1048576).toFixed(2) + ' MB' - : b >= 1024 ? (b / 1024).toFixed(1) + ' KB' - : b + ' B'; - } + function humanSize(b) { + return b >= 1073741824 + ? (b / 1073741824).toFixed(2) + " GB" + : b >= 1048576 + ? (b / 1048576).toFixed(2) + " MB" + : b >= 1024 + ? (b / 1024).toFixed(1) + " KB" + : b + " B"; + } - function esc(s) { return s.replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } + function esc(s) { + return s.replace(/[&<>"]/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; + }); + } - // ── 1. TFE multi-file queue ──────────────────────────────────────────── - var picker = document.getElementById('tfe-files-input'); - var queue = document.getElementById('tfe-file-queue'); - var empty = document.getElementById('tfe-file-queue-empty'); - var sortHint = document.getElementById('tfe-file-queue-sort-hint'); - if (picker && queue) { - console.log('[file-upload-queue] init TFE queue picker=', picker, 'multiple=', picker.multiple); - var fileArray = []; + // ── 1. TFE multi-file queue ──────────────────────────────────────────── + var picker = document.getElementById("tfe-files-input"); + var queue = document.getElementById("tfe-file-queue"); + var empty = document.getElementById("tfe-file-queue-empty"); + var sortHint = document.getElementById("tfe-file-queue-sort-hint"); + if (picker && queue) { + console.log( + "[file-upload-queue] init TFE queue picker=", + picker, + "multiple=", + picker.multiple, + ); + var fileArray = []; - if (typeof Sortable !== 'undefined') { - Sortable.create(queue, { animation: 150, handle: '.fq-drag-handle', ghostClass: 'fq-ghost', - onEnd: function () { - var items = queue.querySelectorAll('.fq-item'); - var newArr = Array.prototype.map.call(items, function (li) { return fileArray[parseInt(li.getAttribute('data-idx'), 10)]; }); - fileArray = newArr; - renderQueue(); - } - }); - } + if (typeof Sortable !== "undefined") { + Sortable.create(queue, { + animation: 150, + handle: ".fq-drag-handle", + ghostClass: "fq-ghost", + onEnd: function () { + var items = queue.querySelectorAll(".fq-item"); + var newArr = Array.prototype.map.call(items, function (li) { + return fileArray[parseInt(li.getAttribute("data-idx"), 10)]; + }); + fileArray = newArr; + renderQueue(); + }, + }); + } - picker.onchange = function () { - console.log('[file-upload-queue] onchange fired, files count:', picker.files.length, 'names:', Array.from(picker.files).map(function(f){return f.name})); - fileArray = fileArray.concat(Array.from(picker.files)); - console.log('[file-upload-queue] fileArray after concat, length:', fileArray.length); - picker.value = ''; - renderQueue(); - }; + picker.onchange = function () { + console.log( + "[file-upload-queue] onchange fired, files count:", + picker.files.length, + "names:", + Array.from(picker.files).map(function (f) { + return f.name; + }), + ); + fileArray = fileArray.concat(Array.from(picker.files)); + console.log( + "[file-upload-queue] fileArray after concat, length:", + fileArray.length, + ); + picker.value = ""; + renderQueue(); + }; - function renderQueue() { - queue.innerHTML = ''; - if (!fileArray.length) { empty.style.display = ''; if (sortHint) sortHint.style.display = 'none'; injectHiddenFields([]); return; } - empty.style.display = 'none'; - if (sortHint) sortHint.style.display = ''; - fileArray.forEach(function (file, idx) { - var li = document.createElement('li'); - li.className = 'fq-item'; - li.setAttribute('data-idx', idx); - li.innerHTML = - '\u2820' + - '' + iconFor(file) + '' + - '' + esc(file.name) + '' + - '' + humanSize(file.size) + '' + - ''; - li.querySelector('.fq-remove').onclick = (function (i) { return function () { fileArray.splice(i, 1); renderQueue(); }; })(idx); - queue.appendChild(li); - }); - injectHiddenFields(Array.from(queue.querySelectorAll('.fq-item'))); - } + function renderQueue() { + queue.innerHTML = ""; + if (!fileArray.length) { + empty.style.display = ""; + if (sortHint) sortHint.style.display = "none"; + injectHiddenFields([]); + return; + } + empty.style.display = "none"; + if (sortHint) sortHint.style.display = ""; + fileArray.forEach(function (file, idx) { + var li = document.createElement("li"); + li.className = "fq-item"; + li.setAttribute("data-idx", idx); + li.innerHTML = + '\u2820' + + '' + + iconFor(file) + + "" + + '' + + esc(file.name) + + "" + + '' + + humanSize(file.size) + + "" + + ''; + li.querySelector(".fq-remove").onclick = (function (i) { + return function () { + fileArray.splice(i, 1); + renderQueue(); + }; + })(idx); + queue.appendChild(li); + }); + injectHiddenFields(Array.from(queue.querySelectorAll(".fq-item"))); + } - function injectHiddenFields(items) { - var form = picker.closest('form'); - if (!form) return; - form.querySelectorAll('.fq-hidden-label, .fq-hidden-order').forEach(function (el) { el.remove(); }); - items.forEach(function (li, sortedIdx) { - var label = li.querySelector('.fq-label'); - var lInp = document.createElement('input'); lInp.type = 'hidden'; lInp.name = 'file_labels[]'; lInp.value = label ? label.value : ''; lInp.className = 'fq-hidden-label'; form.appendChild(lInp); - var oInp = document.createElement('input'); oInp.type = 'hidden'; oInp.name = 'file_orders[]'; oInp.value = sortedIdx + 1; oInp.className = 'fq-hidden-order'; form.appendChild(oInp); - }); - } + function injectHiddenFields(items) { + var form = picker.closest("form"); + if (!form) return; + form + .querySelectorAll(".fq-hidden-label, .fq-hidden-order") + .forEach(function (el) { + el.remove(); + }); + items.forEach(function (li, sortedIdx) { + var label = li.querySelector(".fq-label"); + var lInp = document.createElement("input"); + lInp.type = "hidden"; + lInp.name = "file_labels[]"; + lInp.value = label ? label.value : ""; + lInp.className = "fq-hidden-label"; + form.appendChild(lInp); + var oInp = document.createElement("input"); + oInp.type = "hidden"; + oInp.name = "file_orders[]"; + oInp.value = sortedIdx + 1; + oInp.className = "fq-hidden-order"; + form.appendChild(oInp); + }); + } - // On submit, refresh hidden fields from current queue state - var form = picker.closest('form'); - if (form) form.addEventListener('submit', function () { injectHiddenFields(Array.from(queue.querySelectorAll('.fq-item'))); }); - } + // On submit, refresh hidden fields from current queue state + var form = picker.closest("form"); + if (form) + form.addEventListener("submit", function () { + injectHiddenFields(Array.from(queue.querySelectorAll(".fq-item"))); + }); + } - // ── 2. Single-file previews (data-preview attribute) ──────────────────── - document.querySelectorAll('input[type="file"][data-preview]').forEach(function (input) { - if (input.id === 'tfe-files-input') return; - console.log('[file-upload-queue] binding preview for', input.id, 'multiple=', input.multiple); - var container = document.getElementById(input.getAttribute('data-preview')); - if (!container) return; - input.onchange = function () { - container.innerHTML = ''; - Array.from(input.files).forEach(function (file) { - var item = document.createElement('div'); item.className = 'fp-item'; - if (/^image\//.test(file.type)) { - var img = document.createElement('img'); img.className = 'fp-thumb'; img.alt = file.name; - var reader = new FileReader(); - reader.onload = function (e) { img.src = e.target.result; }; - reader.readAsDataURL(file); - item.appendChild(img); - } else { - var ic = document.createElement('span'); ic.className = 'fp-icon'; ic.textContent = iconFor(file); - item.appendChild(ic); - } - var meta = document.createElement('span'); meta.className = 'fp-meta'; - meta.innerHTML = '' + esc(file.name) + '' + humanSize(file.size) + ''; - item.appendChild(meta); - container.appendChild(item); - }); - }; - }); + // ── 2. Single-file previews (data-preview attribute) ──────────────────── + document + .querySelectorAll('input[type="file"][data-preview]') + .forEach(function (input) { + if (input.id === "tfe-files-input") return; + console.log( + "[file-upload-queue] binding preview for", + input.id, + "multiple=", + input.multiple, + ); + var container = document.getElementById( + input.getAttribute("data-preview"), + ); + if (!container) return; + input.onchange = function () { + container.innerHTML = ""; + Array.from(input.files).forEach(function (file) { + var item = document.createElement("div"); + item.className = "fp-item"; + if (/^image\//.test(file.type)) { + var img = document.createElement("img"); + img.className = "fp-thumb"; + img.alt = file.name; + var reader = new FileReader(); + reader.onload = function (e) { + img.src = e.target.result; + }; + reader.readAsDataURL(file); + item.appendChild(img); + } else { + var ic = document.createElement("span"); + ic.className = "fp-icon"; + ic.textContent = iconFor(file); + item.appendChild(ic); + } + var meta = document.createElement("span"); + meta.className = "fp-meta"; + meta.innerHTML = + '' + + esc(file.name) + + '' + + humanSize(file.size) + + ""; + item.appendChild(meta); + container.appendChild(item); + }); + }; + }); - // ── 3. Existing-files sortable (edit mode) ────────────────────────────── - var sortList = document.getElementById('existing-files-sortable'); - if (sortList && typeof Sortable !== 'undefined') { - Sortable.create(sortList, { animation: 150, handle: '.admin-file-drag-handle', ghostClass: 'fq-ghost', - onEnd: function () { - sortList.querySelectorAll('input[name="file_sort_order[]"]').forEach(function (el) { el.remove(); }); - sortList.querySelectorAll('.admin-file-list-item[data-file-id]').forEach(function (li) { - var inp = document.createElement('input'); inp.type = 'hidden'; inp.name = 'file_sort_order[]'; inp.value = li.getAttribute('data-file-id'); li.prepend(inp); - }); - } - }); - } + // ── 3. Existing-files sortable (edit mode) ────────────────────────────── + var sortList = document.getElementById("existing-files-sortable"); + if (sortList && typeof Sortable !== "undefined") { + Sortable.create(sortList, { + animation: 150, + handle: ".admin-file-drag-handle", + ghostClass: "fq-ghost", + onEnd: function () { + sortList + .querySelectorAll('input[name="file_sort_order[]"]') + .forEach(function (el) { + el.remove(); + }); + sortList + .querySelectorAll(".admin-file-list-item[data-file-id]") + .forEach(function (li) { + var inp = document.createElement("input"); + inp.type = "hidden"; + inp.name = "file_sort_order[]"; + inp.value = li.getAttribute("data-file-id"); + li.prepend(inp); + }); + }, + }); + } }; // Bootstrap on page load -if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', window.XamxamInitFileUploads); +if (document.readyState === "loading") + document.addEventListener("DOMContentLoaded", window.XamxamInitFileUploads); else window.XamxamInitFileUploads(); diff --git a/app/public/partage/fichiers-fragment.php b/app/public/partage/fichiers-fragment.php index 14ff15e..90b3f5d 100644 --- a/app/public/partage/fichiers-fragment.php +++ b/app/public/partage/fichiers-fragment.php @@ -197,7 +197,7 @@ $hasAnnexesChecked = !empty($_POST['has_annexes']); $label = 'Annexes :'; $accept = '.pdf,.zip,.tar,.gz'; $hint = 'PDF ou archives ZIP/TAR. Max 500 MB.'; - $required = false; + $required = !$adminMode; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?> diff --git a/app/public/partage/index.php b/app/public/partage/index.php index 705395e..e6c5f2d 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -36,6 +36,13 @@ if ($slug === 'format-website-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST' exit; } +// Special route: /partage/validate-file-fragment (HTMX fragment — file MIME/size validation) +if ($slug === 'validate-file-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') { + App::boot(); + require_once __DIR__ . '/../../src/Controllers/validate-file-fragment-shared.php'; + exit; +} + // Special route: /partage/fichiers-fragment (HTMX fragment — format-aware fichiers block) if ($slug === 'fichiers-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') { App::boot(); diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index b158940..ff4d3b4 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -512,6 +512,12 @@ class ThesisCreateController $exemplaireErg = !empty($post['exemplaire_erg']); $cc2r = !empty($post['cc2r']); + // Annexes validation: if has_annexes is checked, at least one annexe file must be provided + $hasAnnexes = !empty($post['has_annexes']); + if (!$adminMode && $hasAnnexes && empty($_FILES['annexes']['name'][0])) { + throw new Exception('Veuillez fournir au moins un fichier d\'annexe.'); + } + return compact( 'authorNames', 'mail', diff --git a/app/src/Controllers/validate-file-fragment-shared.php b/app/src/Controllers/validate-file-fragment-shared.php new file mode 100644 index 0000000..2469d70 --- /dev/null +++ b/app/src/Controllers/validate-file-fragment-shared.php @@ -0,0 +1,191 @@ + $rawFile['name'][0] ?? null, + 'tmp_name' => $rawFile['tmp_name'][0] ?? null, + 'error' => $rawFile['error'][0] ?? UPLOAD_ERR_NO_FILE, + 'size' => $rawFile['size'][0] ?? 0, + ]; + // Validate ALL selected files (batch validation) + $allFiles = []; + $count = count($rawFile['name'] ?? []); + for ($i = 0; $i < $count; $i++) { + $allFiles[] = [ + 'name' => $rawFile['name'][$i] ?? null, + 'tmp_name' => $rawFile['tmp_name'][$i] ?? null, + 'error' => $rawFile['error'][$i] ?? UPLOAD_ERR_NO_FILE, + 'size' => $rawFile['size'][$i] ?? 0, + ]; + } +} else { + $file = $rawFile; + $allFiles = $file ? [$file] : []; +} + +// ── No file provided — clear any existing validation state ────────────────── +if (empty($allFiles)) { + echo ''; + exit; +} + +if ($adminMode) { + // Admins: no validation, always OK + $count = count($allFiles); + echo '✓ ' . $count . ' fichier' . ($count > 1 ? 's' : '') . ' accepté' . ($count > 1 ? 's' : '') . ''; + exit; +} + +// ── MIME + extension whitelist per field type ──────────────────────────────── +$finfo = new finfo(FILEINFO_MIME_TYPE); + +$constraints = match ($fieldName) { + 'couverture' => [ + 'mimes' => ['image/jpeg', 'image/png', 'image/webp'], + 'exts' => ['jpg', 'jpeg', 'png', 'webp'], + 'maxSize' => 20 * 1024 * 1024, // 20 MB + 'label' => 'Image de couverture', + 'allowedDesc' => 'JPG, PNG ou WEBP', + 'maxSizeDesc' => '20 MB', + ], + 'note_intention' => [ + 'mimes' => ['application/pdf'], + 'exts' => ['pdf'], + 'maxSize' => 100 * 1024 * 1024, // 100 MB + 'label' => 'Note d\'intention', + 'allowedDesc' => 'PDF uniquement', + 'maxSizeDesc' => '100 MB', + ], + 'tfe' => [ + 'mimes' => [ + 'application/pdf', + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', + 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', + 'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4', + 'text/vtt', + 'application/zip', 'application/x-zip-compressed', + 'application/x-tar', 'application/gzip', + 'application/octet-stream', + ], + 'exts' => [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', + 'mp4', 'webm', 'ogv', 'mov', + 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a', + 'vtt', 'zip', 'tar', 'gz', 'tgz', + ], + 'maxSize' => 500 * 1024 * 1024, // 500 MB + 'label' => 'Fichier TFE', + 'allowedDesc' => 'PDF, images, vidéos, audio, archives', + 'maxSizeDesc' => '500 MB', + ], + 'annexes' => [ + 'mimes' => [ + 'application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', + 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', + 'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4', + 'text/vtt', + 'application/zip', 'application/x-zip-compressed', + 'application/x-tar', 'application/gzip', + 'application/octet-stream', + ], + 'exts' => [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', + 'mp4', 'webm', 'ogv', 'mov', + 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a', + 'vtt', 'zip', 'tar', 'gz', 'tgz', + ], + 'maxSize' => 500 * 1024 * 1024, // 500 MB + 'label' => 'Annexe', + 'allowedDesc' => 'PDF, archives, images', + 'maxSizeDesc' => '500 MB', + ], + default => [ + 'mimes' => [], + 'exts' => [], + 'maxSize' => 500 * 1024 * 1024, + 'label' => 'Fichier', + 'allowedDesc' => 'tous types', + 'maxSizeDesc' => '500 MB', + ], +}; + +// ── Validate each file ────────────────────────────────────────────────────── +$errors = []; +$oks = []; +foreach ($allFiles as $idx => $f) { + if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + continue; + } + + $mimeType = $finfo->file($f['tmp_name']); + $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION)); + $size = $f['size']; + + if ($mimeType === 'text/plain' && $ext === 'vtt') { + $mimeType = 'text/vtt'; + } + + // PDF size limit + $effMaxSize = $constraints['maxSize']; + $effMaxDesc = $constraints['maxSizeDesc']; + if ($mimeType === 'application/pdf' || $ext === 'pdf') { + $effMaxSize = min($effMaxSize, 100 * 1024 * 1024); + $effMaxDesc = '100 MB'; + } + + // Validate MIME + $mimeOk = false; + if ($mimeType === 'application/octet-stream' && !in_array($ext, $constraints['exts'], true)) { + $mimeOk = false; + } elseif (in_array($mimeType, $constraints['mimes'], true)) { + $mimeOk = true; + } elseif (in_array($ext, $constraints['exts'], true)) { + $mimeOk = true; + } + + if (!$mimeOk) { + $errors[] = '✕ ' . htmlspecialchars($f['name']) . ' : type non accepté.' + . ' Formats acceptés : ' . htmlspecialchars($constraints['allowedDesc']) . '.'; + continue; + } + + if ($size > $effMaxSize) { + $mb = round($size / 1024 / 1024, 1); + $errors[] = '✕ ' . htmlspecialchars($f['name']) . ' : fichier trop volumineux (' + . $mb . ' MB). Maximum : ' . htmlspecialchars($effMaxDesc) . '.'; + continue; + } + + $oks[] = '✓ ' . htmlspecialchars($f['name']) . ' : ' . round($size / 1024 / 1024, 1) . ' MB'; +} + +// ── Output ──────────────────────────────────────────────────────────────────── +if (!empty($errors)) { + echo '' . implode('
', $errors) . '
'; +} elseif (!empty($oks)) { + echo '' . implode('
', $oks) . '
'; +} else { + echo ''; +} diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index c394e91..2d59799 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -207,6 +207,19 @@ +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +\\\\\\\ to: yztqkpzz 9c95f5ce "fix: TFE and annexes files not saved, plus keyword validation and file preview CSS" (rebased revision) ++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: yztqkpzz 9c95f5ce "fix: TFE and annexes files not saved, plus keyword validation and file preview CSS" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: uvnvvyny 8b83eae7 "fix: req annexes, add HTMX inline file validation (MIME/size)" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: uvnvvyny 09c854ea "fix: req annexes, add HTMX inline file validation (MIME/size)" (rebased revision) +++ $linkName = $link['name'] ?? ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?> diff --git a/app/templates/partials/form/file-field.php b/app/templates/partials/form/file-field.php index b26fe1f..9703019 100644 --- a/app/templates/partials/form/file-field.php +++ b/app/templates/partials/form/file-field.php @@ -10,19 +10,35 @@ * bool $required — whether the field is required; default false * bool $multiple — whether to allow multiple file selection; default false * string|null $id — override the id attribute (defaults to $name) + * string|null $fieldName — validation field name for HTMX inline validation ('couverture', 'note_intention', 'tfe', 'annexes') */ -$accept = $accept ?? ''; -$hint = $hint ?? null; -$hintRaw = $hintRaw ?? false; // when true, $hint is emitted as raw HTML -$required = $required ?? false; -$multiple = $multiple ?? false; -$id = $id ?? $name; +$accept = $accept ?? ''; +$hint = $hint ?? null; +$hintRaw = $hintRaw ?? false; // when true, $hint is emitted as raw HTML +$required = $required ?? false; +$multiple = $multiple ?? false; +$id = $id ?? $name; +$fieldName = $fieldName ?? $name; // validation field name $previewId = 'fp-' . htmlspecialchars($id); + +// Determine HTMX POST endpoint for inline file validation +if (defined('ADMIN_MODE') && ADMIN_MODE) { + $validateUrl = '/admin/validate-file-fragment.php'; +} else { + $validateUrl = '/partage/validate-file-fragment'; +} ?>
-
+
+ data-preview=""> +
@@ -37,4 +54,4 @@ $previewId = 'fp-' . htmlspecialchars($id);