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' +
- '' +
- '' + 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' +
+ '" +
+ '' +
+ 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'])) : '';
?>