website/assets/js/pages/realisations.js

451 lines
19 KiB
JavaScript
Raw Normal View History

2026-07-01 18:02:52 +02:00
// 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();
});