templates/pages/_blocks/timeline_block.html.twig line 1

Open in your IDE?
  1. {# templates/blocks/timeline_block.html.twig #}
  2. {% set maxEvents = (content.maxNumberEvents|default(7)) %}
  3. {% set id = "timeline-block-" ~ random() %}
  4. {% set eventsRaw = content.events|default([]) %}
  5. {% set eventsSorted = eventsRaw|sort((a,b) => b.dateEvent <=> a.dateEvent) %}
  6. {% set totalEvents = eventsSorted|length %}
  7. {# 1) On prend uniquement les events visibles #}
  8. {% set visibleEvents = eventsSorted|slice(0, maxEvents) %}
  9. <div
  10. id="{{ id }}" class="timeline-block {{ content.isTextRight is defined and content.isTextRight == true ? 'col-lg-6 col-md-6 col-sm-12' : '' }}">
  11. {# ---------- Frise chronologique ---------- #}
  12. <div class="timeline-events">
  13. {% set lastYear = null %}
  14. {% for ev in visibleEvents %}
  15. {% set year = ev.dateEvent|slice(0,4) %}
  16. {% set side = (loop.index0 % 2 == 0) ? 'right' : 'left' %}
  17. {# Badge année seulement si on a un event visible cette année #}
  18. {% if year != lastYear %}
  19. <div class="timeline-year">
  20. <span class="timeline-year-badge">{{ year }}</span>
  21. </div>
  22. {% set lastYear = year %}
  23. {% endif %}
  24. {% set imgUrl = '' %}
  25. {% if ev.isExternalImageEvent is defined and ev.isExternalImageEvent and ev.externalImageEvent|default('') %}
  26. {% set imgUrl = ev.externalImageEvent %}
  27. {% elseif ev.internalImageEvent is defined and ev.internalImageEvent %}
  28. {% if ev.internalImageEvent.url is defined and ev.internalImageEvent.url %}
  29. {% set imgUrl = sulu_media_url(ev.internalImageEvent, '400x') %}
  30. {% elseif ev.internalImageEvent.id is defined and ev.internalImageEvent.id %}
  31. {% set resolvedEventImage = sulu_resolve_media(ev.internalImageEvent.id, app.request.locale) %}
  32. {% set imgUrl = resolvedEventImage ? sulu_media_url(resolvedEventImage, '400x') : '' %}
  33. {% endif %}
  34. {% endif %}
  35. <div class="timeline-item timeline-item--{{ side }}">
  36. <div class="timeline-item-line">
  37. <span class="timeline-dot" aria-hidden="true"></span>
  38. </div>
  39. <article class="timeline-card">
  40. <div class="timeline-card-body">
  41. <div class="timeline-card-top">
  42. <div class="timeline-pictogram">
  43. {{ getLucidIcon('medal') }}
  44. </div>
  45. <div class="timeline-text">
  46. <div class="timeline-title">{{ ev.titleTextEvent|default('') }}</div>
  47. <div class="timeline-desc">{{ ev.descTextEvent|default('') }}</div>
  48. </div>
  49. {% if imgUrl %}
  50. <figure class="timeline-logo">
  51. <img src="{{ imgUrl }}" alt="{{ ev.alternativeTextImageEvent|default(ev.titleTextEvent|default('')) }}" loading="lazy" decoding="async"/>
  52. </figure>
  53. {% endif %}
  54. </div>
  55. </div>
  56. </article>
  57. </div>
  58. {% endfor %}
  59. </div>
  60. {% if totalEvents > maxEvents %}
  61. <div class="timeline-footer">
  62. <button type="button" class="timeline-toggle">
  63. {{ 'timeline.see_all'|trans }}
  64. </button>
  65. </div>
  66. {% endif %}
  67. <script>
  68. (function () {
  69. // Initialiser la modale si elle n'est pas déjà initialisée
  70. function initTimelineModal() {
  71. const boxTimelineViewer = document.getElementById("box-timeline-viewer");
  72. if (!boxTimelineViewer || window.timelineModalInitialized) {
  73. return;
  74. }
  75. const eventsContainer = boxTimelineViewer.querySelector("#timeline-events-full");
  76. const closeBtn = boxTimelineViewer.querySelector("#close-timeline") || boxTimelineViewer.querySelector(".icon-x");
  77. let timelineData = {};
  78. // Charger les données depuis le script JSON
  79. function loadTimelineData() {
  80. const timelineDataScript = document.querySelector('script[type="application/json"][data-timeline-id="{{ id }}"]');
  81. if (timelineDataScript) {
  82. try {
  83. const dataText = timelineDataScript.textContent.trim();
  84. timelineData = JSON.parse(dataText);
  85. } catch (e) {
  86. {# console.error("Erreur lors du chargement des données de la timeline:", e); #}
  87. }
  88. }
  89. }
  90. // Appliquer les styles CSS depuis les données
  91. function applyStyles() {
  92. if (!timelineData.content || !boxTimelineViewer) {
  93. return;
  94. }
  95. const content = timelineData.content;
  96. // Créer un élément style pour les variables CSS
  97. let styleElement = boxTimelineViewer.querySelector('style[data-timeline-styles]');
  98. if (!styleElement) {
  99. styleElement = document.createElement('style');
  100. styleElement.setAttribute('data-timeline-styles', '');
  101. boxTimelineViewer.appendChild(styleElement);
  102. }
  103. const css = `
  104. #box-timeline-viewer.timeline-modal {
  105. --color-pictogram: ${content.colorPictogram || '#FF5C00'};
  106. --bg-color-pictogram: ${content.backgroundColorPictogram || '#42D7FF'};
  107. --color-title: ${content.colorTitle || '#6C6C6C'};
  108. --font-size-title: ${content.fontSizeTitle || '10px'};
  109. --font-weight-title: ${content.fontWeightTitle || '700'};
  110. --color-desc: ${content.colorDesc || '#1D1D1D'};
  111. --font-size-desc: ${content.fontSizeDesc || '14px'};
  112. --font-weight-desc: ${content.fontWeightDesc || '500'};
  113. }
  114. #box-timeline-viewer .timeline-events-full {
  115. --color-pictogram: ${content.colorPictogram || '#FF5C00'};
  116. --bg-color-pictogram: ${content.backgroundColorPictogram || '#42D7FF'};
  117. --color-title: ${content.colorTitle || '#6C6C6C'};
  118. --font-size-title: ${content.fontSizeTitle || '10px'};
  119. --font-weight-title: ${content.fontWeightTitle || '700'};
  120. --color-desc: ${content.colorDesc || '#1D1D1D'};
  121. --font-size-desc: ${content.fontSizeDesc || '14px'};
  122. --font-weight-desc: ${content.fontWeightDesc || '500'};
  123. }
  124. `;
  125. styleElement.textContent = css;
  126. }
  127. // Rendre la frise chronologique complète
  128. function renderTimeline() {
  129. if (!eventsContainer || !timelineData.events) {
  130. return;
  131. }
  132. // S'assurer que events est un tableau (peut être un objet si JSON mal sérialisé)
  133. let events = timelineData.events;
  134. if (!Array.isArray(events)) {
  135. // Convertir l'objet en tableau si nécessaire
  136. events = Object.values(events);
  137. }
  138. if (events.length === 0) {
  139. eventsContainer.innerHTML = "<p>{{ 'timeline.no_event'|trans }}</p>";
  140. return;
  141. }
  142. // Trier les événements par date décroissante
  143. const sortedEvents = [...events].sort((a, b) => {
  144. const dateA = a.dateEvent || "";
  145. const dateB = b.dateEvent || "";
  146. return dateB.localeCompare(dateA);
  147. });
  148. // Générer le HTML de la timeline
  149. let html = "";
  150. let lastYear = null;
  151. sortedEvents.forEach((ev, index) => {
  152. const year = ev.dateEvent ? ev.dateEvent.slice(0, 4) : "";
  153. const side = index % 2 === 0 ? "right" : "left";
  154. // Badge année seulement si on a un event visible cette année
  155. if (year && year !== lastYear) {
  156. html += `<div class="timeline-year"><span class="timeline-year-badge">${year}</span></div>`;
  157. lastYear = year;
  158. }
  159. // Récupérer l'URL de l'image
  160. let imgUrl = "";
  161. if (timelineData.eventImageUrls && timelineData.eventImageUrls[index]) {
  162. imgUrl = timelineData.eventImageUrls[index];
  163. } else if (ev.isExternalImageEvent && ev.externalImageEvent) {
  164. imgUrl = ev.externalImageEvent;
  165. } else if (ev.internalImageEvent && ev.internalImageEvent.url) {
  166. imgUrl = ev.internalImageEvent.url;
  167. }
  168. const title = ev.titleTextEvent || "";
  169. const desc = ev.descTextEvent || "";
  170. const alt = ev.alternativeTextImageEvent || title;
  171. // Échapper les caractères HTML pour éviter les injections XSS
  172. const escapeHtml = (text) => {
  173. const div = document.createElement('div');
  174. div.textContent = text;
  175. return div.innerHTML;
  176. };
  177. html += `
  178. <div class="timeline-item timeline-item--${side}">
  179. <div class="timeline-item-line">
  180. <span class="timeline-dot" aria-hidden="true"></span>
  181. </div>
  182. <article class="timeline-card">
  183. <div class="timeline-card-body">
  184. <div class="timeline-card-top">
  185. <div class="timeline-pictogram">
  186. ${timelineData.svgIcon || ""}
  187. </div>
  188. <div class="timeline-text">
  189. <div class="timeline-title">${escapeHtml(title)}</div>
  190. <div class="timeline-desc">${escapeHtml(desc)}</div>
  191. </div>
  192. ${imgUrl ? `<figure class="timeline-logo"><img src="${escapeHtml(imgUrl)}" alt="${escapeHtml(alt)}" loading="lazy" decoding="async"/></figure>` : ""}
  193. </div>
  194. </div>
  195. </article>
  196. </div>
  197. `;
  198. });
  199. eventsContainer.innerHTML = html;
  200. // Ajuster la hauteur de la ligne verticale pour qu'elle couvre tous les événements
  201. setTimeout(() => {
  202. const firstItem = eventsContainer.querySelector('.timeline-item');
  203. const lastItem = Array.from(eventsContainer.querySelectorAll('.timeline-item')).pop();
  204. if (firstItem && lastItem) {
  205. const firstRect = firstItem.getBoundingClientRect();
  206. const lastRect = lastItem.getBoundingClientRect();
  207. const containerRect = eventsContainer.getBoundingClientRect();
  208. // Calculer la position du premier point (centre du dot du premier item)
  209. const firstDot = firstItem.querySelector('.timeline-dot');
  210. const lastDot = lastItem.querySelector('.timeline-dot');
  211. if (firstDot && lastDot) {
  212. const firstDotRect = firstDot.getBoundingClientRect();
  213. const lastDotRect = lastDot.getBoundingClientRect();
  214. const lineTop = firstDotRect.top - containerRect.top;
  215. const lineBottom = containerRect.bottom - lastDotRect.bottom;
  216. const lineHeight = containerRect.height - lineTop - lineBottom;
  217. if (lineHeight > 0) {
  218. // Créer ou mettre à jour le style pour la ligne
  219. let styleElement = eventsContainer.querySelector('style[data-timeline-line-height]');
  220. if (!styleElement) {
  221. styleElement = document.createElement('style');
  222. styleElement.setAttribute('data-timeline-line-height', '');
  223. eventsContainer.appendChild(styleElement);
  224. }
  225. styleElement.textContent = `
  226. #box-timeline-viewer .timeline-events-full::before {
  227. top: ${lineTop}px !important;
  228. height: ${lineHeight}px !important;
  229. bottom: auto !important;
  230. }
  231. `;
  232. }
  233. }
  234. }
  235. }, 100);
  236. }
  237. // Gérer la fermeture
  238. function closeModal() {
  239. boxTimelineViewer.classList.remove("opened");
  240. boxTimelineViewer.classList.add("closing");
  241. document.body.classList.remove("modale_opened");
  242. setTimeout(() => {
  243. boxTimelineViewer.classList.remove("closing");
  244. // Utiliser la bonne transformation selon la taille d'écran
  245. const isMobile = window.innerWidth <= 768;
  246. boxTimelineViewer.style.transform = isMobile ? "translateY(100vh)" : "translate(-50%, 100vh)";
  247. }, 500);
  248. }
  249. if (closeBtn) {
  250. closeBtn.addEventListener("click", closeModal);
  251. }
  252. document.addEventListener("click", function (e) {
  253. if (!boxTimelineViewer.classList.contains("opened")) {
  254. return;
  255. }
  256. if (boxTimelineViewer.contains(e.target)) {
  257. return;
  258. }
  259. closeModal();
  260. });
  261. // Fermer avec Escape
  262. document.addEventListener("keydown", (e) => {
  263. if (e.key === "Escape" && boxTimelineViewer.classList.contains("opened")) {
  264. closeModal();
  265. }
  266. });
  267. // Charger les données au démarrage
  268. loadTimelineData();
  269. // Exposer la fonction d'ouverture globalement
  270. window.openTimelineModal = function (timelineDataParam) {
  271. if (timelineDataParam) {
  272. timelineData = timelineDataParam;
  273. } else {
  274. loadTimelineData();
  275. }
  276. // Appliquer les styles CSS
  277. applyStyles();
  278. // Ajouter la classe timeline-modal pour les styles spécifiques
  279. boxTimelineViewer.classList.add("timeline-modal");
  280. if (boxTimelineViewer.parentElement !== document.body) {
  281. document.body.appendChild(boxTimelineViewer);
  282. }
  283. boxTimelineViewer.classList.add("opened");
  284. boxTimelineViewer.classList.remove("closing");
  285. document.body.classList.add("modale_opened");
  286. // Rendre la timeline
  287. renderTimeline();
  288. };
  289. window.timelineModalInitialized = true;
  290. }
  291. // Initialiser la modale
  292. if (document.readyState === 'loading') {
  293. document.addEventListener('DOMContentLoaded', initTimelineModal);
  294. } else {
  295. initTimelineModal();
  296. }
  297. // Gestion du bouton "Voir tout"
  298. const root = document.currentScript.closest('.timeline-block');
  299. if (!root) return;
  300. const btn = root.querySelector('.timeline-toggle');
  301. if (!btn) return;
  302. btn.addEventListener('click', function (e) {
  303. e.stopPropagation();
  304. // Récupérer les données de la timeline depuis le script JSON
  305. const timelineDataScript = document.querySelector('script[type="application/json"][data-timeline-id="{{ id }}"]');
  306. let timelineData = {};
  307. if (timelineDataScript) {
  308. try {
  309. timelineData = JSON.parse(timelineDataScript.textContent);
  310. } catch (e) {
  311. {# console.error('Erreur lors du chargement des données de la timeline:', e); #}
  312. }
  313. }
  314. // S'assurer que la modale est initialisée
  315. if (!window.timelineModalInitialized) {
  316. initTimelineModal();
  317. }
  318. // Ouvrir la modale via la fonction globale
  319. if (window.openTimelineModal) {
  320. window.openTimelineModal(timelineData);
  321. } else {
  322. {# console.error('La fonction openTimelineModal n\'est pas disponible'); #}
  323. }
  324. });
  325. })();
  326. </script>
  327. </div>
  328. {% if content.isTextRight is defined and content.isTextRight == true %}
  329. <div class="timeline-text-right {{ content.isTextRight is defined and content.isTextRight == true ? 'col-lg-6 col-md-6 col-sm-12' : '' }}">
  330. {{ content.textToRight|default('')|raw }}
  331. </div>
  332. {% endif %}
  333. {# Modale "Voir tout" pour la frise chronologique #}
  334. {% include 'pages/_includes/details/_timeline-modal.html.twig' %}
  335. {# Réindexer le tableau pour garantir une sérialisation JSON en tableau et non en objet #}
  336. {% set eventsReindexed = [] %}
  337. {% set eventImageUrls = [] %}
  338. {% for ev in eventsSorted %}
  339. {% set evCdnUrl = '' %}
  340. {% if ev.isExternalImageEvent|default(false) and ev.externalImageEvent|default('') %}
  341. {% set evCdnUrl = ev.externalImageEvent %}
  342. {% elseif ev.internalImageEvent is defined and ev.internalImageEvent %}
  343. {% set evCdnUrl = sulu_media_url(ev.internalImageEvent, '400x') %}
  344. {% endif %}
  345. {% set eventsReindexed = eventsReindexed|merge([ev]) %}
  346. {% set eventImageUrls = eventImageUrls|merge([evCdnUrl]) %}
  347. {% endfor %}
  348. {% set timeline_modal_svg_icon_json = getLucidIcon('medal')|json_encode %}
  349. {# Données de la timeline complète pour JavaScript #}
  350. <script type="application/json" data-timeline-id="{{ id }}">
  351. {
  352. "events": {{ eventsReindexed|json_encode|raw }},
  353. "eventImageUrls": {{ eventImageUrls|json_encode|raw }},
  354. "svgIcon": {{ timeline_modal_svg_icon_json|raw }},
  355. "content": {
  356. "colorPictogram": "{{ content.colorPictogram|default('#FF5C00') }}",
  357. "backgroundColorPictogram": "{{ content.backgroundColorPictogram|default('#42D7FF') }}",
  358. "colorTitle": "{{ content.colorTitle|default('#6C6C6C') }}",
  359. "fontSizeTitle": "{{ content.fontSizeTitle|default('10') ~ content.fontSizeUnitTitle|default('px') }}",
  360. "fontWeightTitle": "{{ content.fontWeightTitle|default('700') }}",
  361. "colorDesc": "{{ content.colorDesc|default('#1D1D1D') }}",
  362. "fontSizeDesc": "{{ content.fontSizeDesc|default('14') ~ content.fontSizeUnitDesc|default('px') }}",
  363. "fontWeightDesc": "{{ content.fontWeightDesc|default('500') }}"
  364. }
  365. }
  366. </script>
  367. <style>
  368. .timeline-block#{{ id }}
  369. {
  370. --color-pictogram: {{content.colorPictogram|default('#FF5C00')}}
  371. ;
  372. --bg-color-pictogram: {{content.backgroundColorPictogram|default('#42D7FF')}}
  373. ;
  374. --color-title: {{content.colorTitle|default('#6C6C6C')}}
  375. ;
  376. --font-size-title: {{content.fontSizeTitle|default('10') ~ content.fontSizeUnitTitle|default('px')}}
  377. ;
  378. --font-weight-title: {{content.fontWeightTitle|default('700')}}
  379. ;
  380. --color-desc: {{content.colorDesc|default('#1D1D1D')}}
  381. ;
  382. --font-size-desc: {{content.fontSizeDesc|default('14') ~ content.fontSizeUnitDesc|default('px')}}
  383. ;
  384. --font-weight-desc: {{content.fontWeightDesc|default('500')}}
  385. ;
  386. }
  387. </style>