diff --git a/TODO.md b/TODO.md
index f656f06..c7a3cde 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4,13 +4,13 @@
> Context: Inline JS/CSS + gzip analysis (see docs/ANALYSIS_INLINE_JS_CSS_MINIFY.md)
## Pending
-- [ ] #gzip-nginx Enable gzip compression in nginx config `(nginx/xamxam.conf)`
-- [ ] #extract-inline-js Move inline JS to external files across 17 templates `(app/templates/**/*.php)`
- [ ] #rep-student-touch Replace hover student popover with tap-to-open drawer for mobile `(repertoire.php, repertoire.css)`
- [ ] #rep-polish Polish: scroll-position memory on HTMX swap, animation tuning `(repertoire.css)`
- [ ] #icon-color-verify Verify icon colors render correctly across all pages (header, admin tables, forms, dialogs, cleanup modal)
## Completed
+- [x] #gzip-nginx Enable gzip compression in nginx config `(nginx/xamxam.conf)` ✓
+- [x] #extract-inline-js Move inline JS to external files across 17 templates → 15 new JS files created `(app/public/assets/js/app/*.js)` ✓
- [x] #inline-icon-helper Create `icon()` PHP helper + auto-load in bootstrap `(src/icon.php, bootstrap.php)` ✓
- [x] #icon-fill-currentcolor Ensure all 36 icon SVGs use fill="currentColor" or stroke="currentColor" `(assets/icons/*.svg)` ✓
- [x] #migrate-all-img-icons Replace all remaining `` with `= icon() ?>` across 26 template files ✓
diff --git a/app/public/assets/js/app/admin-acces-sharelink.js b/app/public/assets/js/app/admin-acces-sharelink.js
new file mode 100644
index 0000000..7c96068
--- /dev/null
+++ b/app/public/assets/js/app/admin-acces-sharelink.js
@@ -0,0 +1,46 @@
+/**
+ * admin-acces-sharelink.js — Share link management for acces-etudiante.php.
+ *
+ * Provides: dialog openers, clipboard copy, password dialog.
+ */
+(function () {
+ var createBtn = document.getElementById('open-create-dialog');
+ if (createBtn) {
+ createBtn.addEventListener('click', function () {
+ document.getElementById('create-dialog').showModal();
+ });
+ }
+})();
+
+var _pendingDeleteLinkId = null;
+
+function openDeleteLinkDialog(id) {
+ _pendingDeleteLinkId = id;
+ document.getElementById('delete-link-dialog').showModal();
+}
+
+function _executeDeleteLink() {
+ var form = document.getElementById('delete-link-form-' + _pendingDeleteLinkId);
+ if (form) form.submit();
+}
+
+function copyUrl(id) {
+ var input = document.getElementById('url-' + id);
+ navigator.clipboard.writeText(input.value).then(function () {
+ var btn = event.target.closest('button');
+ var orig = btn.textContent;
+ btn.textContent = '✓ Copié';
+ setTimeout(function () {
+ btn.textContent = orig;
+ }, 1200);
+ });
+}
+
+function openPasswordDialog(id, hasPassword) {
+ document.getElementById('password-link-id').value = id;
+ var info = document.getElementById('password-current-info');
+ info.textContent = hasPassword
+ ? 'Un mot de passe est actuellement configuré. Entrez-en un nouveau ou laissez vide pour le supprimer.'
+ : 'Aucun mot de passe configuré.';
+ document.getElementById('password-dialog').showModal();
+}
diff --git a/app/public/assets/js/app/admin-acces.js b/app/public/assets/js/app/admin-acces.js
new file mode 100644
index 0000000..f4f3a3b
--- /dev/null
+++ b/app/public/assets/js/app/admin-acces.js
@@ -0,0 +1,105 @@
+/**
+ * admin-acces.js — Share link + file access management for acces.php.
+ *
+ * Reads PHP-injected data from tags:
+ *
+ *
+ *
+ */
+(function () {
+ var baseUrlMeta = document.querySelector('meta[name="acces-base-url"]');
+ var passwordMeta = document.querySelector('meta[name="acces-new-link-password"]');
+ var slugMeta = document.querySelector('meta[name="acces-new-link-slug"]');
+
+ // Show result dialog after redirect (new link created)
+ if (passwordMeta && slugMeta && passwordMeta.content && slugMeta.content) {
+ document.getElementById('create-result-password').value = passwordMeta.content;
+ document.getElementById('create-result-url').value =
+ (baseUrlMeta ? baseUrlMeta.content : '') + '/partage/' + slugMeta.content;
+ document.getElementById('create-result-dialog').showModal();
+ }
+
+ // Create dialog opener
+ var createBtn = document.getElementById('open-create-dialog');
+ if (createBtn) {
+ createBtn.addEventListener('click', function () {
+ document.getElementById('create-dialog').showModal();
+ });
+ }
+})();
+
+function copyUrl(id) {
+ var input = document.getElementById('url-' + id);
+ navigator.clipboard.writeText(input.value).then(function () {
+ var btn = event.target.closest('button');
+ if (btn) {
+ var orig = btn.getAttribute('title') || '';
+ btn.setAttribute('title', '✓ Copié');
+ setTimeout(function () {
+ btn.setAttribute('title', orig);
+ }, 1200);
+ }
+ });
+}
+
+function copyUrlFrom(el) {
+ navigator.clipboard.writeText(el.value).then(function () {
+ var btn = el.nextElementSibling;
+ if (btn) {
+ var orig = btn.textContent;
+ btn.textContent = '✓ Copié';
+ setTimeout(function () {
+ btn.textContent = orig;
+ }, 1200);
+ }
+ });
+}
+
+function copyTextToClipboard(text) {
+ navigator.clipboard.writeText(text)
+ .then(function () {
+ var btn = event && event.target ? event.target.closest('button') : null;
+ if (btn) {
+ var orig = btn.getAttribute('title') || '';
+ btn.setAttribute('title', '✓ Copié');
+ setTimeout(function () {
+ btn.setAttribute('title', orig);
+ }, 1200);
+ }
+ })
+ .catch(function () {});
+}
+
+function openEditDialog(id, name, hasPassword, expiresVal) {
+ document.getElementById('edit-link-id').value = id;
+ document.getElementById('edit-name').value = name || '';
+ document.getElementById('edit-expires').value = expiresVal || '';
+ document.getElementById('edit-dialog').showModal();
+}
+
+function openApproveDialog(requestId) {
+ document.getElementById('approve-request-id').value = requestId;
+ document.getElementById('approve-dialog').showModal();
+}
+
+function openRejectDialog(requestId) {
+ document.getElementById('reject-request-id').value = requestId;
+ document.getElementById('reject-dialog').showModal();
+}
+
+var _pendingArchiveLinkId = null;
+
+function openArchiveLinkDialog(id) {
+ _pendingArchiveLinkId = id;
+ document.getElementById('archive-link-dialog').showModal();
+}
+
+function _executeArchiveLink() {
+ var form = document.getElementById('archive-link-form-' + _pendingArchiveLinkId);
+ if (form) form.submit();
+}
+
+function openDeleteArchivedLinkDialog(id) {
+ document.getElementById('delete-archived-link-id').value = id;
+ document.getElementById('delete-archived-link-dialog').showModal();
+}
diff --git a/app/public/assets/js/app/admin-contacts-form.js b/app/public/assets/js/app/admin-contacts-form.js
new file mode 100644
index 0000000..1216b60
--- /dev/null
+++ b/app/public/assets/js/app/admin-contacts-form.js
@@ -0,0 +1,91 @@
+/**
+ * admin-contacts-form.js — Dynamic contact group/entry management.
+ *
+ * Reads `data-apropos-key` and `data-apropos-group-count` from the form element.
+ * Expects templates with ids `entry-template-f-{key}` and `group-template-f-{key}`.
+ */
+(function () {
+ var form = document.querySelector('form[data-apropos-key]');
+ if (!form) return;
+
+ var key = form.dataset.aproposKey;
+ var groupCount = parseInt(form.dataset.aproposGroupCount, 10) || 0;
+ var entryTpl = document.getElementById('entry-template-f-' + key).innerHTML;
+ var groupTpl = document.getElementById('group-template-f-' + key).innerHTML;
+
+ function reindexGroups() {
+ var fieldsets = form.querySelectorAll('fieldset.apropos-group');
+ groupCount = fieldsets.length;
+ fieldsets.forEach(function (fs, i) {
+ var newIdx = i;
+ var legend = fs.querySelector('legend');
+ if (legend) legend.textContent = 'Contact ' + (newIdx + 1);
+
+ fs.querySelectorAll('input').forEach(function (inp) {
+ if (inp.name) {
+ inp.name = inp.name.replace(/groups\[\d+\]/, 'groups[' + newIdx + ']');
+ }
+ if (inp.id) {
+ inp.id = inp.id.replace(
+ /(group_f_contacts_|entry_f_contacts_)\d+/,
+ '$1' + newIdx
+ );
+ }
+ });
+
+ fs.querySelectorAll('label[for]').forEach(function (lbl) {
+ lbl.setAttribute(
+ 'for',
+ lbl.getAttribute('for').replace(
+ /(group_f_contacts_|entry_f_contacts_)\d+/,
+ '$1' + newIdx
+ )
+ );
+ });
+
+ var addBtn = fs.querySelector('.add-entry-btn-f');
+ if (addBtn) addBtn.dataset.group = newIdx;
+ });
+ }
+
+ function bindAddEntry(btn) {
+ btn.addEventListener('click', function () {
+ var gi = parseInt(this.dataset.group);
+ var fieldset = this.closest('fieldset');
+ var entryCount = fieldset.querySelectorAll('.apropos-entry').length;
+ var html = entryTpl.replaceAll('{{gi}}', gi).replaceAll('{{ei}}', entryCount);
+ this.insertAdjacentHTML('beforebegin', html);
+ });
+ }
+
+ function bindDeleteGroup(btn) {
+ btn.addEventListener('click', function () {
+ var fieldset = this.closest('fieldset');
+ if (fieldset) {
+ fieldset.remove();
+ reindexGroups();
+ }
+ });
+ }
+
+ // Bind existing buttons
+ form.querySelectorAll('.add-entry-btn-f').forEach(bindAddEntry);
+ form.querySelectorAll('.delete-group-btn-f').forEach(bindDeleteGroup);
+
+ form.querySelector('.add-group-btn-f').addEventListener('click', function () {
+ groupCount++;
+ var html = groupTpl.replaceAll('{{gi}}', groupCount);
+ this.insertAdjacentHTML('beforebegin', html);
+
+ var newGroup = this.previousElementSibling;
+ if (newGroup && newGroup.classList.contains('apropos-group')) {
+ var addBtn = newGroup.querySelector('.add-entry-btn-f');
+ if (addBtn) {
+ addBtn.dataset.group = groupCount;
+ bindAddEntry(addBtn);
+ }
+ var delBtn = newGroup.querySelector('.delete-group-btn-f');
+ if (delBtn) bindDeleteGroup(delBtn);
+ }
+ });
+})();
diff --git a/app/public/assets/js/app/admin-contenus-langues.js b/app/public/assets/js/app/admin-contenus-langues.js
new file mode 100644
index 0000000..3187afc
--- /dev/null
+++ b/app/public/assets/js/app/admin-contenus-langues.js
@@ -0,0 +1,117 @@
+/**
+ * admin-contenus-langues.js — Langues management on contenus.php.
+ * Bulk merge, delete, toggle-all, sticky bar height tracking.
+ *
+ * Inline-rename functions (languesStartRename, languesCancelRename) remain in
+ * the template because they embed PHP-generated icon SVGs.
+ */
+var _languesPendingForm = null;
+
+function languesConfirmDelete(btn, name) {
+ _languesPendingForm = btn.closest('form');
+ document.getElementById('langues-delete-name').textContent = name;
+ document.getElementById('langues-delete-dialog').showModal();
+}
+
+function languesSubmitPending() {
+ if (_languesPendingForm) _languesPendingForm.submit();
+}
+
+function languesToggleAll(src) {
+ document.querySelectorAll('input[name="selected_langs[]"]').forEach(function (cb) {
+ cb.checked = src.checked;
+ });
+ languesUpdateBulk();
+}
+
+function languesUpdateBulk() {
+ var n = document.querySelectorAll('input[name="selected_langs[]"]:checked').length;
+ document.getElementById('langues-selected-count').textContent = n;
+ var bar = document.getElementById('langues-bulk-actions');
+ var wrap = document.getElementById('langues-table-wrap');
+ var visible = n > 1;
+ bar.style.display = visible ? 'flex' : 'none';
+ if (visible) {
+ requestAnimationFrame(function () {
+ wrap.style.setProperty('--sticky-top', bar.offsetHeight + 'px');
+ });
+ } else {
+ wrap.style.setProperty('--sticky-top', '0px');
+ }
+}
+
+function languesCancelSelection() {
+ document.querySelectorAll('input[name="selected_langs[]"]').forEach(function (cb) {
+ cb.checked = false;
+ });
+ languesUpdateBulk();
+}
+
+function languesConfirmBulkDelete() {
+ var checked = document.querySelectorAll('input[name="selected_langs[]"]:checked');
+ if (checked.length < 1) return;
+ document.getElementById('langues-bulk-delete-count').textContent = checked.length;
+ document.getElementById('langues-bulk-delete-dialog').showModal();
+}
+
+function languesExecBulkDelete() {
+ var container = document.getElementById('langues-bulk-checkboxes');
+ container.innerHTML = '';
+ document.querySelectorAll('input[name="selected_langs[]"]:checked').forEach(function (cb) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden';
+ inp.name = 'selected_langs[]';
+ inp.value = cb.value;
+ container.appendChild(inp);
+ });
+ document.getElementById('langues-bulk-form').querySelector('input[name="action"]').value =
+ 'delete_bulk';
+ document.getElementById('langues-bulk-delete-dialog').close();
+ document.getElementById('langues-bulk-form').submit();
+}
+
+function languesConfirmBulkMerge() {
+ var checked = document.querySelectorAll('input[name="selected_langs[]"]:checked');
+ if (checked.length < 2) return;
+ document.getElementById('langues-bulk-merge-count').textContent = checked.length;
+ var sel = document.getElementById('langues-bulk-merge-target-select');
+ sel.innerHTML = '';
+ checked.forEach(function (cb) {
+ var tr = cb.closest('tr');
+ sel.innerHTML +=
+ '';
+ });
+ document.getElementById('langues-bulk-merge-dialog').showModal();
+}
+
+function languesExecBulkMerge() {
+ var targetId = document.getElementById('langues-bulk-merge-target-select').value;
+ if (!targetId) return;
+ document.getElementById('langues-bulk-target').value = targetId;
+ var container = document.getElementById('langues-bulk-checkboxes');
+ container.innerHTML = '';
+ document.querySelectorAll('input[name="selected_langs[]"]:checked').forEach(function (cb) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden';
+ inp.name = 'selected_langs[]';
+ inp.value = cb.value;
+ container.appendChild(inp);
+ });
+ document.getElementById('langues-bulk-merge-dialog').close();
+ document.getElementById('langues-bulk-form').submit();
+}
+
+document.addEventListener('htmx:afterSwap', function (evt) {
+ if (evt.target.id === 'langues-table-wrap') {
+ document
+ .querySelectorAll('input[name="selected_langs[]"]')
+ .forEach(function (cb) {
+ cb.addEventListener('change', languesUpdateBulk);
+ });
+ languesUpdateBulk();
+ }
+});
diff --git a/app/public/assets/js/app/admin-contenus-motscles.js b/app/public/assets/js/app/admin-contenus-motscles.js
new file mode 100644
index 0000000..f3a2c44
--- /dev/null
+++ b/app/public/assets/js/app/admin-contenus-motscles.js
@@ -0,0 +1,117 @@
+/**
+ * admin-contenus-motscles.js — Mots-clés management on contenus.php.
+ * Bulk merge, delete, toggle-all, sticky bar height tracking.
+ *
+ * Inline-rename functions (motsclesStartRename, motsclesCancelRename) remain
+ * in the template because they embed PHP-generated icon SVGs.
+ */
+var _motsclesPendingForm = null;
+
+function motsclesConfirmDelete(btn, name) {
+ _motsclesPendingForm = btn.closest('form');
+ document.getElementById('motscles-delete-name').textContent = name;
+ document.getElementById('motscles-delete-dialog').showModal();
+}
+
+function motsclesSubmitPending() {
+ if (_motsclesPendingForm) _motsclesPendingForm.submit();
+}
+
+function motsclesToggleAll(src) {
+ document.querySelectorAll('input[name="selected_tags[]"]').forEach(function (cb) {
+ cb.checked = src.checked;
+ });
+ motsclesUpdateBulk();
+}
+
+function motsclesUpdateBulk() {
+ var n = document.querySelectorAll('input[name="selected_tags[]"]:checked').length;
+ document.getElementById('motscles-selected-count').textContent = n;
+ var bar = document.getElementById('motscles-bulk-actions');
+ var wrap = document.getElementById('motscles-table-wrap');
+ var visible = n > 1;
+ bar.style.display = visible ? 'flex' : 'none';
+ if (visible) {
+ requestAnimationFrame(function () {
+ wrap.style.setProperty('--sticky-top', bar.offsetHeight + 'px');
+ });
+ } else {
+ wrap.style.setProperty('--sticky-top', '0px');
+ }
+}
+
+function motsclesConfirmBulkMerge() {
+ var checked = document.querySelectorAll('input[name="selected_tags[]"]:checked');
+ if (checked.length < 2) return;
+ document.getElementById('motscles-bulk-merge-count').textContent = checked.length;
+ var sel = document.getElementById('motscles-bulk-merge-target-select');
+ sel.innerHTML = '';
+ checked.forEach(function (cb) {
+ var tr = cb.closest('tr');
+ sel.innerHTML +=
+ '';
+ });
+ document.getElementById('motscles-bulk-merge-dialog').showModal();
+}
+
+function motsclesExecBulkMerge() {
+ var targetId = document.getElementById('motscles-bulk-merge-target-select').value;
+ if (!targetId) return;
+ document.getElementById('motscles-bulk-target').value = targetId;
+ var container = document.getElementById('motscles-bulk-checkboxes');
+ container.innerHTML = '';
+ document.querySelectorAll('input[name="selected_tags[]"]:checked').forEach(function (cb) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden';
+ inp.name = 'selected_tags[]';
+ inp.value = cb.value;
+ container.appendChild(inp);
+ });
+ document.getElementById('motscles-bulk-merge-dialog').close();
+ document.getElementById('motscles-bulk-form').submit();
+}
+
+function motsclesCancelSelection() {
+ document.querySelectorAll('input[name="selected_tags[]"]').forEach(function (cb) {
+ cb.checked = false;
+ });
+ motsclesUpdateBulk();
+}
+
+function motsclesConfirmBulkDelete() {
+ var checked = document.querySelectorAll('input[name="selected_tags[]"]:checked');
+ if (checked.length < 1) return;
+ document.getElementById('motscles-bulk-delete-count').textContent = checked.length;
+ document.getElementById('motscles-bulk-delete-dialog').showModal();
+}
+
+function motsclesExecBulkDelete() {
+ var container = document.getElementById('motscles-bulk-checkboxes');
+ container.innerHTML = '';
+ document.querySelectorAll('input[name="selected_tags[]"]:checked').forEach(function (cb) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden';
+ inp.name = 'selected_tags[]';
+ inp.value = cb.value;
+ container.appendChild(inp);
+ });
+ document.getElementById('motscles-bulk-form').querySelector('input[name="action"]').value =
+ 'delete_bulk';
+ document.getElementById('motscles-bulk-delete-dialog').close();
+ document.getElementById('motscles-bulk-form').submit();
+}
+
+document.addEventListener('htmx:afterSwap', function (evt) {
+ if (evt.target.id === 'motscles-table-wrap') {
+ document
+ .querySelectorAll('input[name="selected_tags[]"]')
+ .forEach(function (cb) {
+ cb.addEventListener('change', motsclesUpdateBulk);
+ });
+ motsclesUpdateBulk();
+ }
+});
diff --git a/app/public/assets/js/app/admin-file-access.js b/app/public/assets/js/app/admin-file-access.js
new file mode 100644
index 0000000..87e558a
--- /dev/null
+++ b/app/public/assets/js/app/admin-file-access.js
@@ -0,0 +1,14 @@
+/**
+ * admin-file-access.js — File access request management dialogs.
+ *
+ * Provides: openApproveDialog, openRejectDialog.
+ */
+function openApproveDialog(requestId) {
+ document.getElementById('approve-request-id').value = requestId;
+ document.getElementById('approve-dialog').showModal();
+}
+
+function openRejectDialog(requestId) {
+ document.getElementById('reject-request-id').value = requestId;
+ document.getElementById('reject-dialog').showModal();
+}
diff --git a/app/public/assets/js/app/admin-index-bulk.js b/app/public/assets/js/app/admin-index-bulk.js
new file mode 100644
index 0000000..d05886f
--- /dev/null
+++ b/app/public/assets/js/app/admin-index-bulk.js
@@ -0,0 +1,113 @@
+/**
+ * admin-index-bulk.js — Bulk selection and actions for the thesis list page.
+ *
+ * Provides: toggleAll, updateBulk, getSelectedIds, confirmBulk, execBulk,
+ * confirmExport, confirmExportFiles, confirmDelete.
+ */
+(function () {
+ function toggleAll(src) {
+ document.querySelectorAll('input[name="selected_theses[]"]').forEach(function (cb) {
+ cb.checked = src.checked;
+ });
+ updateBulk();
+ }
+
+ function updateBulk() {
+ var n = document.querySelectorAll('input[name="selected_theses[]"]:checked').length;
+ var b = document.getElementById('bulk-actions');
+ document.getElementById('selected-count').textContent = n;
+ b.style.display = n > 0 ? 'flex' : 'none';
+ document.getElementById('admin-table-wrap').style.setProperty(
+ '--sticky-top',
+ n > 0 ? b.offsetHeight + 'px' : '0px'
+ );
+ }
+
+ function getSelectedIds() {
+ return Array.from(
+ document.querySelectorAll('input[name="selected_theses[]"]:checked')
+ ).map(function (cb) {
+ return cb.value;
+ });
+ }
+
+ function confirmBulk(act) {
+ var ids = getSelectedIds();
+ if (!ids.length) {
+ document.getElementById('no-selection-dialog').showModal();
+ return;
+ }
+ var n = ids.length;
+ document.getElementById('bulk-action-input').value = act;
+ if (act === 'delete') {
+ document.getElementById('bulk-delete-count').textContent = n;
+ document.getElementById('bulk-delete-dialog').showModal();
+ } else {
+ document.getElementById('bulk-confirm-word').textContent =
+ act === 'publish' ? 'Publier' : 'Dépublier';
+ document.getElementById('bulk-confirm-count').textContent = n;
+ document.getElementById('bulk-confirm-dialog').showModal();
+ }
+ }
+
+ function execBulk() {
+ var a = document.getElementById('bulk-action-input').value;
+ var f = document.getElementById('bulk-form');
+ f.action = a === 'delete' ? 'actions/delete.php' : 'actions/publish.php';
+ var c = document.getElementById('bulk-checkboxes');
+ c.innerHTML = '';
+ getSelectedIds().forEach(function (id) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden';
+ inp.name = 'selected_theses[]';
+ inp.value = id;
+ c.appendChild(inp);
+ });
+ f.submit();
+ }
+
+ function confirmExport() {
+ var ids = getSelectedIds();
+ if (!ids.length) {
+ document.getElementById('no-selection-dialog').showModal();
+ return;
+ }
+ window.location.href = '/admin/actions/export.php?csv=1&ids=' + ids.join(',');
+ }
+
+ function confirmExportFiles() {
+ var ids = getSelectedIds();
+ if (!ids.length) {
+ document.getElementById('no-selection-dialog').showModal();
+ return;
+ }
+ window.location.href = '/admin/actions/export.php?files=1&ids=' + ids.join(',');
+ }
+
+ function confirmDelete(id, title) {
+ document.getElementById('delete-thesis-title').textContent = title;
+ document.getElementById('delete-thesis-dialog').showModal();
+ document.getElementById('delete-dialog-confirm').onclick = function () {
+ document.getElementById('delete-form-' + id).submit();
+ };
+ }
+
+ function reattachListeners() {
+ document.querySelectorAll('input[name="selected_theses[]"]').forEach(function (cb) {
+ cb.addEventListener('change', updateBulk);
+ });
+ updateBulk();
+ }
+
+ document.addEventListener('DOMContentLoaded', reattachListeners);
+ document.addEventListener('htmx:afterSwap', reattachListeners);
+
+ // Export to global scope for onclick handlers in HTML
+ window.toggleAll = toggleAll;
+ window.updateBulk = updateBulk;
+ window.confirmBulk = confirmBulk;
+ window.execBulk = execBulk;
+ window.confirmExport = confirmExport;
+ window.confirmExportFiles = confirmExportFiles;
+ window.confirmDelete = confirmDelete;
+})();
diff --git a/app/public/assets/js/app/admin-tags.js b/app/public/assets/js/app/admin-tags.js
new file mode 100644
index 0000000..03f2a70
--- /dev/null
+++ b/app/public/assets/js/app/admin-tags.js
@@ -0,0 +1,80 @@
+/**
+ * admin-tags.js — Tags/mots-clés admin page: bulk selection, merge, delete, and
+ * HTMX-reloaded attachment.
+ *
+ * Exported globals: tagsToggleAll, tagsUpdateBulk, tagsConfirmBulkMerge,
+ * tagsExecBulkMerge, tagsConfirmDelete, _submitPendingTagForm.
+ *
+ * Inline-rename functions (tagsStartRename, tagsCancelRename) remain in the
+ * template because they embed PHP-generated icon SVGs.
+ */
+var _pendingTagForm = null;
+
+function tagsToggleAll(src) {
+ document.querySelectorAll('input[name="selected_tags[]"]').forEach(function (cb) {
+ cb.checked = src.checked;
+ });
+ tagsUpdateBulk();
+}
+
+function tagsUpdateBulk() {
+ var n = document.querySelectorAll('input[name="selected_tags[]"]:checked').length;
+ document.getElementById('tags-selected-count').textContent = n;
+ document.getElementById('tags-bulk-actions').style.display = n > 1 ? 'flex' : 'none';
+}
+
+function tagsConfirmBulkMerge() {
+ var checked = document.querySelectorAll('input[name="selected_tags[]"]:checked');
+ if (checked.length < 2) return;
+ document.getElementById('bulk-merge-count').textContent = checked.length;
+ var sel = document.getElementById('bulk-merge-target-select');
+ sel.innerHTML = '';
+ checked.forEach(function (cb) {
+ var tr = cb.closest('tr');
+ sel.innerHTML +=
+ '';
+ });
+ document.getElementById('bulk-merge-dialog').showModal();
+}
+
+function tagsExecBulkMerge() {
+ var targetId = document.getElementById('bulk-merge-target-select').value;
+ if (!targetId) return;
+ document.getElementById('tags-bulk-target').value = targetId;
+ var container = document.getElementById('tags-bulk-checkboxes');
+ container.innerHTML = '';
+ document.querySelectorAll('input[name="selected_tags[]"]:checked').forEach(function (cb) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden';
+ inp.name = 'selected_tags[]';
+ inp.value = cb.value;
+ container.appendChild(inp);
+ });
+ document.getElementById('bulk-merge-dialog').close();
+ document.getElementById('tags-bulk-form').submit();
+}
+
+function tagsConfirmDelete(btn, name) {
+ _pendingTagForm = btn.closest('form');
+ document.getElementById('delete-tag-name').textContent = name;
+ document.getElementById('delete-tag-dialog').showModal();
+}
+
+function _submitPendingTagForm() {
+ if (_pendingTagForm) _pendingTagForm.submit();
+}
+
+document.addEventListener('htmx:afterSwap', function (evt) {
+ if (evt.target.id === 'tags-table-wrap') {
+ document
+ .querySelectorAll('input[name="selected_tags[]"]')
+ .forEach(function (cb) {
+ cb.addEventListener('change', tagsUpdateBulk);
+ });
+ tagsUpdateBulk();
+ }
+});
diff --git a/app/public/assets/js/app/admin-toc.js b/app/public/assets/js/app/admin-toc.js
new file mode 100644
index 0000000..b50ab2f
--- /dev/null
+++ b/app/public/assets/js/app/admin-toc.js
@@ -0,0 +1,63 @@
+/**
+ * admin-toc.js — Sticky table-of-contents with IntersectionObserver active-section
+ * highlighting.
+ *
+ * Renders nav links from section[aria-labelledby] headings inside #main-content.
+ * Hides the TOC aside if fewer than 2 sections exist.
+ */
+(function () {
+ function build() {
+ var main = document.getElementById('main-content');
+ var nav = document.getElementById('admin-toc-list');
+ var aside = document.getElementById('admin-toc');
+ if (!main || !nav || !aside) return;
+
+ var sections = main.querySelectorAll('section[aria-labelledby]');
+ if (sections.length < 2) {
+ aside.hidden = true;
+ return;
+ }
+
+ var items = [];
+ sections.forEach(function (sec) {
+ var headingId = sec.getAttribute('aria-labelledby');
+ var heading = document.getElementById(headingId);
+ if (!heading) return;
+ if (!sec.id) sec.id = headingId;
+
+ var a = document.createElement('a');
+ a.href = '#' + sec.id;
+ a.textContent = heading.textContent.trim();
+ a.style.display = 'block';
+ nav.appendChild(a);
+ items.push({ section: sec, link: a });
+ });
+
+ var observer = new IntersectionObserver(
+ function (entries) {
+ var best = null,
+ bestRatio = 0;
+ entries.forEach(function (e) {
+ if (e.intersectionRatio > bestRatio) {
+ bestRatio = e.intersectionRatio;
+ best = e.target;
+ }
+ });
+ items.forEach(function (item) {
+ item.link.classList.toggle('admin-toc-active', item.section === best);
+ });
+ },
+ { rootMargin: '-10% 0px -70% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] }
+ );
+
+ items.forEach(function (item) {
+ observer.observe(item.section);
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', build);
+ } else {
+ build();
+ }
+})();
diff --git a/app/public/assets/js/app/form-duration-toggle.js b/app/public/assets/js/app/form-duration-toggle.js
new file mode 100644
index 0000000..3502eac
--- /dev/null
+++ b/app/public/assets/js/app/form-duration-toggle.js
@@ -0,0 +1,51 @@
+/**
+ * form-duration-toggle.js — Duration unit toggle on TFE form.
+ *
+ * Switches between integer input (pages/mo) and h/m/s time inputs (durée).
+ * Updates hidden #duration_value on change.
+ */
+(function () {
+ var unit = document.getElementById('duration_unit');
+ var hidden = document.getElementById('duration_value');
+ var intWrap = document.getElementById('duration-value-integer');
+ var intInput = document.getElementById('duration_value_int');
+ var intLabel = document.getElementById('duration-value-label');
+ var timeWrap = document.getElementById('duration-value-time');
+ var hInput = document.getElementById('duration_h');
+ var mInput = document.getElementById('duration_m');
+ var sInput = document.getElementById('duration_s');
+
+ var LABELS = { pages: 'Nombre :', mo: 'Taille :', durée: 'Durée :' };
+ if (!unit || !hidden) return;
+
+ function updateHidden() {
+ if (unit.value === 'durée') {
+ var h = parseInt(hInput.value, 10) || 0;
+ var m = parseInt(mInput.value, 10) || 0;
+ var s = parseInt(sInput.value, 10) || 0;
+ var total = h + m / 60 + s / 3600;
+ hidden.value = total > 0 ? total.toFixed(6) : '';
+ } else {
+ hidden.value = intInput.value;
+ }
+ }
+
+ function toggleFields() {
+ if (unit.value === 'durée') {
+ intWrap.style.display = 'none';
+ timeWrap.style.display = '';
+ } else {
+ timeWrap.style.display = 'none';
+ intWrap.style.display = '';
+ if (intLabel) intLabel.textContent = LABELS[unit.value] || 'Valeur :';
+ }
+ updateHidden();
+ }
+
+ unit.addEventListener('change', toggleFields);
+ if (intInput) intInput.addEventListener('input', updateHidden);
+ if (hInput) hInput.addEventListener('input', updateHidden);
+ if (mInput) mInput.addEventListener('input', updateHidden);
+ if (sInput) sInput.addEventListener('input', updateHidden);
+ toggleFields();
+})();
diff --git a/app/public/assets/js/app/form-jury-fields.js b/app/public/assets/js/app/form-jury-fields.js
new file mode 100644
index 0000000..92e0239
--- /dev/null
+++ b/app/public/assets/js/app/form-jury-fields.js
@@ -0,0 +1,66 @@
+/**
+ * form-jury-fields.js — Jury fieldset helpers: dynamic row add/remove and ULB
+ * promoteur conditional toggle (based on finality selection).
+ *
+ * Reads `data-admin-mode` (0/1) from the jury