{# templates/blocks/timeline_block.html.twig #}
{% set maxEvents = (content.maxNumberEvents|default(7)) %}
{% set id = "timeline-block-" ~ random() %}
{% set eventsRaw = content.events|default([]) %}
{% set eventsSorted = eventsRaw|sort((a,b) => b.dateEvent <=> a.dateEvent) %}
{% set totalEvents = eventsSorted|length %}
{# 1) On prend uniquement les events visibles #}
{% set visibleEvents = eventsSorted|slice(0, maxEvents) %}
<div
id="{{ id }}" class="timeline-block {{ content.isTextRight is defined and content.isTextRight == true ? 'col-lg-6 col-md-6 col-sm-12' : '' }}">
{# ---------- Frise chronologique ---------- #}
<div class="timeline-events">
{% set lastYear = null %}
{% for ev in visibleEvents %}
{% set year = ev.dateEvent|slice(0,4) %}
{% set side = (loop.index0 % 2 == 0) ? 'right' : 'left' %}
{# Badge année seulement si on a un event visible cette année #}
{% if year != lastYear %}
<div class="timeline-year">
<span class="timeline-year-badge">{{ year }}</span>
</div>
{% set lastYear = year %}
{% endif %}
{% set imgUrl = '' %}
{% if ev.isExternalImageEvent is defined and ev.isExternalImageEvent and ev.externalImageEvent|default('') %}
{% set imgUrl = ev.externalImageEvent %}
{% elseif ev.internalImageEvent is defined and ev.internalImageEvent %}
{% if ev.internalImageEvent.url is defined and ev.internalImageEvent.url %}
{% set imgUrl = sulu_media_url(ev.internalImageEvent, '400x') %}
{% elseif ev.internalImageEvent.id is defined and ev.internalImageEvent.id %}
{% set resolvedEventImage = sulu_resolve_media(ev.internalImageEvent.id, app.request.locale) %}
{% set imgUrl = resolvedEventImage ? sulu_media_url(resolvedEventImage, '400x') : '' %}
{% endif %}
{% endif %}
<div class="timeline-item timeline-item--{{ side }}">
<div class="timeline-item-line">
<span class="timeline-dot" aria-hidden="true"></span>
</div>
<article class="timeline-card">
<div class="timeline-card-body">
<div class="timeline-card-top">
<div class="timeline-pictogram">
{{ getLucidIcon('medal') }}
</div>
<div class="timeline-text">
<div class="timeline-title">{{ ev.titleTextEvent|default('') }}</div>
<div class="timeline-desc">{{ ev.descTextEvent|default('') }}</div>
</div>
{% if imgUrl %}
<figure class="timeline-logo">
<img src="{{ imgUrl }}" alt="{{ ev.alternativeTextImageEvent|default(ev.titleTextEvent|default('')) }}" loading="lazy" decoding="async"/>
</figure>
{% endif %}
</div>
</div>
</article>
</div>
{% endfor %}
</div>
{% if totalEvents > maxEvents %}
<div class="timeline-footer">
<button type="button" class="timeline-toggle">
{{ 'timeline.see_all'|trans }}
</button>
</div>
{% endif %}
<script>
(function () {
// Initialiser la modale si elle n'est pas déjà initialisée
function initTimelineModal() {
const boxTimelineViewer = document.getElementById("box-timeline-viewer");
if (!boxTimelineViewer || window.timelineModalInitialized) {
return;
}
const eventsContainer = boxTimelineViewer.querySelector("#timeline-events-full");
const closeBtn = boxTimelineViewer.querySelector("#close-timeline") || boxTimelineViewer.querySelector(".icon-x");
let timelineData = {};
// Charger les données depuis le script JSON
function loadTimelineData() {
const timelineDataScript = document.querySelector('script[type="application/json"][data-timeline-id="{{ id }}"]');
if (timelineDataScript) {
try {
const dataText = timelineDataScript.textContent.trim();
timelineData = JSON.parse(dataText);
} catch (e) {
{# console.error("Erreur lors du chargement des données de la timeline:", e); #}
}
}
}
// Appliquer les styles CSS depuis les données
function applyStyles() {
if (!timelineData.content || !boxTimelineViewer) {
return;
}
const content = timelineData.content;
// Créer un élément style pour les variables CSS
let styleElement = boxTimelineViewer.querySelector('style[data-timeline-styles]');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.setAttribute('data-timeline-styles', '');
boxTimelineViewer.appendChild(styleElement);
}
const css = `
#box-timeline-viewer.timeline-modal {
--color-pictogram: ${content.colorPictogram || '#FF5C00'};
--bg-color-pictogram: ${content.backgroundColorPictogram || '#42D7FF'};
--color-title: ${content.colorTitle || '#6C6C6C'};
--font-size-title: ${content.fontSizeTitle || '10px'};
--font-weight-title: ${content.fontWeightTitle || '700'};
--color-desc: ${content.colorDesc || '#1D1D1D'};
--font-size-desc: ${content.fontSizeDesc || '14px'};
--font-weight-desc: ${content.fontWeightDesc || '500'};
}
#box-timeline-viewer .timeline-events-full {
--color-pictogram: ${content.colorPictogram || '#FF5C00'};
--bg-color-pictogram: ${content.backgroundColorPictogram || '#42D7FF'};
--color-title: ${content.colorTitle || '#6C6C6C'};
--font-size-title: ${content.fontSizeTitle || '10px'};
--font-weight-title: ${content.fontWeightTitle || '700'};
--color-desc: ${content.colorDesc || '#1D1D1D'};
--font-size-desc: ${content.fontSizeDesc || '14px'};
--font-weight-desc: ${content.fontWeightDesc || '500'};
}
`;
styleElement.textContent = css;
}
// Rendre la frise chronologique complète
function renderTimeline() {
if (!eventsContainer || !timelineData.events) {
return;
}
// S'assurer que events est un tableau (peut être un objet si JSON mal sérialisé)
let events = timelineData.events;
if (!Array.isArray(events)) {
// Convertir l'objet en tableau si nécessaire
events = Object.values(events);
}
if (events.length === 0) {
eventsContainer.innerHTML = "<p>{{ 'timeline.no_event'|trans }}</p>";
return;
}
// Trier les événements par date décroissante
const sortedEvents = [...events].sort((a, b) => {
const dateA = a.dateEvent || "";
const dateB = b.dateEvent || "";
return dateB.localeCompare(dateA);
});
// Générer le HTML de la timeline
let html = "";
let lastYear = null;
sortedEvents.forEach((ev, index) => {
const year = ev.dateEvent ? ev.dateEvent.slice(0, 4) : "";
const side = index % 2 === 0 ? "right" : "left";
// Badge année seulement si on a un event visible cette année
if (year && year !== lastYear) {
html += `<div class="timeline-year"><span class="timeline-year-badge">${year}</span></div>`;
lastYear = year;
}
// Récupérer l'URL de l'image
let imgUrl = "";
if (timelineData.eventImageUrls && timelineData.eventImageUrls[index]) {
imgUrl = timelineData.eventImageUrls[index];
} else if (ev.isExternalImageEvent && ev.externalImageEvent) {
imgUrl = ev.externalImageEvent;
} else if (ev.internalImageEvent && ev.internalImageEvent.url) {
imgUrl = ev.internalImageEvent.url;
}
const title = ev.titleTextEvent || "";
const desc = ev.descTextEvent || "";
const alt = ev.alternativeTextImageEvent || title;
// Échapper les caractères HTML pour éviter les injections XSS
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
html += `
<div class="timeline-item timeline-item--${side}">
<div class="timeline-item-line">
<span class="timeline-dot" aria-hidden="true"></span>
</div>
<article class="timeline-card">
<div class="timeline-card-body">
<div class="timeline-card-top">
<div class="timeline-pictogram">
${timelineData.svgIcon || ""}
</div>
<div class="timeline-text">
<div class="timeline-title">${escapeHtml(title)}</div>
<div class="timeline-desc">${escapeHtml(desc)}</div>
</div>
${imgUrl ? `<figure class="timeline-logo"><img src="${escapeHtml(imgUrl)}" alt="${escapeHtml(alt)}" loading="lazy" decoding="async"/></figure>` : ""}
</div>
</div>
</article>
</div>
`;
});
eventsContainer.innerHTML = html;
// Ajuster la hauteur de la ligne verticale pour qu'elle couvre tous les événements
setTimeout(() => {
const firstItem = eventsContainer.querySelector('.timeline-item');
const lastItem = Array.from(eventsContainer.querySelectorAll('.timeline-item')).pop();
if (firstItem && lastItem) {
const firstRect = firstItem.getBoundingClientRect();
const lastRect = lastItem.getBoundingClientRect();
const containerRect = eventsContainer.getBoundingClientRect();
// Calculer la position du premier point (centre du dot du premier item)
const firstDot = firstItem.querySelector('.timeline-dot');
const lastDot = lastItem.querySelector('.timeline-dot');
if (firstDot && lastDot) {
const firstDotRect = firstDot.getBoundingClientRect();
const lastDotRect = lastDot.getBoundingClientRect();
const lineTop = firstDotRect.top - containerRect.top;
const lineBottom = containerRect.bottom - lastDotRect.bottom;
const lineHeight = containerRect.height - lineTop - lineBottom;
if (lineHeight > 0) {
// Créer ou mettre à jour le style pour la ligne
let styleElement = eventsContainer.querySelector('style[data-timeline-line-height]');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.setAttribute('data-timeline-line-height', '');
eventsContainer.appendChild(styleElement);
}
styleElement.textContent = `
#box-timeline-viewer .timeline-events-full::before {
top: ${lineTop}px !important;
height: ${lineHeight}px !important;
bottom: auto !important;
}
`;
}
}
}
}, 100);
}
// Gérer la fermeture
function closeModal() {
boxTimelineViewer.classList.remove("opened");
boxTimelineViewer.classList.add("closing");
document.body.classList.remove("modale_opened");
setTimeout(() => {
boxTimelineViewer.classList.remove("closing");
// Utiliser la bonne transformation selon la taille d'écran
const isMobile = window.innerWidth <= 768;
boxTimelineViewer.style.transform = isMobile ? "translateY(100vh)" : "translate(-50%, 100vh)";
}, 500);
}
if (closeBtn) {
closeBtn.addEventListener("click", closeModal);
}
document.addEventListener("click", function (e) {
if (!boxTimelineViewer.classList.contains("opened")) {
return;
}
if (boxTimelineViewer.contains(e.target)) {
return;
}
closeModal();
});
// Fermer avec Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && boxTimelineViewer.classList.contains("opened")) {
closeModal();
}
});
// Charger les données au démarrage
loadTimelineData();
// Exposer la fonction d'ouverture globalement
window.openTimelineModal = function (timelineDataParam) {
if (timelineDataParam) {
timelineData = timelineDataParam;
} else {
loadTimelineData();
}
// Appliquer les styles CSS
applyStyles();
// Ajouter la classe timeline-modal pour les styles spécifiques
boxTimelineViewer.classList.add("timeline-modal");
if (boxTimelineViewer.parentElement !== document.body) {
document.body.appendChild(boxTimelineViewer);
}
boxTimelineViewer.classList.add("opened");
boxTimelineViewer.classList.remove("closing");
document.body.classList.add("modale_opened");
// Rendre la timeline
renderTimeline();
};
window.timelineModalInitialized = true;
}
// Initialiser la modale
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTimelineModal);
} else {
initTimelineModal();
}
// Gestion du bouton "Voir tout"
const root = document.currentScript.closest('.timeline-block');
if (!root) return;
const btn = root.querySelector('.timeline-toggle');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
// Récupérer les données de la timeline depuis le script JSON
const timelineDataScript = document.querySelector('script[type="application/json"][data-timeline-id="{{ id }}"]');
let timelineData = {};
if (timelineDataScript) {
try {
timelineData = JSON.parse(timelineDataScript.textContent);
} catch (e) {
{# console.error('Erreur lors du chargement des données de la timeline:', e); #}
}
}
// S'assurer que la modale est initialisée
if (!window.timelineModalInitialized) {
initTimelineModal();
}
// Ouvrir la modale via la fonction globale
if (window.openTimelineModal) {
window.openTimelineModal(timelineData);
} else {
{# console.error('La fonction openTimelineModal n\'est pas disponible'); #}
}
});
})();
</script>
</div>
{% if content.isTextRight is defined and content.isTextRight == true %}
<div class="timeline-text-right {{ content.isTextRight is defined and content.isTextRight == true ? 'col-lg-6 col-md-6 col-sm-12' : '' }}">
{{ content.textToRight|default('')|raw }}
</div>
{% endif %}
{# Modale "Voir tout" pour la frise chronologique #}
{% include 'pages/_includes/details/_timeline-modal.html.twig' %}
{# Réindexer le tableau pour garantir une sérialisation JSON en tableau et non en objet #}
{% set eventsReindexed = [] %}
{% set eventImageUrls = [] %}
{% for ev in eventsSorted %}
{% set evCdnUrl = '' %}
{% if ev.isExternalImageEvent|default(false) and ev.externalImageEvent|default('') %}
{% set evCdnUrl = ev.externalImageEvent %}
{% elseif ev.internalImageEvent is defined and ev.internalImageEvent %}
{% set evCdnUrl = sulu_media_url(ev.internalImageEvent, '400x') %}
{% endif %}
{% set eventsReindexed = eventsReindexed|merge([ev]) %}
{% set eventImageUrls = eventImageUrls|merge([evCdnUrl]) %}
{% endfor %}
{% set timeline_modal_svg_icon_json = getLucidIcon('medal')|json_encode %}
{# Données de la timeline complète pour JavaScript #}
<script type="application/json" data-timeline-id="{{ id }}">
{
"events": {{ eventsReindexed|json_encode|raw }},
"eventImageUrls": {{ eventImageUrls|json_encode|raw }},
"svgIcon": {{ timeline_modal_svg_icon_json|raw }},
"content": {
"colorPictogram": "{{ content.colorPictogram|default('#FF5C00') }}",
"backgroundColorPictogram": "{{ content.backgroundColorPictogram|default('#42D7FF') }}",
"colorTitle": "{{ content.colorTitle|default('#6C6C6C') }}",
"fontSizeTitle": "{{ content.fontSizeTitle|default('10') ~ content.fontSizeUnitTitle|default('px') }}",
"fontWeightTitle": "{{ content.fontWeightTitle|default('700') }}",
"colorDesc": "{{ content.colorDesc|default('#1D1D1D') }}",
"fontSizeDesc": "{{ content.fontSizeDesc|default('14') ~ content.fontSizeUnitDesc|default('px') }}",
"fontWeightDesc": "{{ content.fontWeightDesc|default('500') }}"
}
}
</script>
<style>
.timeline-block#{{ id }}
{
--color-pictogram: {{content.colorPictogram|default('#FF5C00')}}
;
--bg-color-pictogram: {{content.backgroundColorPictogram|default('#42D7FF')}}
;
--color-title: {{content.colorTitle|default('#6C6C6C')}}
;
--font-size-title: {{content.fontSizeTitle|default('10') ~ content.fontSizeUnitTitle|default('px')}}
;
--font-weight-title: {{content.fontWeightTitle|default('700')}}
;
--color-desc: {{content.colorDesc|default('#1D1D1D')}}
;
--font-size-desc: {{content.fontSizeDesc|default('14') ~ content.fontSizeUnitDesc|default('px')}}
;
--font-weight-desc: {{content.fontWeightDesc|default('500')}}
;
}
</style>