402 lines
No EOL
15 KiB
JavaScript
402 lines
No EOL
15 KiB
JavaScript
// OmégaKube - shared scripts
|
|
|
|
// Feather icons loader (centralized)
|
|
// loads https://unpkg.com/feather-icons when needed and exposes window.ensureFeather()
|
|
const FEATHER_URL = 'https://unpkg.com/feather-icons';
|
|
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 {
|
|
const res = await fetch(url);
|
|
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);
|
|
});
|
|
});
|
|
el.replaceWith(fragment);
|
|
} 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();
|
|
|
|
// 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 (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();
|
|
// });
|
|
// }
|
|
}); |