update last version of the website
This commit is contained in:
commit
abc54697fd
131 changed files with 11253 additions and 0 deletions
13
assets/js/feather.min.js
vendored
Normal file
13
assets/js/feather.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
589
assets/js/home.js
Normal file
589
assets/js/home.js
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
// 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 });
|
||||
}
|
||||
})();
|
||||
514
assets/js/main.js
Normal file
514
assets/js/main.js
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
// OmégaKube - shared scripts
|
||||
|
||||
/**
|
||||
* Sécurise une chaîne contre les attaques XSS avant insertion dans le DOM (innerHTML)
|
||||
*/
|
||||
window.escapeHTML = function(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
if (typeof str !== 'string') str = String(str);
|
||||
return str.replace(/[&<>'"]/g, function(tag) {
|
||||
const charsToReplace = { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' };
|
||||
return charsToReplace[tag] || tag;
|
||||
});
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONFIGURATION GLOBALE — liens réseaux sociaux
|
||||
// Modifier ici pour mettre à jour partout sur le site
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
window.OK_SOCIAL = {
|
||||
discord: 'https://discord.gg/FwvNkvu5dy',
|
||||
fortnite: 'https://www.fortnite.com/@omega_kube_team',
|
||||
twitter: 'https://x.com/Omegakube',
|
||||
instagram: 'https://www.instagram.com/omegakube/',
|
||||
youtube: 'https://www.youtube.com/@OmegaKube',
|
||||
tiktok: 'https://www.tiktok.com/@omegakube',
|
||||
facebook: '#',
|
||||
bluesky: '#',
|
||||
epicgames: 'https://www.fortnite.com/@omega_kube_team',
|
||||
email: 'contact@omegakube.fr'
|
||||
};
|
||||
|
||||
// SVGs des réseaux — utilisé partout dans les modals
|
||||
const SVG_ICONS = {
|
||||
discord: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>',
|
||||
fortnite: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M2.234 0v24h6.522v-8.035h5.9v-5.64h-5.9V5.636h7.964V0zm11.13 10.329v5.636h5.9v2.382h-5.9V24H21.766V10.329z"/></svg>',
|
||||
epicgames: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>',
|
||||
minecraft: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M3.261 0A3.261 3.261 0 0 0 0 3.261V20.74A3.261 3.261 0 0 0 3.261 24H20.74A3.261 3.261 0 0 0 24 20.739V3.261A3.261 3.261 0 0 0 20.739 0zm4.103 3.836h3.27v3.268h3.268V3.836h3.27v3.268h-3.27v3.27h3.27v3.267h-3.27v3.27h-3.268v-3.27H7.364v-3.268h3.268V7.104H7.364z"/></svg>',
|
||||
gmod: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2c5.514 0 10 4.486 10 10s-4.486 10-10 10S2 17.514 2 12 6.486 2 12 2zm1 4h-2v6H7v2h4v4h2v-4h4v-2h-4z"/></svg>',
|
||||
hytale: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M1.117 0v24h5.412v-9.386h10.942V24h5.412V0h-5.412v9.386H6.529V0z"/></svg>',
|
||||
twitter: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.259 5.631 5.905-5.631zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
|
||||
instagram: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>',
|
||||
youtube: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
|
||||
tiktok: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1V9.01a6.32 6.32 0 0 0-.79-.05 6.34 6.34 0 0 0-6.34 6.34 6.34 6.34 0 0 0 6.34 6.34 6.34 6.34 0 0 0 6.33-6.34V8.69a8.17 8.17 0 0 0 4.77 1.52V6.75a4.85 4.85 0 0 1-1-.06z"/></svg>',
|
||||
github: '<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>',
|
||||
website: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><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>',
|
||||
};
|
||||
|
||||
/**
|
||||
* Génère le HTML des liens sociaux pour les modals
|
||||
* @param {Object} social - objet {discord, twitter, instagram, ...}
|
||||
* @param {string} [discordFallback] - lien discord global si pas de lien perso
|
||||
* @returns {string} HTML des icônes sociales
|
||||
*/
|
||||
window.renderSocialLinks = function(social, discordFallback) {
|
||||
const S = social || {};
|
||||
const entries = [
|
||||
{ key: 'discord', url: S.discord || discordFallback },
|
||||
{ key: 'fortnite', url: S.fortnite },
|
||||
{ key: 'twitter', url: S.twitter },
|
||||
{ key: 'instagram', url: S.instagram },
|
||||
{ key: 'youtube', url: S.youtube },
|
||||
{ key: 'tiktok', url: S.tiktok },
|
||||
{ key: 'epicgames', url: S.epicgames },
|
||||
{ key: 'github', url: S.github },
|
||||
{ key: 'website', url: S.website },
|
||||
];
|
||||
return entries
|
||||
.filter(e => e.url && e.url !== '#' && e.url !== '' && (e.url.startsWith('https://') || e.url.startsWith('mailto:')))
|
||||
.map(e => `<a href="${window.escapeHTML(e.url)}" target="_blank" rel="noopener noreferrer" aria-label="${window.escapeHTML(e.key)}">${SVG_ICONS[e.key] || ''}</a>`)
|
||||
.join('');
|
||||
};
|
||||
|
||||
// Feather icons loader (centralized)
|
||||
// loads local feather-icons when needed and exposes window.ensureFeather()
|
||||
const FEATHER_URL = window.location.pathname.includes('/pages/') ? '../assets/js/feather.min.js' : 'assets/js/feather.min.js';
|
||||
function ensureFeather() {
|
||||
if (window.__featherLoading) return window.__featherLoading;
|
||||
if (window.feather && window.feather.replace) return Promise.resolve(window.feather);
|
||||
window.__featherLoading = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = FEATHER_URL;
|
||||
s.async = true;
|
||||
s.onload = () => resolve(window.feather);
|
||||
s.onerror = () => reject(new Error('Failed to load feather-icons'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return window.__featherLoading;
|
||||
}
|
||||
window.ensureFeather = ensureFeather;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// ensure feather icons are available and replace icons in document
|
||||
try { await ensureFeather(); if (window.feather && feather.replace) try { feather.replace(); } catch(e) {} } catch (e) {}
|
||||
|
||||
// Load HTML includes (components header/footer) and fix relative paths when necessary
|
||||
async function loadIncludes() {
|
||||
const includeSelector = '[data-include]';
|
||||
const nodes = Array.from(document.querySelectorAll(includeSelector));
|
||||
if (!nodes.length) return;
|
||||
await Promise.all(nodes.map(async el => {
|
||||
const url = el.getAttribute('data-include');
|
||||
try {
|
||||
// Sécurité: on résout l'URL relative à partir du document courant,
|
||||
// puis on autorise uniquement les fichiers du même origin.
|
||||
// Objectif: éviter les cas où un pattern trop strict bloque certains includes relatifs (ex: "../components/..."),
|
||||
// tout en refusant les contenus externes.
|
||||
const resolvedUrl = new URL(url, document.baseURI);
|
||||
const origin = new URL(window.location.href).origin;
|
||||
if (resolvedUrl.origin !== origin) {
|
||||
console.error('Blocked potentially unsafe include origin:', resolvedUrl.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// On autorise uniquement HTTP(s) et on évite les schémas bizarres.
|
||||
if (!/^https?$/i.test(resolvedUrl.protocol.replace(':',''))) {
|
||||
console.error('Blocked include with unsupported protocol:', resolvedUrl.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(resolvedUrl.toString());
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
|
||||
const text = await res.text();
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = text;
|
||||
const fragment = template.content;
|
||||
// adjust relative paths for assets and page links when included from /pages/
|
||||
fragment.querySelectorAll('[href], [src]').forEach(node => {
|
||||
['href','src'].forEach(attr => {
|
||||
if (!node.hasAttribute(attr)) return;
|
||||
const original = node.getAttribute(attr);
|
||||
|
||||
// leave absolute URLs and anchors untouched
|
||||
if (/^(https?:|\/|#)/.test(original)) return;
|
||||
|
||||
// compute useful bases
|
||||
const includeUrl = new URL(url, document.baseURI);
|
||||
const includeDir = includeUrl.pathname.substring(0, includeUrl.pathname.lastIndexOf('/') + 1);
|
||||
const docUrl = new URL(document.location.href);
|
||||
const docDir = docUrl.pathname.substring(0, docUrl.pathname.lastIndexOf('/') + 1);
|
||||
|
||||
// find common prefix between include location and current document to infer site root
|
||||
const includeParts = includeDir.split('/');
|
||||
const docParts = docDir.split('/');
|
||||
let i = 0;
|
||||
while (i < includeParts.length && i < docParts.length && includeParts[i] === docParts[i]) i++;
|
||||
const baseRoot = includeParts.slice(0, i).join('/') || '/';
|
||||
|
||||
let newVal;
|
||||
if (original.startsWith('./') || original.startsWith('../')) {
|
||||
// explicit relative paths should resolve relative to the include file
|
||||
const resolved = new URL(original, includeUrl);
|
||||
newVal = resolved.pathname + resolved.search + resolved.hash;
|
||||
} else {
|
||||
// treat plain paths (e.g. "assets/..." or "pages/...") as site-root relative
|
||||
newVal = (baseRoot.endsWith('/') ? baseRoot : baseRoot + '/') + original;
|
||||
}
|
||||
|
||||
node.setAttribute(attr, newVal);
|
||||
});
|
||||
});
|
||||
// Eviter de remplacer par un DocumentFragment (comportement variable selon navigateurs)
|
||||
// On remplace plutôt par les nœuds enfants réels.
|
||||
el.replaceWith(...Array.from(fragment.childNodes));
|
||||
} catch (err) {
|
||||
console.error('Failed to load include', url, err);
|
||||
}
|
||||
}));
|
||||
if (typeof ensureFeather === 'function') {
|
||||
ensureFeather().then(() => { try { if (window.feather && feather.replace) feather.replace(); } catch (e) {} }).catch(()=>{});
|
||||
} else if (window.feather && feather.replace) {
|
||||
try { feather.replace(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// wait for includes to be loaded so subsequent initialization can bind to elements inside them
|
||||
await loadIncludes();
|
||||
|
||||
// Hydrate les liens sociaux (data-social) avec les URLs de OK_SOCIAL
|
||||
(function hydrateSocialLinks() {
|
||||
const S = window.OK_SOCIAL || {};
|
||||
const map = {
|
||||
'discord': S.discord,
|
||||
'twitter': S.twitter,
|
||||
'instagram': S.instagram,
|
||||
'youtube': S.youtube,
|
||||
'tiktok': S.tiktok,
|
||||
'facebook': S.facebook,
|
||||
'bluesky': S.bluesky,
|
||||
'email-link': S.email ? 'mailto:' + S.email : '#',
|
||||
'epicgames': S.epicgames,
|
||||
};
|
||||
document.querySelectorAll('[data-social]').forEach(el => {
|
||||
const key = el.getAttribute('data-social');
|
||||
if (map[key] && map[key] !== '#') {
|
||||
el.setAttribute('href', map[key]);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Déclenche les reveals maintenant que tous les includes sont dans le DOM
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.reveal:not(.active)').forEach(el => {
|
||||
if (el.getBoundingClientRect().top < window.innerHeight - 50) {
|
||||
el.classList.add('active');
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const html = document.documentElement;
|
||||
|
||||
function getPreferredTheme() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (['light', 'dark'].includes(savedTheme)) return savedTheme;
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
// Apply preferred theme
|
||||
try {
|
||||
html.setAttribute('data-theme', getPreferredTheme());
|
||||
} catch (e) {}
|
||||
|
||||
// Theme toggle (robust)
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
// sync aria state
|
||||
themeToggle.setAttribute('aria-pressed', html.getAttribute('data-theme') === 'dark' ? 'false' : 'true');
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
themeToggle.setAttribute('aria-pressed', newTheme === 'dark' ? 'false' : 'true');
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar scroll effect
|
||||
const navbar = document.getElementById('navbar');
|
||||
function updateNavbarOnScroll() {
|
||||
if (!navbar) return;
|
||||
navbar.classList.toggle('scrolled', window.scrollY > 50);
|
||||
}
|
||||
window.addEventListener('scroll', updateNavbarOnScroll);
|
||||
window.addEventListener('load', updateNavbarOnScroll);
|
||||
|
||||
// Mobile menu
|
||||
const mobileToggle = document.getElementById('mobile-toggle');
|
||||
const navLinks = document.getElementById('nav-links');
|
||||
|
||||
// mobile menu initialization (debug logs removed)
|
||||
|
||||
// create a toggle function that is safe even if elements are added later
|
||||
const toggleMenu = () => {
|
||||
const mt = document.getElementById('mobile-toggle');
|
||||
const nl = document.getElementById('nav-links');
|
||||
// console.log('[DEBUG] toggleMenu called', { mt: !!mt, nl: !!nl, mtClass: mt ? mt.className : null, nlClass: nl ? nl.className : null });
|
||||
if (!mt || !nl) {
|
||||
// elements missing: abort toggle
|
||||
// console.log('[DEBUG] toggleMenu aborted: missing elements', { mt, nl });
|
||||
return;
|
||||
}
|
||||
mt.classList.toggle('active');
|
||||
nl.classList.toggle('active');
|
||||
// console.log('[DEBUG] toggleMenu toggled', { mtClass: mt.className, nlClass: nl.className });
|
||||
const expanded = nl.classList.contains('active') ? 'true' : 'false';
|
||||
mt.setAttribute('aria-expanded', expanded);
|
||||
};
|
||||
|
||||
// |||||||||||||| ça fait buguer le menu mobile ||||||||||||||
|
||||
// direct binding if elements exist
|
||||
// if (mobileToggle && navLinks) {
|
||||
// if (!mobileToggle.hasAttribute('tabindex')) mobileToggle.setAttribute('tabindex', '0');
|
||||
// mobileToggle.setAttribute('aria-expanded', navLinks.classList.contains('active') ? 'true' : 'false');
|
||||
|
||||
// mobileToggle.addEventListener('click', (e) => {
|
||||
// console.log('[DEBUG] mobileToggle click handler fired', e.target, e);
|
||||
// e.preventDefault();
|
||||
// toggleMenu();
|
||||
// });
|
||||
|
||||
// mobileToggle.addEventListener('keydown', (e) => {
|
||||
// if (e.key === 'Enter' || e.key === ' ') {
|
||||
// console.log('[DEBUG] mobileToggle keydown:', e.key);
|
||||
// e.preventDefault();
|
||||
// toggleMenu();
|
||||
// }
|
||||
// });
|
||||
|
||||
// navLinks.querySelectorAll('a').forEach(link => {
|
||||
// link.addEventListener('click', () => {
|
||||
// console.log('[DEBUG] nav link clicked in mobile menu', link.getAttribute('href'));
|
||||
// // collapse on link click
|
||||
// const mt = document.getElementById('mobile-toggle');
|
||||
// const nl = document.getElementById('nav-links');
|
||||
// if (mt && nl) {
|
||||
// mt.classList.remove('active');
|
||||
// nl.classList.remove('active');
|
||||
// mt.setAttribute('aria-expanded', 'false');
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// Global delegation: catch clicks anywhere on page for '#mobile-toggle' (works even if element is replaced)
|
||||
document.addEventListener('click', (e) => {
|
||||
const mt = e.target.closest ? e.target.closest('#mobile-toggle') : null;
|
||||
if (mt) {
|
||||
// console.log('[DEBUG] delegated click caught for #mobile-toggle', e.target);
|
||||
e.preventDefault();
|
||||
toggleMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// expose helper for console debugging
|
||||
window.toggleMobileMenu = toggleMenu;
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
const href = this.getAttribute('href');
|
||||
if (!href || href === '#') return;
|
||||
const target = document.querySelector(href);
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Highlight active nav link based on current URL
|
||||
function normalizePath(path) {
|
||||
if (!path) return '/';
|
||||
// treat both '/' and '/index.html' as root
|
||||
if (path.endsWith('index.html')) {
|
||||
const p = path.slice(0, -'index.html'.length);
|
||||
return p === '' ? '/' : p;
|
||||
}
|
||||
return path.endsWith('/') ? path : path;
|
||||
}
|
||||
|
||||
function setActiveNavLink() {
|
||||
const links = document.querySelectorAll('#nav-links a[href]');
|
||||
if (!links.length) return;
|
||||
const currentPath = normalizePath(window.location.pathname);
|
||||
const currentHash = window.location.hash || '';
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = -1;
|
||||
|
||||
links.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
let linkUrl;
|
||||
try {
|
||||
linkUrl = new URL(href, document.location.href);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const linkPath = normalizePath(linkUrl.pathname);
|
||||
const linkHash = linkUrl.hash || '';
|
||||
|
||||
let score = 0;
|
||||
if (linkPath === currentPath) score += 2;
|
||||
if (linkHash && linkHash === currentHash) score += 4;
|
||||
// Prefer exact hash matches, then exact path matches
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = link;
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: if on root (/) and there is an index link, pick it
|
||||
if (!bestMatch) {
|
||||
links.forEach(link => {
|
||||
if (!bestMatch && /index\.html$|\/$/.test(link.getAttribute('href') || '')) {
|
||||
bestMatch = link;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
links.forEach(l => { l.classList.remove('active'); l.removeAttribute('aria-current'); });
|
||||
if (bestMatch) { bestMatch.classList.add('active'); bestMatch.setAttribute('aria-current', 'page'); }
|
||||
}
|
||||
|
||||
// initialize and listen for navigation/hash changes
|
||||
setActiveNavLink();
|
||||
window.addEventListener('hashchange', setActiveNavLink);
|
||||
window.addEventListener('popstate', setActiveNavLink);
|
||||
|
||||
// Home page: observe sections so #apropos and #contact become active when visible,
|
||||
// and revert to "Accueil" when neither section is in view.
|
||||
let homeSectionObserver = null;
|
||||
let homeVisibility = {};
|
||||
|
||||
function initHomeSectionObserver() {
|
||||
// clean existing observer
|
||||
if (homeSectionObserver) {
|
||||
homeSectionObserver.disconnect();
|
||||
homeSectionObserver = null;
|
||||
homeVisibility = {};
|
||||
}
|
||||
|
||||
// Instead of requiring a strict '/' path, detect whether the current document
|
||||
// actually contains any sections referenced by nav links (e.g. #apropos, #contact).
|
||||
const links = Array.from(document.querySelectorAll('#nav-links a[href]'));
|
||||
const sectionLinks = links.filter(link => {
|
||||
try {
|
||||
const u = new URL(link.getAttribute('href'), document.location.href);
|
||||
if (!u.hash) return false;
|
||||
return !!document.querySelector(u.hash);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!sectionLinks.length) return;
|
||||
|
||||
const sections = sectionLinks.map(link => {
|
||||
const hash = new URL(link.getAttribute('href'), document.location.href).hash;
|
||||
const el = document.querySelector(hash);
|
||||
return el ? { id: el.id, el, link } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
if (!sections.length) return;
|
||||
|
||||
// setup observer
|
||||
homeSectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
homeVisibility[entry.target.id] = entry.intersectionRatio || 0;
|
||||
});
|
||||
|
||||
// pick the section with highest intersection ratio
|
||||
let bestId = null; let bestRatio = 0;
|
||||
Object.entries(homeVisibility).forEach(([id, ratio]) => {
|
||||
if (ratio > bestRatio) { bestRatio = ratio; bestId = id; }
|
||||
});
|
||||
|
||||
const navLinks = Array.from(document.querySelectorAll('#nav-links a[href]'));
|
||||
// if a section is sufficiently visible, activate its link
|
||||
if (bestId && bestRatio > 0.15) {
|
||||
const targetLink = navLinks.find(l => {
|
||||
try {
|
||||
return new URL(l.getAttribute('href'), document.location.href).hash === '#' + bestId;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
if (targetLink) {
|
||||
navLinks.forEach(l => { l.classList.remove('active'); l.removeAttribute('aria-current'); });
|
||||
targetLink.classList.add('active');
|
||||
targetLink.setAttribute('aria-current', 'page');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: set Accueil active when no section is visible
|
||||
const indexLink = Array.from(navLinks).find(l => /index\.html$|\/$/.test(l.getAttribute('href') || ''));
|
||||
navLinks.forEach(l => { l.classList.remove('active'); l.removeAttribute('aria-current'); });
|
||||
if (indexLink) { indexLink.classList.add('active'); indexLink.setAttribute('aria-current', 'page'); }
|
||||
|
||||
}, { root: null, threshold: [0, 0.15, 0.3, 0.6] });
|
||||
|
||||
sections.forEach(s => {
|
||||
homeVisibility[s.id] = 0;
|
||||
homeSectionObserver.observe(s.el);
|
||||
});
|
||||
}
|
||||
|
||||
// initialize observer on load and update when navigation changes
|
||||
initHomeSectionObserver();
|
||||
window.addEventListener('resize', () => { if (homeSectionObserver) initHomeSectionObserver(); });
|
||||
// re-init observer when URL path changes (i.e., leave/enter index)
|
||||
const originalSetActive = setActiveNavLink;
|
||||
function wrappedSetActive() {
|
||||
// if leaving index, disconnect observer
|
||||
const path = normalizePath(window.location.pathname);
|
||||
if (path !== '/') {
|
||||
if (homeSectionObserver) { homeSectionObserver.disconnect(); homeSectionObserver = null; homeVisibility = {}; }
|
||||
originalSetActive();
|
||||
return;
|
||||
}
|
||||
// on index, ensure observer is active and let it control the highlight where possible
|
||||
initHomeSectionObserver();
|
||||
originalSetActive();
|
||||
}
|
||||
|
||||
// replace handlers so navigation updates use our wrapped variant
|
||||
window.removeEventListener('hashchange', setActiveNavLink);
|
||||
window.removeEventListener('popstate', setActiveNavLink);
|
||||
window.addEventListener('hashchange', wrappedSetActive);
|
||||
window.addEventListener('popstate', wrappedSetActive);
|
||||
|
||||
// Reveal on scroll — re-query après chargement des includes
|
||||
function revealOnScroll() {
|
||||
const els = document.querySelectorAll('.reveal:not(.active)');
|
||||
els.forEach(el => {
|
||||
if (el.getBoundingClientRect().top < window.innerHeight - 50) {
|
||||
el.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
// Déclenche immédiatement après includes + au scroll + au load
|
||||
revealOnScroll();
|
||||
window.addEventListener('scroll', revealOnScroll, { passive: true });
|
||||
window.addEventListener('load', revealOnScroll);
|
||||
|
||||
// Contact form (if exists) c'est en double
|
||||
// const contactForm = document.getElementById('contact-form');
|
||||
// if (contactForm) {
|
||||
// contactForm.addEventListener('submit', function (e) {
|
||||
// e.preventDefault();
|
||||
// alert('Merci pour votre message ! Nous vous répondrons très bientôt.');
|
||||
// this.reset();
|
||||
// });
|
||||
// }
|
||||
});
|
||||
209
assets/js/pages/404.js
Normal file
209
assets/js/pages/404.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
feather.replace();
|
||||
|
||||
// Particles
|
||||
const particlesContainer = document.getElementById('particles');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.classList.add('particle');
|
||||
particle.style.left = Math.random() * 100 + '%';
|
||||
particle.style.animationDelay = Math.random() * 10 + 's';
|
||||
particle.style.animationDuration = (Math.random() * 5 + 8) + 's';
|
||||
particle.style.width = (Math.random() * 4 + 2) + 'px';
|
||||
particle.style.height = particle.style.width;
|
||||
particlesContainer.appendChild(particle);
|
||||
}
|
||||
|
||||
// Easter Egg: Konami Code (desktop) ou 10 taps (mobile)
|
||||
const konamiCode = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
|
||||
let konamiIndex = 0;
|
||||
let tapCount = 0;
|
||||
let tapTimer = null;
|
||||
const REQUIRED_TAPS = 10;
|
||||
const errorCodeEl = document.querySelector('.error-code');
|
||||
|
||||
// Desktop: Konami Code
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === konamiCode[konamiIndex]) {
|
||||
konamiIndex++;
|
||||
if (konamiIndex === konamiCode.length) {
|
||||
startPortalTransition();
|
||||
konamiIndex = 0;
|
||||
}
|
||||
} else {
|
||||
konamiIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: 10 taps
|
||||
function handleTap(e) {
|
||||
if (e.type === 'touchend') e.preventDefault();
|
||||
tapCount++;
|
||||
|
||||
errorCodeEl.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => errorCodeEl.style.transform = '', 100);
|
||||
|
||||
if (tapCount >= 3 && tapCount < REQUIRED_TAPS) {
|
||||
showTapCounter(tapCount, REQUIRED_TAPS);
|
||||
}
|
||||
|
||||
if (tapTimer) clearTimeout(tapTimer);
|
||||
tapTimer = setTimeout(() => { tapCount = 0; hideTapCounter(); }, 500);
|
||||
|
||||
if (tapCount >= REQUIRED_TAPS) {
|
||||
tapCount = 0;
|
||||
hideTapCounter();
|
||||
startPortalTransition();
|
||||
}
|
||||
}
|
||||
|
||||
function showTapCounter(current, total) {
|
||||
let counter = document.getElementById('tap-counter');
|
||||
if (!counter) {
|
||||
counter = document.createElement('div');
|
||||
counter.id = 'tap-counter';
|
||||
counter.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(139,58,98,0.9);color:white;padding:0.5rem 1rem;border-radius:20px;font-size:0.85rem;font-weight:600;z-index:1000;';
|
||||
document.body.appendChild(counter);
|
||||
}
|
||||
counter.textContent = current + ' / ' + total + ' 🔓';
|
||||
}
|
||||
|
||||
function hideTapCounter() {
|
||||
const counter = document.getElementById('tap-counter');
|
||||
if (counter) counter.remove();
|
||||
}
|
||||
|
||||
errorCodeEl.addEventListener('click', handleTap);
|
||||
errorCodeEl.addEventListener('touchend', handleTap);
|
||||
|
||||
// Portal Transition
|
||||
function startPortalTransition() {
|
||||
const transition = document.createElement('div');
|
||||
transition.id = 'portal-transition';
|
||||
transition.innerHTML = '<div class="warp-container"><div class="warp-tunnel"></div><div class="warp-stars"></div><div class="warp-center"><div class="warp-ring"></div><div class="warp-ring"></div><div class="warp-ring"></div></div><div class="warp-flash"></div></div><div class="warp-text">ENTRÉE DANS LA MATRICE</div>';
|
||||
document.body.appendChild(transition);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'transition-styles';
|
||||
style.textContent = '#portal-transition{position:fixed;inset:0;z-index:10000;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden}.warp-container{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;perspective:500px}.warp-tunnel{position:absolute;width:200vmax;height:200vmax;background:repeating-conic-gradient(from 0deg,transparent 0deg 10deg,rgba(139,58,98,0.1) 10deg 20deg);animation:tunnelSpin 2s linear infinite,tunnelZoom 3s ease-in forwards}@keyframes tunnelSpin{to{transform:rotate(360deg)}}@keyframes tunnelZoom{0%{transform:rotate(0)scale(1);opacity:0}20%{opacity:1}100%{transform:rotate(720deg)scale(20);opacity:0}}.warp-stars{position:absolute;inset:0;background-image:radial-gradient(2px 2px at 20px 30px,#fff,transparent),radial-gradient(2px 2px at 40px 70px,rgba(255,255,255,0.8),transparent),radial-gradient(1px 1px at 90px 40px,#fff,transparent);background-size:350px 200px;animation:starsWarp 3s ease-in forwards}@keyframes starsWarp{0%{transform:scale(1);opacity:0}30%{opacity:1}100%{transform:scale(50);opacity:0}}.warp-center{position:relative;width:150px;height:150px;animation:centerPulse 0.5s ease-in-out infinite,centerGrow 2.5s ease-in forwards}@keyframes centerPulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.5)}}@keyframes centerGrow{0%{transform:scale(0);opacity:0}20%{transform:scale(1);opacity:1}80%{transform:scale(1);opacity:1}100%{transform:scale(100);opacity:1}}.warp-ring{position:absolute;inset:0;border:4px solid #8b3a62;border-radius:50%;animation:ringPulse 1s ease-in-out infinite;box-shadow:0 0 30px #8b3a62,inset 0 0 30px #8b3a62}.warp-ring:nth-child(2){inset:20px;border-color:#5e2a8b;animation-delay:0.2s;box-shadow:0 0 30px #5e2a8b,inset 0 0 30px #5e2a8b}.warp-ring:nth-child(3){inset:40px;border-color:#d4a5c9;animation-delay:0.4s;box-shadow:0 0 30px #d4a5c9,inset 0 0 30px #d4a5c9}@keyframes ringPulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.1);opacity:0.8}}.warp-flash{position:absolute;inset:0;background:#fff;opacity:0;animation:flashBang 0.5s ease-out 2.5s forwards}@keyframes flashBang{0%{opacity:0}50%{opacity:1}100%{opacity:0}}.warp-text{position:absolute;font-size:1.5rem;font-weight:700;color:#fff;letter-spacing:0.3rem;z-index:10;opacity:0;animation:textAppear 0.5s ease 0.5s forwards,textFade 0.5s ease 2s forwards}@keyframes textAppear{to{opacity:1}}@keyframes textFade{to{opacity:0;transform:scale(2)}}';
|
||||
document.head.appendChild(style);
|
||||
|
||||
setTimeout(() => {
|
||||
transition.remove();
|
||||
style.remove();
|
||||
launchGameOfLife();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Game of Life
|
||||
function launchGameOfLife() {
|
||||
const gameContainer = document.createElement('div');
|
||||
gameContainer.id = 'game-of-life';
|
||||
gameContainer.innerHTML = '<canvas id="gol-canvas"></canvas><div class="gol-ui"><div class="gol-header"><div class="gol-header-text"><h1>🧬 Dimension Secrète</h1><p>Le Jeu de la Vie de Conway</p></div><button id="gol-minimize" class="gol-minimize" title="Réduire"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon-min"><line x1="5" y1="12" x2="19" y2="12"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon-max"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></button></div><div class="gol-body"><div class="gol-description"><p><strong>Règles :</strong> Cellule vivante avec 2-3 voisins survit. Cellule morte avec 3 voisins naît.</p></div><div class="gol-controls"><button id="gol-play" class="gol-btn active" title="Play/Pause"><svg viewBox="0 0 24 24" fill="currentColor" class="icon-pause"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg><svg viewBox="0 0 24 24" fill="currentColor" class="icon-play"><polygon points="5,3 19,12 5,21"/></svg></button><button id="gol-step" class="gol-btn" title="Étape"><svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 15,12 5,21"/><rect x="15" y="3" width="4" height="18"/></svg></button><button id="gol-random" class="gol-btn" title="Aléatoire"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></button><button id="gol-clear" class="gol-btn" title="Effacer"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button><button id="gol-gliders" class="gol-btn" title="Planeurs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><polygon points="22,2 15,22 11,13 2,9"/></svg></button></div><div class="gol-zoom-row"><button id="gol-zoom-out" class="gol-btn gol-btn-sm">−</button><span id="gol-zoom-val">30px</span><button id="gol-zoom-in" class="gol-btn gol-btn-sm">+</button></div><div class="gol-speed-row"><label>Vitesse: <span id="gol-speed-val">15</span> fps</label><input type="range" id="gol-speed" min="1" max="60" value="15"></div><div class="gol-stats"><div class="gol-stat"><span id="gol-gen">0</span><label>Génération</label></div><div class="gol-stat"><span id="gol-pop">0</span><label>Population</label></div><div class="gol-stat"><span id="gol-dim">0×0</span><label>Grille</label></div></div></div><button id="gol-exit" class="gol-exit">✕ Quitter</button></div>';
|
||||
document.body.appendChild(gameContainer);
|
||||
|
||||
const gameStyles = document.createElement('style');
|
||||
gameStyles.id = 'gol-styles';
|
||||
gameStyles.textContent = '#game-of-life{position:fixed;inset:0;z-index:10000;background:#050508;animation:golIn 0.5s ease}@keyframes golIn{from{opacity:0}to{opacity:1}}#gol-canvas{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}.gol-ui{position:absolute;top:20px;left:20px;background:rgba(10,10,15,0.95);backdrop-filter:blur(10px);border:1px solid rgba(139,58,98,0.3);border-radius:16px;padding:1rem;min-width:280px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}.gol-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem}.gol-header-text h1{font-size:1.1rem;margin-bottom:0.1rem;background:linear-gradient(135deg,#d4a5c9,#8b3a62);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.gol-header-text p{font-size:0.7rem;color:#6b6b7b}.gol-minimize{width:32px;height:32px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.05);border-radius:8px;color:#b8b8c8;cursor:pointer;display:flex;align-items:center;justify-content:center}.gol-minimize:hover{background:rgba(139,58,98,0.3);border-color:#8b3a62;color:#fff}.gol-minimize svg{width:16px;height:16px}.gol-minimize .icon-max{display:none}.gol-ui.minimized .gol-body,.gol-ui.minimized .gol-exit{display:none}.gol-ui.minimized .gol-minimize .icon-min{display:none}.gol-ui.minimized .gol-minimize .icon-max{display:block}.gol-ui.minimized{min-width:auto}.gol-body{display:flex;flex-direction:column;gap:0.75rem}.gol-description{background:rgba(139,58,98,0.1);border:1px solid rgba(139,58,98,0.2);border-radius:8px;padding:0.5rem}.gol-description p{font-size:0.7rem;color:#b8b8c8;margin:0}.gol-description strong{color:#d4a5c9}.gol-controls{display:flex;gap:0.4rem;flex-wrap:wrap}.gol-btn{width:40px;height:40px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.05);border-radius:8px;color:#b8b8c8;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s}.gol-btn:hover{background:rgba(139,58,98,0.3);border-color:#8b3a62;color:#fff;transform:translateY(-2px)}.gol-btn svg{width:18px;height:18px}.gol-btn.active{background:linear-gradient(135deg,#8b3a62,#5e2a8b);border-color:transparent;color:#fff}.gol-btn .icon-play{display:none}.gol-btn .icon-pause{display:block}.gol-btn:not(.active) .icon-play{display:block}.gol-btn:not(.active) .icon-pause{display:none}.gol-btn-sm{width:32px;height:32px;font-size:1.2rem;font-weight:bold}.gol-zoom-row,.gol-speed-row{display:flex;align-items:center;gap:0.5rem}.gol-zoom-row span{color:#d4a5c9;font-family:monospace;font-size:0.85rem;min-width:40px;text-align:center}.gol-speed-row{flex-direction:column;align-items:stretch}.gol-speed-row label{font-size:0.75rem;color:#b8b8c8}.gol-speed-row label span{color:#d4a5c9;font-family:monospace}.gol-speed-row input{width:100%;height:6px;-webkit-appearance:none;background:rgba(255,255,255,0.1);border-radius:3px;cursor:pointer}.gol-speed-row input::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;background:linear-gradient(135deg,#8b3a62,#5e2a8b);border-radius:50%;cursor:pointer}.gol-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:0.5rem}.gol-stat{text-align:center;padding:0.4rem;background:rgba(255,255,255,0.03);border-radius:6px}.gol-stat span{font-size:1rem;font-weight:700;color:#d4a5c9;font-family:monospace;display:block}.gol-stat label{font-size:0.6rem;color:#6b6b7b;text-transform:uppercase}.gol-exit{width:100%;margin-top:0.5rem;padding:0.6rem;background:transparent;border:1px solid rgba(255,255,255,0.1);border-radius:8px;color:#6b6b7b;font-size:0.8rem;cursor:pointer;transition:all 0.2s}.gol-exit:hover{background:rgba(255,50,50,0.2);border-color:rgba(255,50,50,0.5);color:#ff6b6b}@media(max-width:768px){.gol-ui{top:auto;bottom:0;left:0;right:0;border-radius:16px 16px 0 0;max-height:50vh;overflow-y:auto;min-width:auto}.gol-header-text p,.gol-description{display:none}.gol-ui.minimized{max-height:none}}@media(max-width:480px){.gol-ui{padding:0.75rem}.gol-btn{width:36px;height:36px}.gol-btn-sm{width:28px;height:28px}}';
|
||||
document.head.appendChild(gameStyles);
|
||||
|
||||
const canvas = document.getElementById('gol-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const MAX_CELLS = 150, MIN_SIZE = 4, MAX_SIZE = 30;
|
||||
let cellSize = MAX_SIZE, minCellSize = MIN_SIZE, cols, rows, grid, nextGrid;
|
||||
let isRunning = true, generation = 0, speed = 15, lastTime = 0, isDrawing = false, drawValue = 1;
|
||||
|
||||
function initCanvas() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
minCellSize = Math.max(MIN_SIZE, Math.ceil(canvas.width / MAX_CELLS), Math.ceil(canvas.height / MAX_CELLS));
|
||||
cellSize = Math.max(minCellSize, Math.min(MAX_SIZE, cellSize));
|
||||
cols = Math.ceil(canvas.width / cellSize);
|
||||
rows = Math.ceil(canvas.height / cellSize);
|
||||
grid = Array(cols).fill(null).map(() => Array(rows).fill(0));
|
||||
nextGrid = Array(cols).fill(null).map(() => Array(rows).fill(0));
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function addGlider(sx, sy, dir) {
|
||||
const p = [[[0,1],[1,2],[2,0],[2,1],[2,2]],[[0,1],[1,0],[2,0],[2,1],[2,2]],[[0,0],[0,1],[0,2],[1,2],[2,1]],[[0,0],[0,1],[0,2],[1,0],[2,1]]];
|
||||
p[dir % 4].forEach(([dx, dy]) => { const x = (sx + dx) % cols, y = (sy + dy) % rows; if (x >= 0 && x < cols && y >= 0 && y < rows) grid[x][y] = 1; });
|
||||
}
|
||||
|
||||
function addGliders(n) {
|
||||
grid = Array(cols).fill(null).map(() => Array(rows).fill(0));
|
||||
for (let i = 0; i < n; i++) addGlider(Math.floor(Math.random() * (cols - 10)) + 5, Math.floor(Math.random() * (rows - 10)) + 5, Math.floor(Math.random() * 4));
|
||||
generation = 0; updateUI();
|
||||
}
|
||||
|
||||
function randomize() { for (let x = 0; x < cols; x++) for (let y = 0; y < rows; y++) grid[x][y] = Math.random() > 0.7 ? 1 : 0; generation = 0; updateUI(); }
|
||||
function clear() { for (let x = 0; x < cols; x++) for (let y = 0; y < rows; y++) grid[x][y] = 0; generation = 0; updateUI(); }
|
||||
|
||||
function step() {
|
||||
for (let x = 0; x < cols; x++) for (let y = 0; y < rows; y++) {
|
||||
let n = 0;
|
||||
for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) if (i || j) n += grid[(x + i + cols) % cols][(y + j + rows) % rows];
|
||||
nextGrid[x][y] = grid[x][y] ? (n === 2 || n === 3 ? 1 : 0) : (n === 3 ? 1 : 0);
|
||||
}
|
||||
[grid, nextGrid] = [nextGrid, grid]; generation++; updateUI();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = 'rgba(139,58,98,0.1)'; ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x <= cols * cellSize; x += cellSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, rows * cellSize); ctx.stroke(); }
|
||||
for (let y = 0; y <= rows * cellSize; y += cellSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cols * cellSize, y); ctx.stroke(); }
|
||||
ctx.shadowColor = 'rgba(139,58,98,0.5)'; ctx.shadowBlur = 8;
|
||||
for (let x = 0; x < cols; x++) for (let y = 0; y < rows; y++) if (grid[x][y]) {
|
||||
const g = ctx.createRadialGradient(x * cellSize + cellSize / 2, y * cellSize + cellSize / 2, 0, x * cellSize + cellSize / 2, y * cellSize + cellSize / 2, cellSize);
|
||||
g.addColorStop(0, '#d4a5c9'); g.addColorStop(0.5, '#8b3a62'); g.addColorStop(1, '#5e2a8b');
|
||||
ctx.fillStyle = g; ctx.fillRect(x * cellSize + 1, y * cellSize + 1, cellSize - 2, cellSize - 2);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
let pop = 0; for (let x = 0; x < cols; x++) for (let y = 0; y < rows; y++) pop += grid[x][y];
|
||||
document.getElementById('gol-gen').textContent = generation;
|
||||
document.getElementById('gol-pop').textContent = pop;
|
||||
document.getElementById('gol-dim').textContent = cols + '×' + rows;
|
||||
document.getElementById('gol-zoom-val').textContent = cellSize + 'px';
|
||||
document.getElementById('gol-speed-val').textContent = speed;
|
||||
}
|
||||
|
||||
function handleZoom(d) {
|
||||
const old = cellSize; cellSize = Math.max(minCellSize, Math.min(MAX_SIZE, cellSize + d));
|
||||
if (cellSize !== old) { const og = grid, oc = cols, or = rows; initCanvas(); for (let x = 0; x < Math.min(oc, cols); x++) for (let y = 0; y < Math.min(or, rows); y++) grid[x][y] = og[x][y]; updateUI(); }
|
||||
}
|
||||
|
||||
function getCell(e) {
|
||||
const r = canvas.getBoundingClientRect(), cx = e.touches ? e.touches[0].clientX : e.clientX, cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
return { x: Math.max(0, Math.min(cols - 1, Math.floor((cx - r.left) / cellSize))), y: Math.max(0, Math.min(rows - 1, Math.floor((cy - r.top) / cellSize))) };
|
||||
}
|
||||
|
||||
function gameLoop(t) { if (isRunning && t - lastTime > 1000 / speed) { step(); lastTime = t; } draw(); requestAnimationFrame(gameLoop); }
|
||||
|
||||
canvas.addEventListener('mousedown', e => { isDrawing = true; const c = getCell(e); drawValue = grid[c.x][c.y] ? 0 : 1; grid[c.x][c.y] = drawValue; });
|
||||
canvas.addEventListener('mousemove', e => { if (isDrawing) { const c = getCell(e); grid[c.x][c.y] = drawValue; } });
|
||||
canvas.addEventListener('mouseup', () => isDrawing = false);
|
||||
canvas.addEventListener('mouseleave', () => isDrawing = false);
|
||||
canvas.addEventListener('touchstart', e => { e.preventDefault(); isDrawing = true; const c = getCell(e); drawValue = grid[c.x][c.y] ? 0 : 1; grid[c.x][c.y] = drawValue; }, { passive: false });
|
||||
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (isDrawing) { const c = getCell(e); grid[c.x][c.y] = drawValue; } }, { passive: false });
|
||||
canvas.addEventListener('touchend', () => isDrawing = false);
|
||||
canvas.addEventListener('wheel', e => { e.preventDefault(); handleZoom(e.deltaY > 0 ? -1 : 1); });
|
||||
|
||||
document.getElementById('gol-play').addEventListener('click', function() { isRunning = !isRunning; this.classList.toggle('active', isRunning); });
|
||||
document.getElementById('gol-step').addEventListener('click', () => { if (!isRunning) step(); });
|
||||
document.getElementById('gol-random').addEventListener('click', randomize);
|
||||
document.getElementById('gol-clear').addEventListener('click', clear);
|
||||
document.getElementById('gol-gliders').addEventListener('click', () => addGliders(3));
|
||||
document.getElementById('gol-zoom-in').addEventListener('click', () => handleZoom(2));
|
||||
document.getElementById('gol-zoom-out').addEventListener('click', () => handleZoom(-2));
|
||||
document.getElementById('gol-speed').addEventListener('input', function() { speed = parseInt(this.value); updateUI(); });
|
||||
document.getElementById('gol-minimize').addEventListener('click', () => document.querySelector('.gol-ui').classList.toggle('minimized'));
|
||||
document.getElementById('gol-exit').addEventListener('click', () => { document.getElementById('game-of-life').remove(); document.getElementById('gol-styles').remove(); });
|
||||
window.addEventListener('resize', () => { const og = grid, oc = cols, or = rows; initCanvas(); for (let x = 0; x < Math.min(oc, cols); x++) for (let y = 0; y < Math.min(or, rows); y++) grid[x][y] = og[x][y]; updateUI(); });
|
||||
|
||||
initCanvas();
|
||||
addGliders(3);
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
35
assets/js/pages/legal.js
Normal file
35
assets/js/pages/legal.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// assets/js/pages/legal.js - minimal fallback for legal pages
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// replace feather icons if available (use centralized loader if present)
|
||||
if (typeof window.ensureFeather === 'function') {
|
||||
window.ensureFeather().then(() => { try { if (window.feather && feather.replace) feather.replace(); } catch (e) {} }).catch(()=>{});
|
||||
} else if (window.feather && feather.replace) {
|
||||
try { feather.replace(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// theme fallback (will be overwritten by assets/js/main.js if present)
|
||||
const html = document.documentElement;
|
||||
if (!html.getAttribute('data-theme')) {
|
||||
const saved = localStorage.getItem('theme');
|
||||
const validTheme = ['dark', 'light'].includes(saved) ? saved : 'dark';
|
||||
html.setAttribute('data-theme', validTheme);
|
||||
}
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) themeToggle.addEventListener('click', () => {
|
||||
const newTheme = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
});
|
||||
|
||||
// navbar scrolled
|
||||
const navbar = document.getElementById('navbar');
|
||||
if (navbar) window.addEventListener('scroll', () => navbar.classList.toggle('scrolled', window.scrollY > 50));
|
||||
|
||||
// mobile toggle (minimal)
|
||||
const mobileToggle = document.getElementById('mobile-toggle');
|
||||
const navLinks = document.getElementById('nav-links');
|
||||
if (mobileToggle && navLinks) mobileToggle.addEventListener('click', () => {
|
||||
mobileToggle.classList.toggle('active');
|
||||
navLinks.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
450
assets/js/pages/realisations.js
Normal file
450
assets/js/pages/realisations.js
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// Page script for /pages/realisations.html et index.html
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Fonctionne depuis /pages/ et depuis / (index.html)
|
||||
const isRoot = !window.location.pathname.includes('/pages/');
|
||||
const dataUrl = isRoot ? 'assets/data/projects.json' : '../assets/data/projects.json';
|
||||
let allProjects = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(dataUrl);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
allProjects = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects data:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 9;
|
||||
let currentPage = 1;
|
||||
let currentFilter = 'all';
|
||||
|
||||
const portfolioGrid = document.getElementById('portfolio-grid');
|
||||
const paginationContainer = document.getElementById('pagination');
|
||||
const filterTabs = document.querySelectorAll('.filter-tab');
|
||||
|
||||
const modalOverlay = document.getElementById('project-modal-overlay');
|
||||
const modalClose = document.getElementById('project-modal-close');
|
||||
const gallerySlides = document.getElementById('gallery-slides');
|
||||
const galleryPrev = document.getElementById('gallery-prev');
|
||||
const galleryNext = document.getElementById('gallery-next');
|
||||
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalCategory = document.getElementById('modal-category');
|
||||
const modalDesc = document.getElementById('modal-desc');
|
||||
const modalMeta = document.getElementById('modal-meta');
|
||||
const modalFeatures = document.getElementById('modal-features');
|
||||
const modalTech = document.getElementById('modal-tech');
|
||||
const modalStats = document.getElementById('modal-stats');
|
||||
|
||||
function getFilteredProjects() {
|
||||
if (currentFilter === 'all') return [...allProjects];
|
||||
return allProjects.filter(p => p.category === currentFilter);
|
||||
}
|
||||
|
||||
function getTotalPages(filtered) {
|
||||
return Math.ceil(filtered.length / ITEMS_PER_PAGE) || 1;
|
||||
}
|
||||
|
||||
function getProjectsForPage(filtered, page) {
|
||||
const start = (page - 1) * ITEMS_PER_PAGE;
|
||||
return filtered.slice(start, start + ITEMS_PER_PAGE);
|
||||
}
|
||||
|
||||
function getCategoryLabel(category) {
|
||||
const labels = { 'bot': 'Bot Discord', 'map': 'Fortnite Map', 'graphisme': 'Graphisme' };
|
||||
return labels[category] || category;
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
if (!portfolioGrid) return; // pas sur la page réalisations
|
||||
const filtered = getFilteredProjects();
|
||||
const projects = getProjectsForPage(filtered, currentPage);
|
||||
|
||||
portfolioGrid.innerHTML = projects.map(project => {
|
||||
const prefix = isRoot ? '' : '../';
|
||||
const firstFile = Array.isArray(project.files) && project.files.length > 0 ? project.files[0] : null;
|
||||
const safeTitle = window.escapeHTML(project.title);
|
||||
const coverImg = firstFile ? `<img src="${window.escapeHTML(prefix + project.images + firstFile)}" alt="${safeTitle}" class="portfolio-cover-img" onerror="this.style.display='none'">` : '';
|
||||
return `
|
||||
<div class="portfolio-item reveal" data-project-id="${window.escapeHTML(project.id)}">
|
||||
<div class="portfolio-bg" style="background: ${window.escapeHTML(project.gradient)};">${coverImg}</div>
|
||||
<div class="portfolio-content">
|
||||
<span class="portfolio-tag">${window.escapeHTML(getCategoryLabel(project.category))}</span>
|
||||
<h4>${safeTitle}</h4>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// activate reveals & icons
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.portfolio-item.reveal').forEach(el => el.classList.add('active'));
|
||||
}, 80);
|
||||
|
||||
// ensure icons are replaced for newly injected content
|
||||
if (window.feather && feather.replace) {
|
||||
try { feather.replace(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
if (!paginationContainer) return; // pas sur la page réalisations
|
||||
const filtered = getFilteredProjects();
|
||||
const totalPages = getTotalPages(filtered);
|
||||
|
||||
if (totalPages <= 1) {
|
||||
paginationContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<button class="pagination-btn pagination-arrow" data-page="${Math.max(1, currentPage - 1)}" ${currentPage === 1 ? 'disabled' : ''}><i data-feather="chevron-left"></i></button>`;
|
||||
|
||||
const maxVisible = 5;
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
||||
if (endPage - startPage < maxVisible - 1) startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
|
||||
if (startPage > 1) {
|
||||
html += `<button class="pagination-btn" data-page="1">1</button>`;
|
||||
if (startPage > 2) html += `<span class="pagination-ellipsis">...</span>`;
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
html += `<button class="pagination-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) html += `<span class="pagination-ellipsis">...</span>`;
|
||||
html += `<button class="pagination-btn" data-page="${totalPages}">${totalPages}</button>`;
|
||||
}
|
||||
|
||||
html += `<button class="pagination-btn pagination-arrow" data-page="${Math.min(totalPages, currentPage + 1)}" ${currentPage === totalPages ? 'disabled' : ''}><i data-feather="chevron-right"></i></button>`;
|
||||
|
||||
const startItem = (currentPage - 1) * ITEMS_PER_PAGE + 1;
|
||||
const endItem = Math.min(currentPage * ITEMS_PER_PAGE, filtered.length);
|
||||
html += `<div class="pagination-info">Affichage ${startItem}-${endItem} sur ${filtered.length} projets</div>`;
|
||||
|
||||
paginationContainer.innerHTML = html;
|
||||
|
||||
if (window.feather && feather.replace) {
|
||||
try { feather.replace(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
const filtered = getFilteredProjects();
|
||||
const totalPages = getTotalPages(filtered);
|
||||
if (page < 1 || page > totalPages) return;
|
||||
currentPage = page;
|
||||
renderProjects();
|
||||
renderPagination();
|
||||
portfolioGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// ── Modal tab switching ──────────────────────────────────────────────────────
|
||||
const modalTabs = document.querySelectorAll('.project-modal-tab');
|
||||
modalTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabId = tab.dataset.tab;
|
||||
modalTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.querySelectorAll('.project-modal-tab-content').forEach(c => {
|
||||
c.classList.toggle('active', c.id === 'tab-' + tabId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function resetModalTabs() {
|
||||
modalTabs.forEach((t, i) => t.classList.toggle('active', i === 0));
|
||||
document.querySelectorAll('.project-modal-tab-content').forEach((c, i) => c.classList.toggle('active', i === 0));
|
||||
}
|
||||
|
||||
function openProjectModal(projectId) {
|
||||
const project = allProjects.find(p => p.id === Number(projectId));
|
||||
if (!project) return;
|
||||
|
||||
resetModalTabs();
|
||||
|
||||
modalCategory.textContent = getCategoryLabel(project.category);
|
||||
modalTitle.textContent = project.title;
|
||||
modalDesc.textContent = project.desc;
|
||||
|
||||
modalMeta.innerHTML = `<span><i data-feather="calendar"></i> ${window.escapeHTML(project.year)}</span>${project.duration ? ` <span><i data-feather="clock"></i> ${window.escapeHTML(project.duration)}</span>` : ''}`;
|
||||
modalFeatures.innerHTML = project.features.map(f => `<div class="project-feature-item">${window.escapeHTML(f)}</div>`).join('');
|
||||
|
||||
// Tech avec icônes (support ancien format string et nouveau format {name, icon})
|
||||
modalTech.innerHTML = project.tech.map(t => {
|
||||
const name = typeof t === 'object' ? t.name : t;
|
||||
const icon = typeof t === 'object' ? (t.icon || 'code') : 'code';
|
||||
return `<div class="project-tech-item"><i data-feather="${window.escapeHTML(icon)}"></i>${window.escapeHTML(name)}</div>`;
|
||||
}).join('');
|
||||
|
||||
// Stats en grille 2×2 avec icônes
|
||||
modalStats.innerHTML = project.stats.map(s => `
|
||||
<div class="project-stat-card">
|
||||
<div class="project-stat-card-icon"><i data-feather="${window.escapeHTML(s.icon || 'star')}"></i></div>
|
||||
<div class="project-stat-card-value">${window.escapeHTML(s.value)}</div>
|
||||
<div class="project-stat-card-label">${window.escapeHTML(s.label)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Gallery : utilise project.files (noms réels) avec fallback gradient si vide
|
||||
const imgPath = (isRoot ? '' : '../') + (project.images || '');
|
||||
const files = Array.isArray(project.files) ? project.files : [];
|
||||
|
||||
if (files.length > 0) {
|
||||
// Images réelles nommées explicitement dans le JSON
|
||||
// Supporte : fichier local, mp4 local, URL YouTube, shortcode yt:VIDEO_ID
|
||||
gallerySlides.innerHTML = files.map((filename, i) => {
|
||||
const isLocalVideo = /\.(mp4|webm|ogg)$/i.test(filename);
|
||||
|
||||
// Détecte YouTube : URL complète ou shortcode yt:ID
|
||||
const ytMatch = filename.match(/(?:yt:|youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
|
||||
|
||||
if (ytMatch) {
|
||||
const videoId = ytMatch[1];
|
||||
return `
|
||||
<div class="project-gallery-slide slide-right gallery-slide-youtube" data-img-idx="${i}" style="background:${window.escapeHTML(project.gradient)};">
|
||||
<div class="yt-wrapper">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/${window.escapeHTML(videoId)}?rel=0&modestbranding=1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
class="gallery-slide-yt-iframe"
|
||||
title="Vidéo YouTube ${window.escapeHTML(project.title)}"
|
||||
loading="lazy">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (isLocalVideo) {
|
||||
return `
|
||||
<div class="project-gallery-slide slide-right" data-img-idx="${i}" style="background:${window.escapeHTML(project.gradient)};">
|
||||
<video class="gallery-slide-img gallery-video" src="${window.escapeHTML(imgPath + filename)}" muted loop autoplay playsinline></video>
|
||||
<button class="gallery-mute-btn" aria-label="Activer le son" title="Activer le son" onclick="(function(btn){var v=btn.closest('.project-gallery-slide').querySelector('video');v.muted=!v.muted;btn.classList.toggle('unmuted',!v.muted);btn.setAttribute('aria-label',v.muted?'Activer le son':'Couper le son');})(this)">
|
||||
<svg class="icon-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||||
<svg class="icon-unmuted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="project-gallery-slide slide-right" data-img-idx="${i}" style="background:${window.escapeHTML(project.gradient)};">
|
||||
<img src="${window.escapeHTML(imgPath + filename)}" alt="${window.escapeHTML(project.title)} ${i + 1}"
|
||||
class="gallery-slide-img"
|
||||
onerror="this.parentElement.classList.add('no-image');this.style.display='none'">
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
// Fallback : une slide gradient unique
|
||||
gallerySlides.innerHTML = `
|
||||
<div class="project-gallery-slide has-image" style="background:${window.escapeHTML(project.gradient)};"></div>`;
|
||||
}
|
||||
|
||||
// reset gallery + affichage immédiat
|
||||
galleryIndex = 0;
|
||||
updateGallery();
|
||||
updateGalleryDots();
|
||||
|
||||
if (modalOverlay) modalOverlay.classList.add('active');
|
||||
|
||||
// Réseaux sociaux du projet
|
||||
const projectSocialEl = document.getElementById('modal-project-social');
|
||||
if (projectSocialEl) {
|
||||
// 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 utilise escapeHTML
|
||||
if (window.renderSocialLinks.toString().includes('escapeHTML')) {
|
||||
socialHtml = window.renderSocialLinks(project.social);
|
||||
} else {
|
||||
console.warn('[Project Modal] window.renderSocialLinks semble compromise, utilisation du fallback sécurisé');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Project Modal] Erreur lors du rendu des liens sociaux:', e);
|
||||
}
|
||||
projectSocialEl.innerHTML = socialHtml;
|
||||
}
|
||||
|
||||
if (window.feather && feather.replace) {
|
||||
try { feather.replace(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectModal() {
|
||||
if (modalOverlay) modalOverlay.classList.remove('active');
|
||||
}
|
||||
|
||||
// Gallery helpers
|
||||
let galleryIndex = 0;
|
||||
function getAllSlides() {
|
||||
if (!gallerySlides) return [];
|
||||
return Array.from(gallerySlides.children).filter(s => !s.classList.contains('no-image'));
|
||||
}
|
||||
|
||||
function updateGallery() {
|
||||
const slides = getAllSlides();
|
||||
if (!slides.length) return;
|
||||
if (galleryIndex >= slides.length) galleryIndex = 0;
|
||||
if (galleryIndex < 0) galleryIndex = slides.length - 1;
|
||||
slides.forEach((s, i) => {
|
||||
// Le CSS gère tout via .active — on ne touche JAMAIS à style.transform
|
||||
s.classList.toggle('active', i === galleryIndex);
|
||||
// Juste la direction (gauche/droite) via une classe data
|
||||
if (i < galleryIndex) {
|
||||
s.classList.add('slide-left');
|
||||
s.classList.remove('slide-right');
|
||||
} else if (i > galleryIndex) {
|
||||
s.classList.add('slide-right');
|
||||
s.classList.remove('slide-left');
|
||||
} else {
|
||||
s.classList.remove('slide-left', 'slide-right');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateGalleryDots() {
|
||||
const galleryDots = document.getElementById('gallery-dots');
|
||||
if (!galleryDots) return;
|
||||
const slides = getAllSlides();
|
||||
galleryDots.innerHTML = slides.map((_, i) =>
|
||||
`<div class="gallery-dot${i === 0 ? ' active' : ''}" data-idx="${i}"></div>`
|
||||
).join('');
|
||||
galleryDots.querySelectorAll('.gallery-dot').forEach(dot => {
|
||||
dot.addEventListener('click', () => {
|
||||
galleryIndex = parseInt(dot.dataset.idx);
|
||||
updateGallery();
|
||||
galleryDots.querySelectorAll('.gallery-dot').forEach((d, i) =>
|
||||
d.classList.toggle('active', i === galleryIndex));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncDots() {
|
||||
const galleryDots = document.getElementById('gallery-dots');
|
||||
if (!galleryDots) return;
|
||||
galleryDots.querySelectorAll('.gallery-dot').forEach((d, i) =>
|
||||
d.classList.toggle('active', i === galleryIndex));
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
galleryIndex = galleryIndex <= 0 ? getAllSlides().length - 1 : galleryIndex - 1;
|
||||
updateGallery();
|
||||
syncDots();
|
||||
}
|
||||
function nextSlide() {
|
||||
galleryIndex = galleryIndex >= getAllSlides().length - 1 ? 0 : galleryIndex + 1;
|
||||
updateGallery();
|
||||
syncDots();
|
||||
}
|
||||
|
||||
// Event bindings — uniquement sur la page réalisations
|
||||
if (filterTabs.length) {
|
||||
filterTabs.forEach(tab => tab.addEventListener('click', (e) => {
|
||||
filterTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentFilter = tab.dataset.filter || 'all';
|
||||
currentPage = 1;
|
||||
renderProjects();
|
||||
renderPagination();
|
||||
}));
|
||||
}
|
||||
|
||||
if (paginationContainer) {
|
||||
paginationContainer.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-page]');
|
||||
if (!btn) return;
|
||||
const page = Number(btn.getAttribute('data-page'));
|
||||
goToPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
if (portfolioGrid) {
|
||||
portfolioGrid.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.portfolio-item');
|
||||
if (!item) return;
|
||||
const id = item.getAttribute('data-project-id');
|
||||
openProjectModal(id);
|
||||
});
|
||||
}
|
||||
|
||||
if (modalClose) modalClose.addEventListener('click', closeProjectModal);
|
||||
if (modalOverlay) modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeProjectModal(); });
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modalOverlay && modalOverlay.classList.contains('active')) closeProjectModal(); });
|
||||
|
||||
if (galleryPrev) galleryPrev.addEventListener('click', prevSlide);
|
||||
if (galleryNext) galleryNext.addEventListener('click', nextSlide);
|
||||
|
||||
// Expose for compatibility
|
||||
window.openProjectModal = openProjectModal;
|
||||
window.goToPage = goToPage;
|
||||
|
||||
// ── Projets phares (bento grid) — 3 premiers projets du JSON ───────────────
|
||||
const featuredBento = document.getElementById('featured-bento-grid');
|
||||
if (featuredBento) {
|
||||
const featured = allProjects.slice(0, 3);
|
||||
const categoryLabels = { bot: 'Bot Discord', map: 'Fortnite Map', graphisme: 'Graphisme' };
|
||||
|
||||
featuredBento.innerHTML = featured.map((p, i) => {
|
||||
const isReverse = i % 2 === 1 ? ' reverse' : '';
|
||||
const statsHtml = (p.stats || []).slice(0, 4).map((s) => `
|
||||
<div class="bento-stat">
|
||||
<div class="bento-stat-icon"><i data-feather="${window.escapeHTML(s.icon || 'star')}"></i></div>
|
||||
<div class="bento-stat-value">${window.escapeHTML(s.value)}</div>
|
||||
<div class="bento-stat-label">${window.escapeHTML(s.label)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
const featuresHtml = (p.features || []).slice(0, 6).map(f => `<span class="bento-feature">${window.escapeHTML(f)}</span>`).join('');
|
||||
const divider = i < featured.length - 1 ? '<div class="bento-divider"></div>' : '';
|
||||
|
||||
const prefix = isRoot ? '' : '../';
|
||||
const firstFile = Array.isArray(p.files) && p.files.length > 0 ? p.files[0] : null;
|
||||
const bentoCover = firstFile
|
||||
? `<img src="${window.escapeHTML(prefix + p.images + firstFile)}" alt="${window.escapeHTML(p.title)}" class="bento-cover-img" onerror="this.style.display='none'">`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="bento-project${isReverse} reveal" data-project-id="${window.escapeHTML(p.id)}" style="cursor:pointer;" role="button" tabindex="0" aria-label="Voir le projet ${window.escapeHTML(p.title)}">
|
||||
<div class="bento-image" style="background: ${window.escapeHTML(p.gradient)};">
|
||||
${bentoCover}
|
||||
<div class="bento-image-content">
|
||||
<span class="portfolio-tag">${window.escapeHTML(categoryLabels[p.category] || p.category)}</span>
|
||||
<h3>${window.escapeHTML(p.title)}</h3>
|
||||
<p>${window.escapeHTML(p.desc.substring(0, 100))}${p.desc.length > 100 ? '…' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bento-stats">${statsHtml}</div>
|
||||
<div class="bento-info">
|
||||
<h4>Description</h4>
|
||||
<p>${window.escapeHTML(p.desc)}</p>
|
||||
<div class="bento-features">${featuresHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
${divider}
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Replace feather icons
|
||||
if (window.feather && feather.replace) { try { feather.replace(); } catch(e) {} }
|
||||
|
||||
// Click to open modal
|
||||
featuredBento.querySelectorAll('.bento-project').forEach(el => {
|
||||
const open = () => openProjectModal(el.getAttribute('data-project-id'));
|
||||
el.addEventListener('click', open);
|
||||
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
|
||||
});
|
||||
|
||||
// Reveal animation
|
||||
setTimeout(() => {
|
||||
featuredBento.querySelectorAll('.reveal').forEach(el => el.classList.add('active'));
|
||||
}, 80);
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderProjects();
|
||||
renderPagination();
|
||||
});
|
||||
32
assets/js/pages/services-process.js
Normal file
32
assets/js/pages/services-process.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Services page scripts (moved from inline in pages/services.html)
|
||||
// Depends on ../main.js which initializes feather and other globals
|
||||
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Timeline Animation
|
||||
const timeline = document.getElementById('process-timeline');
|
||||
const steps = document.querySelectorAll('.process-step');
|
||||
|
||||
function animateTimeline() {
|
||||
if (!timeline) return;
|
||||
const timelineTop = timeline.getBoundingClientRect().top;
|
||||
|
||||
if (timelineTop < window.innerHeight - 200) {
|
||||
timeline.classList.add('animated');
|
||||
|
||||
// Animate steps sequentially
|
||||
steps.forEach((step, index) => {
|
||||
setTimeout(() => {
|
||||
step.classList.add('active');
|
||||
}, index * 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', animateTimeline);
|
||||
window.addEventListener('load', animateTimeline);
|
||||
|
||||
|
||||
|
||||
});
|
||||
})();
|
||||
176
assets/js/pages/services.js
Normal file
176
assets/js/pages/services.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* OmégaKube — Services Tabs Renderer
|
||||
* Charge assets/data/services.json et construit dynamiquement
|
||||
* les onglets de navigation et leurs contenus.
|
||||
*
|
||||
* Dépend de main.js et services-process.js (déjà chargés avant ce script).
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────── */
|
||||
|
||||
function stampHtml(stamp, extraClass) {
|
||||
if (!stamp) return '';
|
||||
const animated = stamp.animated ? ' service-stamp-animated' : '';
|
||||
const extra = extraClass ? ' ' + extraClass : '';
|
||||
return `<span class="service-stamp service-stamp-${window.escapeHTML(stamp.type)}${animated}${extra}">${window.escapeHTML(stamp.text)}</span>`;
|
||||
}
|
||||
|
||||
function particlesHtml() {
|
||||
return `<div class="service-visual-particles">${'<div class="particle"></div>'.repeat(6)}</div>`;
|
||||
}
|
||||
|
||||
/* ── Rendu ───────────────────────────────────────────────── */
|
||||
|
||||
function renderNavBtn(service, isFirst) {
|
||||
return `
|
||||
<button class="service-tab-btn${isFirst ? ' active' : ''}" data-tab="${window.escapeHTML(service.id)}">
|
||||
<i data-feather="${window.escapeHTML(service.nav.icon)}"></i>
|
||||
<span>${window.escapeHTML(service.nav.label)}</span>
|
||||
${stampHtml(service.nav.stamp)}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderVisual(service) {
|
||||
const { accentFrom, accentTo, stamp } = service.visual;
|
||||
const safeFrom = window.escapeHTML(accentFrom);
|
||||
const safeTo = window.escapeHTML(accentTo);
|
||||
return `
|
||||
<div class="service-visual" style="--accent-primary:${safeFrom};--accent-secondary:${safeTo};">
|
||||
${stampHtml(stamp, 'service-visual-stamp')}
|
||||
${particlesHtml()}
|
||||
<div class="service-visual-icon">
|
||||
<div class="icon-main" style="background:linear-gradient(135deg,${safeFrom},${safeTo});">
|
||||
<i data-feather="${window.escapeHTML(service.nav.icon)}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHighlights(highlights) {
|
||||
return highlights.map(h => `
|
||||
<div class="service-highlight">
|
||||
<div class="service-highlight-value">${window.escapeHTML(h.value)}</div>
|
||||
<div class="service-highlight-label">${window.escapeHTML(h.label)}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderFeatures(features, accentFrom, accentTo) {
|
||||
const safeFrom = window.escapeHTML(accentFrom);
|
||||
const safeTo = window.escapeHTML(accentTo);
|
||||
return features.map(f => `
|
||||
<div class="service-feature-item">
|
||||
<div class="service-feature-icon" style="background:linear-gradient(135deg,${safeFrom},${safeTo});">
|
||||
<i data-feather="${window.escapeHTML(f.icon)}"></i>
|
||||
</div>
|
||||
<div class="service-feature-text">
|
||||
<h4>${window.escapeHTML(f.title)}</h4>
|
||||
<p>${window.escapeHTML(f.desc)}</p>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderNote(note) {
|
||||
if (!note || !note.length) return '';
|
||||
return `<p class="service-note">${note.map(n => `<span class="service-note-mark">${window.escapeHTML(n.value)}</span> ${window.escapeHTML(n.label)}<br>`).join(' ')}</p>`;
|
||||
}
|
||||
|
||||
function renderCtas(ctas) {
|
||||
return ctas.map(c => `
|
||||
<a href="${window.escapeHTML(c.href)}" class="btn btn-${window.escapeHTML(c.style)}">
|
||||
<i data-feather="${window.escapeHTML(c.icon)}"></i>
|
||||
${window.escapeHTML(c.label)}
|
||||
</a>`).join('');
|
||||
}
|
||||
|
||||
function renderTabContent(service, isFirst) {
|
||||
const { accentFrom, accentTo } = service.visual;
|
||||
const titleStamp = service.titleStamp
|
||||
? ' ' + stampHtml({ ...service.titleStamp }, 'service-stamp-inline')
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="service-tab-content${isFirst ? ' active' : ''}" id="tab-${window.escapeHTML(service.id)}">
|
||||
<div class="service-tab-grid">
|
||||
${renderVisual(service)}
|
||||
<div class="service-tab-details">
|
||||
<div class="service-tab-header">
|
||||
<h2>${window.escapeHTML(service.title)}${titleStamp}</h2>
|
||||
<p>${window.escapeHTML(service.description)}</p>
|
||||
</div>
|
||||
<div class="service-highlights">
|
||||
${renderHighlights(service.highlights)}
|
||||
</div>
|
||||
<div class="service-tab-features">
|
||||
${renderFeatures(service.features, accentFrom, accentTo)}
|
||||
</div>
|
||||
<div class="service-tab-note">
|
||||
${renderNote(service.note)}
|
||||
</div>
|
||||
<div class="service-tab-cta">
|
||||
${renderCtas(service.cta)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Logique des onglets ─────────────────────────────────── */
|
||||
|
||||
function initTabs() {
|
||||
const tabBtns = document.querySelectorAll('.service-tab-btn');
|
||||
const tabContents = document.querySelectorAll('.service-tab-content');
|
||||
if (!tabBtns.length || !tabContents.length) return;
|
||||
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tabId = btn.dataset.tab;
|
||||
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('active');
|
||||
if (content.id === 'tab-' + tabId) content.classList.add('active');
|
||||
});
|
||||
|
||||
if (window.feather) window.feather.replace();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Init ────────────────────────────────────────────────── */
|
||||
|
||||
async function init() {
|
||||
const navContainer = document.getElementById('service-tabs-nav');
|
||||
const contentContainer = document.getElementById('service-tabs-contents');
|
||||
if (!navContainer || !contentContainer) return;
|
||||
|
||||
let services;
|
||||
try {
|
||||
const res = await fetch('../assets/data/services.json');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
services = await res.json();
|
||||
} catch (err) {
|
||||
console.error('[services-tabs.js] Impossible de charger services.json :', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(services) || services.length === 0) {
|
||||
console.warn('[services-tabs.js] services.json est vide ou mal formé.');
|
||||
return;
|
||||
}
|
||||
|
||||
navContainer.innerHTML = services.map((s, i) => renderNavBtn(s, i === 0)).join('');
|
||||
contentContainer.innerHTML = services.map((s, i) => renderTabContent(s, i === 0)).join('');
|
||||
|
||||
if (window.feather) window.feather.replace();
|
||||
|
||||
initTabs();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue