website/assets/js/home.js

589 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Home page specific JS: team carousel + modal + portfolio preview + partners + konami
document.addEventListener('DOMContentLoaded', () => {
// ── Portfolio preview — même cartes que la page réalisations ──────────────────
const homePortfolioGrid = document.getElementById('home-portfolio-grid');
if (homePortfolioGrid) {
fetch('assets/data/projects.json')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.then(projects => {
// Garde-fou: vérifier que projects est un tableau
if (!Array.isArray(projects)) {
console.error('[Portfolio] Données invalides: projects n’est pas un tableau', projects);
throw new Error('Invalid projects data format');
}
const first3 = projects.slice(0, 3);
const delayClasses = ['', ' reveal-delay-1', ' reveal-delay-2'];
const categoryLabels = { bot: 'Bot Discord', map: 'Fortnite Map', graphisme: 'Graphisme' };
homePortfolioGrid.innerHTML = first3.map((p, i) => {
const firstFile = Array.isArray(p.files) && p.files.length > 0 ? p.files[0] : null;
const imgSrc = firstFile ? window.escapeHTML((p.images || '') + firstFile) : null;
const safeTitle = window.escapeHTML(p.title);
const safeCategory = window.escapeHTML(categoryLabels[p.category] || p.category);
return `
<div class="portfolio-item reveal${delayClasses[i]}" data-project-id="${window.escapeHTML(p.id)}" role="button" tabindex="0" aria-label="Voir le projet ${safeTitle}">
<div class="portfolio-bg" style="background: ${window.escapeHTML(p.gradient)};">
${imgSrc ? `<img src="${imgSrc}" alt="${safeTitle}" class="portfolio-cover-img" onerror="this.style.display='none'">` : ''}
</div>
<div class="portfolio-content">
<span class="portfolio-tag">${safeCategory}</span>
<h4>${safeTitle}</h4>
</div>
</div>
`;
}).join('');
setTimeout(() => {
homePortfolioGrid.querySelectorAll('.reveal').forEach(el => el.classList.add('active'));
if (window.feather && feather.replace) try { feather.replace(); } catch(e) {}
}, 80);
// Délégation globale sur document — fonctionne même si realisations.js
// se charge après home.js (ordre des scripts defer non garanti)
homePortfolioGrid.addEventListener('click', e => {
const item = e.target.closest('.portfolio-item[data-project-id]');
if (!item) return;
const id = item.getAttribute('data-project-id');
if (typeof window.openProjectModal === 'function') {
window.openProjectModal(id);
} else {
// Retry : realisations.js pas encore initialisé, on attend
const wait = setInterval(() => {
if (typeof window.openProjectModal === 'function') {
clearInterval(wait);
window.openProjectModal(id);
}
}, 50);
// Abandon après 3 secondes
setTimeout(() => clearInterval(wait), 3000);
}
});
})
.catch(err => {
console.error('[Portfolio] Échec du chargement des projets:', err);
// Fallback: afficher un message d'erreur
homePortfolioGrid.innerHTML = '
<div class="alert alert-error" role="alert" style="grid-column: 1 / -1;">
<p>⚠️ Impossible de charger les projets. Veuillez rafraîchir la page.</p>
</div>
';
});
}
// ── Partners — carousel + modal ─────────────────────────────────────────────
const partnersTrack = document.getElementById('partners-track');
const partnerModalOverlay = document.getElementById('partner-modal-overlay');
if (partnersTrack) {
fetch('assets/data/partners.json')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.then(partners => {
// Garde-fou: vérifier que partners est un tableau
if (!Array.isArray(partners)) {
console.error('[Partners] Données invalides: partners n’est pas un tableau', partners);
throw new Error('Invalid partners data format');
}
if (!partners.length) {
const section = document.getElementById('partenaires');
if (section) section.style.display = 'none';
return;
}
// Générer les slides (tripler pour garantir boucle sans trou)
const copies = [...partners, ...partners, ...partners];
partnersTrack.innerHTML = copies.map((p, i) => {
const imgSrc = window.escapeHTML(p.logo || `assets/images/partners/${p.name}.png`);
const safeName = window.escapeHTML(p.displayName);
const safeGradient = window.escapeHTML(p.gradient || '');
const safeInitials = window.escapeHTML(p.displayName.substring(0,2).toUpperCase());
return `
<div class="partner-slide" data-partner-idx="${i % partners.length}" role="button" tabindex="0" aria-label="Voir ${safeName}">
<div class="partner-slide-inner">
<img src="${imgSrc}" alt="${safeName}" class="partner-logo-img"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
<div class="partner-logo-fallback" style="background:${safeGradient};display:none;">
${safeInitials}
</div>
<span class="partner-slide-name">${safeName}</span>
</div>
</div>
`;
}).join('');
// Défilement infini — loopWidth mesuré une seule fois après rendu complet
const track = partnersTrack;
let isPaused = false;
let scrollPos = 0;
let loopWidth = 0;
let animFrame;
const SPEED = 0.6;
function measureLoop() {
// Mesure la largeur exacte d'un bloc (1/3 des slides)
// On utilise offsetWidth de chaque slide réel + gap pour éviter les arrondis de scrollWidth
const slides = Array.from(track.querySelectorAll('.partner-slide'));
const perGroup = partners.length;
if (slides.length < perGroup) return 0;
// Largeur d'un groupe = somme des (offsetWidth + gap) des premiers slides
const style = window.getComputedStyle(track);
const gap = parseFloat(style.gap) || 24;
let w = 0;
for (let i = 0; i < perGroup; i++) {
w += slides[i].offsetWidth + gap;
}
return Math.round(w);
}
function autoScroll() {
if (!isPaused) {
scrollPos += SPEED;
if (loopWidth > 0 && scrollPos >= loopWidth) {
scrollPos -= loopWidth;
}
track.style.transform = `translateX(-${scrollPos}px)`;
}
animFrame = requestAnimationFrame(autoScroll);
}
// Attendre que les slides soient rendues et mesurées
requestAnimationFrame(() => {
requestAnimationFrame(() => {
loopWidth = measureLoop();
animFrame = requestAnimationFrame(autoScroll);
});
});
// Re-mesurer au resize pour rester précis
let resizeTO;
window.addEventListener('resize', () => {
clearTimeout(resizeTO);
resizeTO = setTimeout(() => { loopWidth = measureLoop(); }, 150);
});
track.parentElement.addEventListener('mouseenter', () => { isPaused = true; });
track.parentElement.addEventListener('mouseleave', () => { isPaused = false; });
// Click → modal
track.querySelectorAll('.partner-slide').forEach(slide => {
const open = () => {
const idx = parseInt(slide.dataset.partnerIdx, 10);
openPartnerModal(partners[idx]);
};
slide.addEventListener('click', open);
slide.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
});
function openPartnerModal(p) {
// Garde-fou: vérifier que p est un objet valide
if (!p || typeof p !== 'object') {
console.error('[Partner Modal] Données partenaire invalides:', p);
return;
}
try {
isPaused = true;
const imgSrc = window.escapeHTML(p.logo || `assets/images/partners/${p.name}.png`);
const logoImg = document.getElementById('partner-modal-logo-img');
const logoInner = document.getElementById('partner-modal-logo-fallback');
if (!logoImg || !logoInner) {
console.error('[Partner Modal] Éléments DOM manquants pour la modal');
return;
}
logoImg.src = imgSrc;
logoImg.alt = window.escapeHTML(p.displayName || 'Partenaire');
logoImg.style.display = 'block';
logoInner.style.display = 'none';
logoInner.style.background = '';
logoInner.style.justifyContent = '';
logoInner.textContent = '';
// Si l'image échoue → fallback initiales dans l'octogone
logoImg.onerror = function() {
this.style.display = 'none';
logoInner.style.background = p.gradient;
logoInner.style.display = 'flex';
logoInner.style.alignItems = 'center';
logoInner.style.justifyContent = 'center';
logoInner.style.fontSize = '2rem';
logoInner.style.fontFamily = "'OmegaLarge', sans-serif";
logoInner.style.color = 'white';
logoInner.textContent = p.displayName.substring(0,2).toUpperCase();
};
document.getElementById('partner-modal-name').textContent = p.displayName;
document.getElementById('partner-modal-desc').textContent = p.longDesc;
document.getElementById('partner-modal-since').textContent = p.since;
document.getElementById('partner-modal-services').innerHTML = (p.services || []).map(s => `<span class="team-modal-skill">${window.escapeHTML(s)}</span>`).join('');
document.getElementById('partner-modal-stats').innerHTML = (p.stats || []).map(s => `
<div class="team-modal-stat">
<div class="team-modal-stat-value">${window.escapeHTML(s.value)}</div>
<div class="team-modal-stat-label">${window.escapeHTML(s.label)}</div>
</div>
`).join('');
const links = document.getElementById('partner-modal-links');
// Réseaux sociaux depuis le champ social du partenaire
// Sécurité: fallback sûr sans injection HTML si renderSocialLinks n'existe pas ou est invalide
let socialHtml = '';
try {
if (typeof window.renderSocialLinks === 'function') {
// Vérification supplémentaire: s'assurer que la fonction est celle attendue (définie dans main.js)
if (window.renderSocialLinks.toString().includes('escapeHTML')) {
socialHtml = window.renderSocialLinks(p.social);
} else {
console.warn('[Partner Modal] window.renderSocialLinks semble compromise, utilisation du fallback sécurisé');
}
}
} catch (e) {
console.error('[Partner Modal] Erreur lors du rendu des liens sociaux:', e);
}
links.innerHTML = socialHtml;
if (window.feather) feather.replace();
partnerModalOverlay.classList.add('active');
document.body.style.overflow = 'hidden';
} catch (err) {
console.error('[Partner Modal] Erreur lors de l\'ouverture de la modal:', err);
// Fallback: fermer la modal si elle a été ouverte
if (partnerModalOverlay) {
partnerModalOverlay.classList.remove('active');
document.body.style.overflow = '';
}
}
if (partnerModalOverlay) {
const closeBtn = document.getElementById('partner-modal-close');
closeBtn && closeBtn.addEventListener('click', closePartnerModal);
partnerModalOverlay.addEventListener('click', e => { if (e.target === partnerModalOverlay) closePartnerModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && partnerModalOverlay.classList.contains('active')) closePartnerModal();
});
}
function closePartnerModal() {
partnerModalOverlay.classList.remove('active');
document.body.style.overflow = '';
isPaused = false;
}
})
.catch(err => {
console.error('[Partners] Échec du chargement des partenaires:', err);
const section = document.getElementById('partenaires');
if (section) {
section.innerHTML = '
<div class="alert alert-error" role="alert">
<p>⚠️ Impossible de charger les partenaires. Veuillez réessayer plus tard.</p>
</div>
';
}
});
}
// ── Team section ─────────────────────────────────────────────────────────────
fetch('assets/data/team.json')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.then(members => {
try {
if (!Array.isArray(members)) {
console.error('[Team] Données invalides: members n’est pas un tableau', members);
throw new Error('Invalid team data format');
}
initTeamSection(members);
} catch (err) {
console.error('[Team] Erreur lors de l’initialisation de la section créateurs:', err);
// Fallback: afficher un message d'erreur dans la section si elle existe
const teamSection = document.getElementById('les-createurs') || document.getElementById('team');
if (teamSection) {
teamSection.innerHTML = '
<div class="alert alert-error" role="alert">
<p>⚠️ Impossible de charger les informations de l\'équipe. Veuillez rafraîchir la page.</p>
</div>
';
}
}
})
.catch(err => {
console.error('[Team] Échec du chargement des données de l’équipe:', err);
// Fallback: afficher un message d'erreur dans la section si elle existe
const teamSection = document.getElementById('les-createurs') || document.getElementById('team');
if (teamSection) {
teamSection.innerHTML = '
<div class="alert alert-error" role="alert">
<p>⚠️ Impossible de charger les informations de l\'équipe. Veuillez réessayer plus tard.</p>
</div>
';
}
});
// Konami + easter egg → définis au niveau top-level du script (voir bas de fichier)
// ── initTeamSection ──────────────────────────────────────────────────────────
function initTeamSection(members) {
// Sécurité: les garde-fous empêchent une erreur JS de casser tout le rendu.
console.log('[Team] Initialisation de la section créateurs avec', members.length, 'membres');
if (!Array.isArray(members)) {
console.error('[Team] initTeamSection: members n’est pas un tableau');
return;
}
const carousel = document.getElementById('team-carousel');
const dotsContainer = document.getElementById('carousel-dots');
const prevBtn = document.getElementById('carousel-prev');
const nextBtn = document.getElementById('carousel-next');
if (!carousel) {
console.error('[Team] initTeamSection: #team-carousel introuvable');
return;
}
if (!dotsContainer) {
console.warn('[Team] initTeamSection: #carousel-dots introuvable (dots désactivés)');
}
// escapeHTML est utilisé pour éviter l’injection XSS via innerHTML.
if (typeof window.escapeHTML !== 'function') {
console.error('[Team] initTeamSection: window.escapeHTML introuvable (risque XSS)');
return;
}
carousel.innerHTML = members.map((m, i) => {
const imgSrc = window.escapeHTML(`assets/images/team/${m.id}.png`);
const safeName = window.escapeHTML(m.name);
const safeRole = window.escapeHTML(m.role);
const safeBio = window.escapeHTML((m.bio || '').split('.').shift() + '.');
const safeFallback = window.escapeHTML(m.avatar || '');
return `
<div class="team-card" data-member="${i}" role="button" tabindex="0" aria-label="Voir le profil de ${safeName}">
<div class="team-avatar">
<div class="team-avatar-inner" id="team-avatar-inner-${i}">
<img src="${imgSrc}" alt="${safeName}" class="team-avatar-photo"
onerror="const s=this.src;if(s.endsWith('.png')){this.src=s.replace('.png','.jpg');}else if(s.endsWith('.jpg')){this.src=s.replace('.jpg','.gif');}else{this.style.display='none';document.getElementById('team-avatar-fallback-${i}').style.display='flex';}">
<span class="team-avatar-fallback" id="team-avatar-fallback-${i}" style="display:none;">${safeFallback}</span>
</div>
</div>
<h4>${safeName}</h4>
<p class="team-role">${safeRole}</p>
<p class="team-desc">${safeBio}</p>
</div>
`;
}).join('');
let cards = Array.from(carousel.querySelectorAll('.team-card'));
let currentIndex = 0;
let cardsPerView = 3;
let autoPlayInterval;
function updateCardsPerView() {
if (window.innerWidth <= 768) cardsPerView = 1;
else if (window.innerWidth <= 1024) cardsPerView = 2;
else cardsPerView = 3;
}
function getTotalDots() { return Math.max(1, cards.length - cardsPerView + 1); }
function createDots() {
if (!dotsContainer) return;
dotsContainer.innerHTML = '';
for (let i = 0; i < getTotalDots(); i++) {
const dot = document.createElement('div');
dot.classList.add('carousel-dot');
if (i === 0) dot.classList.add('active');
dot.addEventListener('click', () => goToSlide(i));
dotsContainer.appendChild(dot);
}
}
function updateCarousel() {
cards = Array.from(carousel.querySelectorAll('.team-card'));
if (!cards.length) return;
const gap = 32;
const cardWidth = cards[0].offsetWidth + gap;
carousel.style.transform = `translateX(-${currentIndex * cardWidth}px)`;
if (dotsContainer) Array.from(dotsContainer.querySelectorAll('.carousel-dot')).forEach((d, i) => d.classList.toggle('active', i === currentIndex));
}
function goToSlide(index) {
currentIndex = Math.min(index, Math.max(0, cards.length - cardsPerView));
updateCarousel(); resetAutoPlay();
}
function nextSlide() {
const max = Math.max(0, cards.length - cardsPerView);
currentIndex = currentIndex >= max ? 0 : currentIndex + 1;
updateCarousel();
}
function prevSlide() {
const max = Math.max(0, cards.length - cardsPerView);
currentIndex = currentIndex <= 0 ? max : currentIndex - 1;
updateCarousel();
}
function startAutoPlay() { clearInterval(autoPlayInterval); autoPlayInterval = setInterval(nextSlide, 4000); }
function resetAutoPlay() { clearInterval(autoPlayInterval); startAutoPlay(); }
prevBtn && prevBtn.addEventListener('click', () => { prevSlide(); resetAutoPlay(); });
nextBtn && nextBtn.addEventListener('click', () => { nextSlide(); resetAutoPlay(); });
carousel.addEventListener('mouseenter', () => clearInterval(autoPlayInterval));
carousel.addEventListener('mouseleave', startAutoPlay);
let resizeTimer;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { updateCardsPerView(); currentIndex = 0; createDots(); updateCarousel(); }, 100);
});
ro.observe(carousel.parentElement || document.body);
updateCardsPerView(); createDots(); updateCarousel(); startAutoPlay();
// Modal
const modalOverlay = document.getElementById('team-modal-overlay');
const modalClose = document.getElementById('team-modal-close');
const modalAvatar = document.getElementById('team-modal-avatar');
const modalName = document.getElementById('team-modal-name');
const modalRole = document.getElementById('team-modal-role');
const modalBio = document.getElementById('team-modal-bio');
const modalStats = document.getElementById('team-modal-stats');
const modalSkills = document.getElementById('team-modal-skills');
const modalSocial = document.getElementById('team-modal-social');
function openMemberModal(index) {
const m = members[index];
// Garde-fou: vérifier que m est un objet valide
if (!m || typeof m !== 'object') {
console.error('[Team Modal] Données membre invalides pour l\'index', index, ':', m);
return;
}
try {
// Avatar avec image si dispo
const imgSrc = window.escapeHTML(`assets/images/team/${m.id}.png`);
const safeName = window.escapeHTML(m.name);
const safeFallback = window.escapeHTML(m.avatar || (m.name ? m.name.charAt(0) : '?'));
if (!modalAvatar) {
console.error('[Team Modal] Éléments DOM manquants pour la modal');
return;
}
modalAvatar.innerHTML = `
<img src="${imgSrc}" alt="${safeName}" class="team-modal-avatar-photo"
onerror="const s=this.src;if(s.endsWith('.png')){this.src=s.replace('.png','.jpg');}else if(s.endsWith('.jpg')){this.src=s.replace('.jpg','.gif');}else{this.style.display='none';this.nextElementSibling.style.display='flex';}">
<span class="team-modal-avatar-fallback">${safeFallback}</span>
`;
modalName.textContent = m.name;
modalRole.textContent = m.role;
modalBio.textContent = m.bio;
modalStats.innerHTML = (m.stats || []).map(s => `
<div class="team-modal-stat">
<div class="team-modal-stat-value">${window.escapeHTML(s.value)}</div>
<div class="team-modal-stat-label">${window.escapeHTML(s.label)}</div>
</div>
`).join('');
modalSkills.innerHTML = (m.skills || []).map(s => `<span class="team-modal-skill">${window.escapeHTML(s)}</span>`).join('');
// Réseaux sociaux depuis le JSON du membre (+ fallback discord global)
// Sécurité : ne jamais injecter de HTML non maîtrisé si le renderer n'existe pas ou est compromis.
let socialHtml = '';
try {
if (typeof window.renderSocialLinks === 'function') {
// Vérification supplémentaire: s'assurer que la fonction utilise escapeHTML
if (window.renderSocialLinks.toString().includes('escapeHTML')) {
socialHtml = window.renderSocialLinks(m.social, (window.OK_SOCIAL || {}).discord);
} else {
console.warn('[Team Modal] window.renderSocialLinks semble compromise, utilisation du fallback sécurisé');
}
} else {
console.warn('[Team Modal] window.renderSocialLinks non définie');
}
} catch (e) {
console.error('[Team Modal] Erreur lors du rendu des liens sociaux:', e);
}
modalSocial.innerHTML = socialHtml;
modalOverlay.classList.add('active');
document.body.style.overflow = 'hidden';
} catch (err) {
console.error('[Team Modal] Erreur lors de l\'ouverture de la modal:', err);
// Fallback: fermer la modal si elle a été ouverte
if (modalOverlay) {
modalOverlay.classList.remove('active');
document.body.style.overflow = '';
}
}
function closeMemberModal() {
modalOverlay.classList.remove('active');
document.body.style.overflow = '';
}
carousel.querySelectorAll('.team-card').forEach(card => {
const openCard = () => { const idx = parseInt(card.dataset.member, 10); openMemberModal(idx); };
card.addEventListener('click', openCard);
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCard(); } });
});
modalClose && modalClose.addEventListener('click', closeMemberModal);
modalOverlay && modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeMemberModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modalOverlay && modalOverlay.classList.contains('active')) closeMemberModal(); });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// EASTER EGG — top-level (hors DOMContentLoaded pour garantir l'attachement)
// ═══════════════════════════════════════════════════════════════════════════
(function () {
var KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
var progress = 0;
// PC : Konami Code — capture:true pour passer avant tout autre listener
window.addEventListener('keydown', function(e) {
var key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (key === KONAMI[progress]) {
progress++;
if (progress === KONAMI.length) {
progress = 0;
triggerEasterEgg();
}
} else {
progress = (key === KONAMI[0]) ? 1 : 0;
}
}, true); // capture phase — reçu avant tout stopPropagation
// Mobile : 10 taps sur le logo navbar OU logo footer
var tapCount = 0;
var tapTimer = null;
document.addEventListener('click', function(e) {
if (!e.target.closest('.logo') && !e.target.closest('.logo-mini') && !e.target.closest('.omega-symbol')) return;
if (!('ontouchstart' in window) && navigator.maxTouchPoints === 0) return;
tapCount++;
clearTimeout(tapTimer);
tapTimer = setTimeout(function() { tapCount = 0; }, 600);
if (tapCount >= 10) { tapCount = 0; triggerEasterEgg(); }
});
function triggerEasterEgg() {
if (document.getElementById('easter-egg-overlay')) return;
var egg = document.createElement('div');
egg.id = 'easter-egg-overlay';
egg.setAttribute('role', 'dialog');
egg.setAttribute('aria-modal', 'true');
egg.innerHTML = '<div class="easter-egg-box"><div class="easter-egg-icon">🎮</div><h2>GG\u00a0! Tu as trouvé l\u2019easter egg\u00a0!</h2><p>La vraie question c\u2019est\u00a0: combien de temps tu as mis\u00a0?</p><button id="easter-egg-close" class="btn btn-primary">Fermer</button></div>';
document.body.appendChild(egg);
var closeBtn = document.getElementById('easter-egg-close');
closeBtn && closeBtn.focus();
var remove = function() { egg.remove(); };
closeBtn && closeBtn.addEventListener('click', remove);
egg.addEventListener('click', function(e) { if (e.target === egg) remove(); });
window.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { remove(); window.removeEventListener('keydown', esc); }
}, { once: true });
}
})();