Refactoring : sécurité (XSS), découpage en modules inc/* et js/admin/*, IDs résolus par slug, perf (caches, cron Gravatar, assets auto-hébergés), tests

This commit is contained in:
2026-06-10 21:30:25 +02:00
parent e6b73df516
commit 9280c3b9ce
44 changed files with 3209 additions and 2907 deletions

299
js/admin/admin-post-edit.js Normal file
View File

@@ -0,0 +1,299 @@
/**
* Page d'édition de post (post.php / post-new.php) : onglets FR/EN du corps,
* réordonnancement des metaboxes, groupement des axes, visibilité
* conditionnelle des boxes Pods, popovers d'aide, fixes TinyMCE/Gutenberg.
* Dépend de admin-base.js (window.ThalimAdmin) — enqueue conditionnel.
*/
(function($) {
'use strict';
var TA = window.ThalimAdmin;
var CONFIG = TA.CONFIG;
var safeRun = TA.safeRun;
// Phase 1: insert the tab bar and relocate #pods-meta-body-en.
// The DOM move breaks TinyMCE's iframe (browsers reset iframe content on detach),
// so we leave the container visible here and let Pods/TinyMCE initialise normally.
// The broken iframe is repaired by reinitEditor() on first EN tab open.
function setupBodyTabsDom() {
var nativeEditor = document.getElementById('postdivrich') || document.getElementById('postdiv');
var bodyEnBox = document.querySelector(CONFIG.boxes.bodyEn);
if (!nativeEditor || !bodyEnBox) return;
var tabBar = document.createElement('div');
tabBar.className = 'body-lang-tabs';
tabBar.innerHTML =
'<button type="button" class="body-lang-tab is-active" data-panel="fr">Français</button>' +
'<button type="button" class="body-lang-tab" data-panel="en">English</button>';
nativeEditor.parentNode.insertBefore(tabBar, nativeEditor);
// Move EN metabox to sit right after the native editor for correct visual layout.
// Do NOT hide it yet — Pods must init TinyMCE with the container visible so the
// iframe can measure its dimensions. Page is still opacity:0 so no flash.
nativeEditor.parentNode.insertBefore(bodyEnBox, nativeEditor.nextSibling);
}
// Phase 2: wire tab click handlers — runs at t=100ms after metabox reordering.
function initBodyLanguageTabs() {
var nativeEditor = document.getElementById('postdivrich') || document.getElementById('postdiv');
var bodyEnBox = document.querySelector(CONFIG.boxes.bodyEn);
var tabBar = document.querySelector('.body-lang-tabs');
if (!nativeEditor || !bodyEnBox || !tabBar) {
// body_en not available (e.g. contributor role) — still force visual mode on main editor
if (nativeEditor) TA.ensureVisualMode('content');
return;
}
var enEditorId = CONFIG.editors.bodyEn;
var enTmceReady = false;
// Hide EN panel — page is still opacity:0, user won't see the switch
bodyEnBox.style.display = 'none';
tabBar.querySelectorAll('.body-lang-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
tabBar.querySelectorAll('.body-lang-tab').forEach(function(b) {
b.classList.remove('is-active');
});
btn.classList.add('is-active');
var revealedPanel;
if (btn.dataset.panel === 'fr') {
bodyEnBox.style.display = 'none';
nativeEditor.style.opacity = '0';
nativeEditor.style.display = '';
revealedPanel = nativeEditor;
} else {
nativeEditor.style.display = 'none';
bodyEnBox.style.opacity = '0';
bodyEnBox.style.display = 'block';
revealedPanel = bodyEnBox;
if (!enTmceReady) {
enTmceReady = true;
// Reinit while container is visible so TinyMCE can measure dimensions
TA.reinitEditor(enEditorId);
}
}
// Notify TinyMCE to reflow, then fade in once layout is correct
setTimeout(function() {
window.dispatchEvent(new Event('resize'));
requestAnimationFrame(function() {
requestAnimationFrame(function() {
revealedPanel.style.opacity = '';
});
});
}, 50);
});
});
// Ensure both editors start in Visual (not Code) mode
TA.ensureVisualMode('content');
TA.ensureVisualMode(enEditorId);
}
function groupAxesCheckboxes() {
if (!window.thalimAxesGroups || !thalimAxesGroups.length) return;
var row = document.querySelector('.' + CONFIG.rows.axes);
if (!row) return;
var list = row.querySelector('ul');
if (!list) return;
// Already grouped — nothing to do
if (list.querySelector('.axes-group-label')) return;
// Map existing <li> by checkbox value; preserve "add new" button
var liMap = {};
var addNewItem = null;
list.querySelectorAll('li').forEach(function(li) {
if (li.classList.contains('pods-pick-add-new')) { addNewItem = li; return; }
var cb = li.querySelector('input[type="checkbox"]');
if (cb) liMap[cb.value] = li;
});
// Rebuild list in group order
list.innerHTML = '';
thalimAxesGroups.forEach(function(group) {
var labelLi = document.createElement('li');
labelLi.className = 'axes-group-label';
labelLi.textContent = group.label;
list.appendChild(labelLi);
group.terms.forEach(function(term) {
var li = liMap[String(term.id)];
if (li) list.appendChild(li);
});
});
if (addNewItem) list.appendChild(addNewItem);
}
var REF_BIB_EDITOR_ID = CONFIG.editors.refBib;
var refBibReinited = false;
// Reinit the référence bibliographique TinyMCE editor.
// Called at page load (if the field is already visible) and by the
// MutationObserver (when the field becomes visible after a category change).
function initRefBibEditor() {
if (refBibReinited) return;
var row = document.querySelector('.' + CONFIG.rows.refBib);
if (!row || row.style.display === 'none') return;
refBibReinited = true;
TA.reinitEditor(REF_BIB_EDITOR_ID);
TA.ensureVisualMode(REF_BIB_EDITOR_ID);
}
function initAxesGroupObserver() {
// Pods shows/hides conditional rows by removing inline style="display:none"
// Watch the entire Pods meta form for style changes on the axes row
var podsForm = document.querySelector('.pods-pick-values, ' + CONFIG.boxes.champsContextuels + ', form#post');
if (!podsForm) podsForm = document.body;
var observer = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var target = mutations[i].target;
if (target.classList && target.classList.contains(CONFIG.rows.axes)) {
if (target.style.display !== 'none') {
setTimeout(groupAxesCheckboxes, 50);
}
}
// Reinit TinyMCE on the référence bibliographique field when its
// row becomes visible — Pods hides it with display:none which breaks
// the TinyMCE iframe. Only reinit once per page load.
if (!refBibReinited && target.classList &&
target.classList.contains(CONFIG.rows.refBib)) {
if (target.style.display !== 'none') {
setTimeout(initRefBibEditor, 100);
}
}
}
});
observer.observe(podsForm, { attributes: true, attributeFilter: ['style'], subtree: true });
}
// Gutenberg's Popover component closes on outside click via focusout detection.
// But if focus never enters the popover, focusout never fires and clicking outside
// does nothing. Fix: focus the popover container as soon as it appears in the DOM.
function initDatePickerPopoverFix() {
var observer = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (node.nodeType !== 1) continue;
var content = node.classList.contains('components-popover__content')
? node
: node.querySelector && node.querySelector('.components-popover__content');
if (content) {
var c = content;
requestAnimationFrame(function() {
if (!c.hasAttribute('tabindex')) c.setAttribute('tabindex', '-1');
c.focus();
});
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function updateMembresGridSeparator() {
var sep = document.querySelector(CONFIG.boxes.membres + ' .membres-grid-separator');
if (!sep) return;
var autreRows = document.querySelectorAll(CONFIG.boxes.membres + ' [class*="pods-form-ui-row-name-autre-"]');
var anyVisible = Array.from(autreRows).some(function(row) {
return row.style.display !== 'none';
});
sep.style.display = anyVisible ? '' : 'none';
}
function initPostEditPage() {
// Disable category options (CSS handles the color)
const categorieSelect = document.querySelector(CONFIG.categorySelect);
if (categorieSelect) {
categorieSelect.querySelectorAll('option').forEach(option => {
if (CONFIG.disabledCategoryIds.includes(option.value)) {
option.disabled = true;
}
});
}
// Reorder meta boxes
const sideSortables = document.querySelector('#side-sortables');
if (sideSortables) {
const typeDannonce = document.querySelector(CONFIG.boxes.typeDannonce);
const affichageAccueil = document.querySelector(CONFIG.boxes.affichageAccueil);
const thematique = document.querySelector(CONFIG.boxes.thematique);
if (typeDannonce) sideSortables.prepend(typeDannonce);
if (affichageAccueil) sideSortables.appendChild(affichageAccueil);
if (thematique) sideSortables.appendChild(thematique);
}
const submitDiv = document.querySelector('#submitdiv');
if (submitDiv && submitDiv.parentNode) {
submitDiv.parentNode.appendChild(submitDiv);
}
const champsContextuels = document.querySelector(CONFIG.boxes.champsContextuels);
if (champsContextuels && champsContextuels.parentNode) {
champsContextuels.parentNode.prepend(champsContextuels);
}
// Chaque sous-module est isolé : une exception dans l'un
// n'empêche pas les suivants de s'initialiser.
safeRun('initBodyLanguageTabs', initBodyLanguageTabs);
safeRun('initRefBibEditor', initRefBibEditor);
safeRun('groupAxesCheckboxes', groupAxesCheckboxes);
safeRun('initAxesGroupObserver', initAxesGroupObserver);
safeRun('updatePostboxVisibility', TA.updatePostboxVisibility);
safeRun('initDatePickerPopoverFix', initDatePickerPopoverFix);
safeRun('initInfoPopovers', TA.initInfoPopovers);
// Place #pods-meta-documents-joints in #normal-sortables, right after
// #pods-meta-champs-contextuels. This keeps it out of #post-body-content
// (the body editor section) regardless of whether champsContextuels is
// currently visible. When champsContextuels is hidden it takes no space,
// so documentsJoints simply appears first in #normal-sortables.
const documentsJoints = document.querySelector(CONFIG.boxes.documentsJoints);
if (documentsJoints) {
if (champsContextuels && champsContextuels.parentNode) {
champsContextuels.parentNode.insertBefore(documentsJoints, champsContextuels.nextSibling);
} else {
const normalSortables = document.querySelector('#normal-sortables');
if (normalSortables) normalSortables.prepend(documentsJoints);
}
}
// Inject separator row for the Membres grid layout
var membresTbody = document.querySelector(CONFIG.boxes.membres + ' .form-table tbody');
if (membresTbody && !membresTbody.querySelector('.membres-grid-separator')) {
var sep = document.createElement('tr');
sep.className = 'membres-grid-separator';
membresTbody.appendChild(sep);
}
updateMembresGridSeparator();
}
$(document).ready(function() {
if (!TA.isPostEditPage()) return;
safeRun('setupBodyTabsDom', setupBodyTabsDom);
setTimeout(function() {
safeRun('initPostEditPage', initPostEditPage);
TA.markReady();
}, 100);
$(CONFIG.categorySelect).change(function() {
setTimeout(function() {
safeRun('updatePostboxVisibility', TA.updatePostboxVisibility);
safeRun('updateMembresGridSeparator', updateMembresGridSeparator);
}, 10);
});
});
})(jQuery);