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

405
js/admin/admin-base.js Normal file
View File

@@ -0,0 +1,405 @@
/**
* Socle partagé des customisations admin (namespace window.ThalimAdmin).
* Chargé sur toutes les pages admin, avant les scripts de contexte
* (admin-rename, admin-post-edit, admin-profile, admin-taxonomy-list,
* admin-pods-modal) qui en dépendent.
*/
(function($) {
'use strict';
// ── Configuration ──────────────────────────────────────────
// Sélecteurs et identifiants Pods utilisés par les modules admin,
// centralisés pour qu'un renommage côté Pods ne demande qu'une édition ici.
var CONFIG = {
// Options désactivées dans le select Pods « Type d'annonce » (term IDs)
disabledCategoryIds: ['1', '12', '5', '20'],
// Catégorie « Séance de séminaire » (verrouillée dans la modale Pods)
seanceCategoryId: '12',
// Select Pods de la catégorie
categorySelect: '#pods-form-ui-pods-meta-categorie',
// IDs des éditeurs TinyMCE Pods à réparer (reinitEditor)
editors: {
bodyEn: 'pods-form-ui-pods-meta-body-en',
refBib: 'pods-form-ui-pods-meta-reference-bibliographique'
},
// Metaboxes Pods déplacées / observées
boxes: {
bodyEn: '#pods-meta-body-en',
typeDannonce: '#pods-meta-type-dannonce',
affichageAccueil: '#pods-meta-affichage-sur-laccueil',
thematique: '#pods-meta-thematique',
champsContextuels: '#pods-meta-champs-contextuels',
documentsJoints: '#pods-meta-documents-joints',
membres: '#pods-meta-membres'
},
// Classes des lignes Pods conditionnelles observées (MutationObserver)
rows: {
axes: 'pods-form-ui-row-name-axes-thematiques',
refBib: 'pods-form-ui-row-name-reference-bibliographique'
},
// Taxonomies dont la page liste reçoit l'info-bulle « FR // EN »
translateTaxonomies: ['axe_thematique', 'programme_de_recherche', 'post_tag']
};
// Exécute un bloc d'init de façon isolée : une exception dans un module
// n'empêche pas les modules suivants de s'initialiser.
function safeRun(name, fn) {
try {
fn();
} catch (err) {
if (window.console && console.error) {
console.error('[thalim-admin] ' + name + ' failed:', err);
}
}
}
function isPostEditPage() {
return window.pagenow === 'post'
|| window.pagenow === 'post-new'
// On CPTs, pagenow is the post_type slug — also catch them via the
// body classes WP sets for any post.php / post-new.php screen.
|| document.body.classList.contains('post-php')
|| document.body.classList.contains('post-new-php');
}
function isProfileEditPage() {
return window.pagenow === 'profile' || window.pagenow === 'user-edit' || window.pagenow === 'user-new';
}
function getProfileForm() {
return document.querySelector('#your-profile, #createuser');
}
function isPodsModal() {
return new URLSearchParams(window.location.search).has('pods_modal');
}
function updatePostboxVisibility() {
document.querySelectorAll('.postbox').forEach(function(postBox) {
if (postBox.id.startsWith('pods')) {
// body-en is controlled by language tabs — never auto-hide it
if ('#' + postBox.id === CONFIG.boxes.bodyEn) return;
var fields = postBox.querySelectorAll('tr');
var hasVisibleFields = Array.from(fields).some(function(field) {
return field.style.display !== 'none';
});
postBox.style.display = hasVisibleFields ? 'block' : 'none';
}
});
}
// Force Visual (TinyMCE) mode on page load.
// WP stores the last-used editor mode in localStorage and restores it at document.ready.
// When Code mode is restored, TinyMCE is never initialised — tinymce.get() returns null.
// Instead, check the wrapper's CSS class:
// tmce-active = Visual mode (fine)
// html-active = Code mode (switch to Visual)
function ensureVisualMode(editorId, attempt) {
attempt = attempt || 0;
if (attempt > 15) return;
var wrap = document.getElementById('wp-' + editorId + '-wrap');
if (!wrap) {
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (wrap.classList.contains('html-active')) {
var ed = window.tinymce && tinymce.get(editorId);
if (!ed || !ed.initialized) {
// TinyMCE not ready yet — retry rather than calling switchEditors.go() prematurely
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (typeof switchEditors !== 'undefined') {
switchEditors.go(editorId, 'tmce');
}
return;
}
if (!wrap.classList.contains('tmce-active')) {
// Mode not yet determined — retry
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
}
}
// Rebuild a TinyMCE editor whose iframe is broken (empty/non-interactive).
// This happens when TinyMCE is initialised on a hidden (display:none) element:
// the iframe can't measure dimensions and its document body stays empty.
//
// We reinit from tinyMCEPreInit.mceInit — first trying the editor's own config
// (registered by Pods server-side), falling back to 'content' (the native WP editor).
//
// Inline toolbar positioning fix:
// TinyMCE's 'wordpress' plugin captures document.getElementById(id+'_ifr') during
// 'preinit' — before the iframe is created — so mceIframe is always null.
// Fix: intercept getElementById during preinit so the 'wordpress' plugin captures
// a proxyIframe instead of null. After init, proxy delegates to the real iframe.
function reinitEditor(editorId) {
var ed = window.tinymce && tinymce.get(editorId);
// Preserve existing content before destroying the instance
var savedContent = '';
if (ed) {
try { savedContent = ed.getContent(); } catch (e) {}
ed.remove();
}
if (!savedContent) {
var ta = document.getElementById(editorId);
if (ta) savedContent = ta.value || '';
}
if (!window.tinyMCEPreInit || !window.tinymce) return;
// Use the editor's own server-side config if available, else clone from 'content'
var baseInit = (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit[editorId])
|| (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit['content']);
if (!baseInit) return;
// Proxy iframe: getBoundingClientRect() falls back to the editor wrap
var wrapId = 'wp-' + editorId + '-wrap';
var proxyIframe = {
getBoundingClientRect: function() {
var el = document.getElementById(wrapId);
return el ? el.getBoundingClientRect()
: { top: 0, left: 0, right: window.innerWidth,
bottom: window.innerHeight, width: window.innerWidth,
height: window.innerHeight };
}
};
var savedGetById = document.getElementById;
var origSetup = baseInit.setup;
var content = savedContent;
tinymce.init($.extend({}, baseInit, {
selector: '#' + editorId,
setup: function(editor) {
if (typeof origSetup === 'function') origSetup(editor);
editor.on('focus', function() {
window.wpActiveEditor = editorId;
});
editor.on('preinit', function() {
document.getElementById = function(id) {
if (id === editorId + '_ifr') return proxyIframe;
return savedGetById.call(document, id);
};
setTimeout(function() {
document.getElementById = savedGetById;
}, 0);
});
editor.on('init', function() {
// Point proxy to real iframe
var realIframe = savedGetById.call(document, editorId + '_ifr');
if (realIframe) {
proxyIframe.getBoundingClientRect = function() {
return realIframe.getBoundingClientRect();
};
}
// Restore content that was in the textarea
if (content) {
editor.setContent(content);
}
});
}
}));
}
// ── Info-popovers (post / user / taxonomy) ─────────────────
var INFO_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 11v6"/><path d="M12 8v.01" stroke-width="2"/></svg>';
var TRANSLATE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
var TRANSLATE_LINES = [
'Traduction en anglais après //',
'ex : Texte en français // English text'
];
// Tips without a `page` key default to 'post'.
// type: 'translate' uses the globe icon + green button style.
var INFO_TIPS = [
// --- post edit page: info ---
{
selector: '.wp-heading-inline',
lines: [
'Saisir le titre anglais après //',
'ex : Titre de lannonce // Title of the announcement'
]
},
{
selector: '#pods-meta-documents-joints .postbox-header h2',
lines: [
'Ajouter les images dans les documents.',
'Ajouter les légendes comme titre du document.'
]
},
{
selector: '#pods-meta-membres .postbox-header h2',
lines: [
'Le champ fonction change le libellé de la liste de personnes citées.',
'Le champ membre permet de lister les membres de Thalim liés à lannonce.',
'Le champ autre personnes permet de lister des personnes extérieures à Thalim.'
]
},
{
selector: '#pods-meta-dates .postbox-header h2',
lines: [
'Pour entrer une date sans lheure, régler lheure sur 00:00.'
]
},
{
selector: '#pods-meta-affichage-sur-laccueil .postbox-header h2',
lines: [
'Épingler lannonce dans le diaporama la fait safficher avant les autres.'
]
},
{
selector: '#pods-meta-medias .postbox-header h2',
lines: [
'Pour ajouter un média Canal U, copier le lien depuis « Citer cette ressource ».',
'ex : https://www.canal-u.tv/166564'
]
},
// --- post edit page: translate ---
{ type: 'translate', selector: '#pods-meta-documents-joints .postbox-header h2', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-sous-titre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-lieu th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-organisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-intervention th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-candidat th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-realisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-dirige th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-redaction th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-auteur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-responsable th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-concerne th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-directeur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-direction-d-ouvrage th',lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-intervenant th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-participants th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-type-autre th', lines: TRANSLATE_LINES },
// --- contenu_general edit page: translate ---
{ type: 'translate', selector: '.pods-form-ui-row-name-umr th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-thalim th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-siecles th', lines: TRANSLATE_LINES },
// --- user/profile edit page: translate ---
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-4 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affiliation-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-de-these th', lines: TRANSLATE_LINES },
// --- taxonomy edit pages: translate ---
{ type: 'translate', page: 'taxonomy', selector: 'label[for="name"]', lines: TRANSLATE_LINES },
// --- user/profile edit page: info ---
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-identifiant-hal',
lines: [
'Renseigner votre idHAL (en lettres), pas votre PersonId (en chiffres).'
]
},
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-affichage-du-statut-1',
lines: [
'Texte de statut affiché sur le profil publique.'
]
}
];
var _popoverCloseHandlerRegistered = false;
function initInfoPopovers(currentPage) {
currentPage = currentPage || 'post';
INFO_TIPS.forEach(function(tip) {
if ((tip.page || 'post') !== currentPage) return;
var el = document.querySelector(tip.selector);
if (!el) return;
var isTranslate = tip.type === 'translate';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = isTranslate ? 'thalim-translate-btn' : 'thalim-info-btn';
btn.setAttribute('aria-label', isTranslate ? 'Traduction bilingue' : 'Informations');
btn.innerHTML = isTranslate ? TRANSLATE_ICON : INFO_ICON;
var popover = document.createElement('div');
popover.className = 'thalim-info-popover' + (isTranslate ? ' thalim-translate-popover' : '');
popover.innerHTML = tip.lines.map(function(line) {
return '<p>' + line + '</p>';
}).join('');
var wrapper = document.createElement('span');
wrapper.className = 'thalim-info-wrapper';
wrapper.appendChild(btn);
wrapper.appendChild(popover);
el.appendChild(wrapper);
btn.addEventListener('click', function(e) {
e.stopPropagation();
var isOpen = popover.classList.contains('is-open');
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
if (!isOpen) {
var rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + 6) + 'px';
popover.style.left = (rect.left + rect.width / 2) + 'px';
popover.classList.add('is-open');
}
});
popover.addEventListener('click', function(e) {
e.stopPropagation();
});
});
if (!_popoverCloseHandlerRegistered) {
_popoverCloseHandlerRegistered = true;
document.addEventListener('click', function() {
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
});
}
}
// ── Reveal (#wpbody est masqué en CSS sur post/profil jusqu'à l'init) ──
function markReady() {
document.body.classList.add('admin-mods-ready');
}
// Fallback global : force le reveal après 2 s même si le script de
// contexte a planté ou n'a pas été chargé.
$(document).ready(function() {
setTimeout(markReady, 2000);
});
window.ThalimAdmin = {
CONFIG: CONFIG,
safeRun: safeRun,
isPostEditPage: isPostEditPage,
isProfileEditPage: isProfileEditPage,
getProfileForm: getProfileForm,
isPodsModal: isPodsModal,
ensureVisualMode: ensureVisualMode,
reinitEditor: reinitEditor,
updatePostboxVisibility: updatePostboxVisibility,
initInfoPopovers: initInfoPopovers,
markReady: markReady
};
})(jQuery);

View File

@@ -0,0 +1,49 @@
/**
* Modale Pods de création de séance (URL contenant pods_modal) :
* verrouille la catégorie sur « Séance de séminaire ».
* 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;
$(document).ready(function() {
if (!TA.isPodsModal()) return;
var lockSeanceCategory = function() {
var seanceCat = CONFIG.seanceCategoryId;
var itemId = $('#post_ID').val();
if (window.PodsDFV && itemId) {
window.PodsDFV.setFieldValue('post', itemId, 'categorie', seanceCat, 0);
}
// Lock category select to the séance category in iframe —
// delay to run after Pods React re-render
setTimeout(function() {
var $select = $(CONFIG.categorySelect);
if ($select.length) {
$select.find('option').each(function() {
this.disabled = this.value !== seanceCat;
});
$select.val(seanceCat);
}
safeRun('updatePostboxVisibility', TA.updatePostboxVisibility);
}, 200);
};
// Dans l'iframe de la modale, window.load peut déjà être passé au moment
// où ce code s'exécute : s'abonner à un load déjà émis ne rejoue rien.
// On exécute donc tout de suite si la page est déjà chargée.
if (document.readyState === 'complete') {
safeRun('lockSeanceCategory', lockSeanceCategory);
} else {
$(window).on('load', function() {
safeRun('lockSeanceCategory', lockSeanceCategory);
});
}
});
})(jQuery);

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);

149
js/admin/admin-profile.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* Pages profil / utilisateur (profile.php, user-edit.php, user-new.php) :
* réordonnancement des sections natives, popovers d'aide, mode visuel forcé
* sur les WYSIWYG Pods.
* Dépend de admin-base.js (window.ThalimAdmin) — enqueue conditionnel.
*/
(function($) {
'use strict';
var TA = window.ThalimAdmin;
var safeRun = TA.safeRun;
// Only native WP field sections — never touch Pods tables (they may contain TinyMCE editors)
var PROFILE_SECTION_KEYS = [
'user-language-wrap',
'user-first-name-wrap',
'user-email-wrap',
'user-pass1-wrap',
'upload-avatar-row',
];
// Desired order. Groups of 2 are wrapped in a flex row and displayed side by side.
var PROFILE_ORDER = [
['user-first-name-wrap', 'upload-avatar-row'],
['user-email-wrap'],
['user-language-wrap', 'user-pass1-wrap'],
];
function reorderProfileSections() {
var form = TA.getProfileForm();
if (!form) return;
var pairMap = {};
form.querySelectorAll('table.form-table').forEach(function(table) {
PROFILE_SECTION_KEYS.forEach(function(key) {
if (pairMap[key] || !table.querySelector('.' + key)) return;
// Find the associated heading: first try preceding sibling in same parent,
// then look for an h2/h3 inside the same wrapper element.
var h2 = null;
var el = table.previousElementSibling;
while (el) {
if (el.tagName === 'H2' || el.tagName === 'H3') { h2 = el; break; }
if (el.tagName === 'TABLE') break;
el = el.previousElementSibling;
}
if (!h2 && table.parentElement !== form) {
h2 = table.parentElement.querySelector('h2, h3');
}
// The unit to move: if h2 and table share a non-form wrapper, move the wrapper.
var wrapper = null;
if (h2 && h2.parentElement !== form && h2.parentElement === table.parentElement) {
wrapper = h2.parentElement;
}
pairMap[key] = { h2: h2, table: table, wrapper: wrapper };
});
});
// Remove all matched units from DOM (dedup by actual element)
var removed = new Set();
function removeEl(el) {
if (el && !removed.has(el)) { removed.add(el); el.remove(); }
}
Object.values(pairMap).forEach(function(unit) {
if (unit.wrapper) { removeEl(unit.wrapper); }
else { removeEl(unit.h2); removeEl(unit.table); }
});
// Re-insert in declared order before the submit button
var submitAnchor = form.querySelector('p.submit');
function append(el) {
if (submitAnchor && submitAnchor.parentNode) form.insertBefore(el, submitAnchor);
else form.appendChild(el);
}
function appendUnit(unit) {
if (unit.wrapper) { append(unit.wrapper); }
else { if (unit.h2) append(unit.h2); append(unit.table); }
}
PROFILE_ORDER.forEach(function(group) {
var available = group.filter(function(key) { return !!pairMap[key]; });
if (!available.length) return;
// Dedup: two keys may resolve to the same table/wrapper
var seen = new Set();
var units = [];
available.forEach(function(key) {
var unit = pairMap[key];
var id = unit.wrapper || unit.table;
if (!seen.has(id)) { seen.add(id); units.push(unit); }
});
if (units.length === 1) {
appendUnit(units[0]);
} else {
var row = document.createElement('div');
row.className = 'profile-section-row';
units.forEach(function(unit) {
var col = document.createElement('div');
col.className = 'profile-section-col';
if (unit.wrapper) { col.appendChild(unit.wrapper); }
else { if (unit.h2) col.appendChild(unit.h2); col.appendChild(unit.table); }
row.appendChild(col);
});
append(row);
}
});
}
function initProfileEditors() {
reorderProfileSections();
TA.initInfoPopovers('user');
// Hide the "À propos du compte" section heading
document.querySelectorAll('#your-profile h2, #adduser h2, #createuser h2').forEach(function(h2) {
if (h2.textContent.trim() === 'À propos du compte') {
h2.style.display = 'none';
}
});
// Rename "Rôle" label to "Rôle sur le site"
var roleLabel = document.querySelector('label[for="role"]');
if (roleLabel && roleLabel.textContent.trim() === 'Rôle') {
roleLabel.textContent = 'Rôle sur le site';
}
}
$(document).ready(function() {
if (!TA.isProfileEditPage()) return;
setTimeout(function() {
safeRun('initProfileEditors', initProfileEditors);
TA.markReady();
}, 100);
// Force visual mode on all Pods WYSIWYG fields once everything is loaded
$(window).on('load', function() {
var scope = TA.getProfileForm() || document;
scope.querySelectorAll('.pods-dfv-container-wysiwyg textarea').forEach(function(ta) {
if (!ta.id) return;
TA.ensureVisualMode(ta.id);
});
});
});
})(jQuery);

77
js/admin/admin-rename.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* Renomme « Article » en « Annonce » dans l'interface admin (toutes pages).
* Dépend de admin-base.js (window.ThalimAdmin).
*/
(function() {
'use strict';
var TA = window.ThalimAdmin;
function renameArticlesToAnnonces() {
const replacements = [
[/Tous les articles/g, 'Toutes les annonces'],
[/Ajouter un article/g, 'Ajouter une annonce'],
[/Modifier l.article/g, "Modifier l'annonce"],
[/Prévisualiser l.article/g, "Prévisualiser l'annonce"],
[/Afficher l.article/g, "Afficher l'annonce"],
[/Voir l.article/g, "Voir l'annonce"],
[/Article publié/g, 'Annonce publiée'],
[/Article mis à jour/g, 'Annonce mise à jour'],
[/Article planifié/g, 'Annonce planifiée'],
[/Articles par page/g, 'Annonces par page'],
[/Articles/g, 'Annonces'],
[/Article/g, 'Annonce'],
[/Rechercher des articles/g, 'Rechercher des annonces'],
];
function applyReplacements(text) {
return replacements.reduce((t, [s, r]) => t.replace(s, r), text);
}
function replaceInTextNodes(el) {
if (!el) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function(node) {
const replaced = applyReplacements(node.textContent);
if (replaced !== node.textContent) node.textContent = replaced;
});
}
// Menu latéral
replaceInTextNodes(document.querySelector('#menu-posts'));
// Titre de page (h1) et bouton d'ajout
document.querySelectorAll('.wp-heading-inline, .page-title-action').forEach(replaceInTextNodes);
// Notifications après sauvegarde (Article publié, mis à jour…)
document.querySelectorAll('#message, .notice').forEach(replaceInTextNodes);
// Boîte de publication — lien "Voir l'article"
replaceInTextNodes(document.querySelector('.submitbox'));
// Options d'écran — "Articles par page"
replaceInTextNodes(document.querySelector('#screen-options-wrap'));
// Bouton de recherche (attribut value + aria-label)
var searchSubmit = document.querySelector('#search-submit');
if (searchSubmit) {
if (searchSubmit.value) {
searchSubmit.value = applyReplacements(searchSubmit.value);
}
var ariaLabel = searchSubmit.getAttribute('aria-label');
if (ariaLabel) {
searchSubmit.setAttribute('aria-label', applyReplacements(ariaLabel));
}
}
// Titre de l'onglet du navigateur
document.title = applyReplacements(document.title);
}
document.addEventListener('DOMContentLoaded', function() {
TA.safeRun('renameArticlesToAnnonces', renameArticlesToAnnonces);
});
})();

View File

@@ -0,0 +1,80 @@
/**
* Pages listes/édition de taxonomies (edit-tags.php, term.php) :
* info-bulles « FR // EN », filtre « Type de programme », mode visuel forcé
* sur les WYSIWYG Pods des pages term.
* 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;
// Inject a "Type de programme" filter select into the taxonomy search form.
// The form already has hidden taxonomy/post_type fields so the select value
// is submitted with them and picked up by pre_get_terms server-side.
function initProgrammeFilter() {
var form = document.querySelector('form.search-form');
if (!form) return;
var types = [
'Programme subventionné',
'Autre programme',
'Ancien programme'
];
// Read current filter value from the URL.
var params = new URLSearchParams(window.location.search);
var current = params.get('type_de_programme') || '';
var select = document.createElement('select');
select.name = 'type_de_programme';
select.id = 'filter-type-de-programme';
select.style.cssText = 'margin-right:6px;';
var blank = document.createElement('option');
blank.value = '';
blank.textContent = 'Tous les types';
select.appendChild(blank);
types.forEach(function(type) {
var opt = document.createElement('option');
opt.value = type;
opt.textContent = type;
if (type === current) opt.selected = true;
select.appendChild(opt);
});
// Insert before the first <p> (search-box) inside the form.
var searchBox = form.querySelector('p.search-box');
form.insertBefore(select, searchBox || null);
}
$(document).ready(function() {
setTimeout(function() {
safeRun('taxonomyPopovers', function() {
var isTranslateTaxonomy = CONFIG.translateTaxonomies.some(function(tax) {
return window.location.search.indexOf('taxonomy=' + tax) !== -1;
});
if (isTranslateTaxonomy) {
TA.initInfoPopovers('taxonomy');
}
});
safeRun('programmeFilter', function() {
if (window.location.search.indexOf('taxonomy=programme_de_recherche') !== -1) {
initProgrammeFilter();
}
});
}, 100);
// term.php / edit-tags.php : force visual mode on Pods WYSIWYG fields
$(window).on('load', function() {
document.querySelectorAll('.pods-dfv-container-wysiwyg textarea').forEach(function(ta) {
if (!ta.id) return;
TA.ensureVisualMode(ta.id);
});
});
});
})(jQuery);