373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
// 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() {
|
||
const filtered = getFilteredProjects();
|
||
const projects = getProjectsForPage(filtered, currentPage);
|
||
|
||
portfolioGrid.innerHTML = projects.map(project => `
|
||
<div class="portfolio-item reveal" data-project-id="${project.id}">
|
||
<div class="portfolio-bg" style="background: ${project.gradient};"></div>
|
||
<div class="portfolio-content">
|
||
<span class="portfolio-tag">${getCategoryLabel(project.category)}</span>
|
||
<h4>${project.title}</h4>
|
||
<p>${project.desc.substring(0, 80)}...</p>
|
||
</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() {
|
||
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> ${project.year}</span>${project.duration ? ` <span><i data-feather="clock"></i> ${project.duration}</span>` : ''}`;
|
||
|
||
modalFeatures.innerHTML = project.features.map(f => `<div class="project-feature-item">${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="${icon}"></i>${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="${s.icon || 'star'}"></i></div>
|
||
<div class="project-stat-card-value">${s.value}</div>
|
||
<div class="project-stat-card-label">${s.label}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Gallery : charger les images du dossier via convention de nommage
|
||
// images/projects/{slug}/1.png, 2.png... avec fallback gradient
|
||
const imgPath = project.images || '';
|
||
const imgExtensions = ['png', 'jpg', 'jpeg', 'webp'];
|
||
|
||
// On tente de charger jusqu'à 6 images numérotées depuis le dossier
|
||
const maxImages = 6;
|
||
let slidesHtml = '';
|
||
|
||
// Toujours créer au moins 1 slide gradient (fallback)
|
||
// Les images réelles se chargent avec onerror pour masquer si absentes
|
||
for (let i = 1; i <= maxImages; i++) {
|
||
slidesHtml += `
|
||
<div class="project-gallery-slide" data-img-idx="${i}" style="background: ${project.gradient};">
|
||
<img src="${imgPath}${i}.png" alt="${project.title} ${i}"
|
||
class="gallery-slide-img"
|
||
onerror="this.parentElement.classList.add('no-image')"
|
||
onload="this.parentElement.classList.add('has-image')">
|
||
</div>`;
|
||
}
|
||
gallerySlides.innerHTML = slidesHtml;
|
||
|
||
// reset gallery state
|
||
galleryIndex = 0;
|
||
updateGallery();
|
||
updateGalleryDots();
|
||
|
||
if (modalOverlay) modalOverlay.classList.add('active');
|
||
|
||
if (window.feather && feather.replace) {
|
||
try { feather.replace(); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
function closeProjectModal() {
|
||
if (modalOverlay) modalOverlay.classList.remove('active');
|
||
}
|
||
|
||
// Gallery helpers
|
||
let galleryIndex = 0;
|
||
function getVisibleSlides() {
|
||
if (!gallerySlides) return [];
|
||
// Retourne uniquement les slides qui ont une image chargée OU qui sont la première (fallback gradient)
|
||
const all = Array.from(gallerySlides.children);
|
||
const withImg = all.filter(s => s.classList.contains('has-image'));
|
||
return withImg.length > 0 ? withImg : [all[0]].filter(Boolean);
|
||
}
|
||
|
||
function updateGallery() {
|
||
const visible = getVisibleSlides();
|
||
const all = gallerySlides ? Array.from(gallerySlides.children) : [];
|
||
// Cache toutes les slides non visibles
|
||
all.forEach(s => {
|
||
if (!visible.includes(s)) { s.style.display = 'none'; }
|
||
else { s.style.display = ''; }
|
||
});
|
||
// Positionne les slides visibles
|
||
visible.forEach((s, i) => {
|
||
s.style.transform = `translateX(${(i - galleryIndex) * 100}%)`;
|
||
s.classList.toggle('active', i === galleryIndex);
|
||
});
|
||
// Clamp index
|
||
if (galleryIndex >= visible.length) galleryIndex = Math.max(0, visible.length - 1);
|
||
}
|
||
|
||
function updateGalleryDots() {
|
||
const galleryDots = document.getElementById('gallery-dots');
|
||
if (!galleryDots) return;
|
||
// Attend que les images se chargent avant de créer les dots
|
||
setTimeout(() => {
|
||
const visible = getVisibleSlides();
|
||
galleryDots.innerHTML = visible.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));
|
||
});
|
||
});
|
||
}, 300);
|
||
}
|
||
|
||
function prevSlide() {
|
||
const visible = getVisibleSlides();
|
||
galleryIndex = galleryIndex <= 0 ? visible.length - 1 : galleryIndex - 1;
|
||
updateGallery();
|
||
}
|
||
function nextSlide() {
|
||
const visible = getVisibleSlides();
|
||
galleryIndex = galleryIndex >= visible.length - 1 ? 0 : galleryIndex + 1;
|
||
updateGallery();
|
||
}
|
||
|
||
// Event bindings
|
||
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();
|
||
}));
|
||
|
||
paginationContainer.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-page]');
|
||
if (!btn) return;
|
||
const page = Number(btn.getAttribute('data-page'));
|
||
goToPage(page);
|
||
});
|
||
|
||
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.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="${s.icon || 'star'}"></i></div>
|
||
<div class="bento-stat-value">${s.value}</div>
|
||
<div class="bento-stat-label">${s.label}</div>
|
||
</div>
|
||
`).join('');
|
||
const featuresHtml = (p.features || []).slice(0, 6).map(f => `<span class="bento-feature">${f}</span>`).join('');
|
||
const divider = i < featured.length - 1 ? '<div class="bento-divider"></div>' : '';
|
||
|
||
return `
|
||
<div class="bento-project${isReverse} reveal" data-project-id="${p.id}" style="cursor:pointer;" role="button" tabindex="0" aria-label="Voir le projet ${p.title}">
|
||
<div class="bento-image" style="background: ${p.gradient};">
|
||
<div class="bento-image-content">
|
||
<span class="portfolio-tag">${categoryLabels[p.category] || p.category}</span>
|
||
<h3>${p.title}</h3>
|
||
<p>${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>${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();
|
||
});
|