update last version of the website

This commit is contained in:
Mathis 2026-07-01 18:02:52 +02:00
commit abc54697fd
131 changed files with 11253 additions and 0 deletions

209
assets/js/pages/404.js Normal file
View 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
View 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');
});
});

View 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();
});

View 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
View 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);
})();