2026-04-05 20:49:52 +02:00
// Page script for /pages/realisations.html et index.html
2026-02-06 18:36:03 +01:00
document . addEventListener ( 'DOMContentLoaded' , async ( ) => {
2026-04-05 20:49:52 +02:00
// 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' ;
2026-02-06 18:36:03 +01:00
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};" > < / d i v >
< div class = "portfolio-content" >
< span class = "portfolio-tag" > $ { getCategoryLabel ( project . category ) } < / s p a n >
< h4 > $ { project . title } < / h 4 >
< p > $ { project . desc . substring ( 0 , 80 ) } ... < / p >
< / d i v >
< / d i v >
` ).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' } ) ;
}
2026-04-05 20:49:52 +02:00
// ── 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 ) ) ;
}
2026-02-06 18:36:03 +01:00
function openProjectModal ( projectId ) {
const project = allProjects . find ( p => p . id === Number ( projectId ) ) ;
if ( ! project ) return ;
2026-04-05 20:49:52 +02:00
resetModalTabs ( ) ;
2026-02-06 18:36:03 +01:00
modalCategory . textContent = getCategoryLabel ( project . category ) ;
modalTitle . textContent = project . title ;
modalDesc . textContent = project . desc ;
2026-04-05 20:49:52 +02:00
modalMeta . innerHTML = ` <span><i data-feather="calendar"></i> ${ project . year } </span> ${ project . duration ? ` <span><i data-feather="clock"></i> ${ project . duration } </span> ` : '' } ` ;
2026-02-06 18:36:03 +01:00
modalFeatures . innerHTML = project . features . map ( f => ` <div class="project-feature-item"> ${ f } </div> ` ) . join ( '' ) ;
2026-04-05 20:49:52 +02:00
// 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
2026-02-06 18:36:03 +01:00
modalStats . innerHTML = project . stats . map ( s => `
< div class = "project-stat-card" >
2026-04-05 20:49:52 +02:00
< div class = "project-stat-card-icon" > < i data - feather = "${s.icon || 'star'}" > < / i > < / d i v >
2026-02-06 18:36:03 +01:00
< div class = "project-stat-card-value" > $ { s . value } < / d i v >
< div class = "project-stat-card-label" > $ { s . label } < / d i v >
< / d i v >
` ).join('');
2026-04-05 20:49:52 +02:00
// 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')" >
< / d i v > ` ;
}
gallerySlides . innerHTML = slidesHtml ;
2026-02-06 18:36:03 +01:00
// reset gallery state
galleryIndex = 0 ;
updateGallery ( ) ;
2026-04-05 20:49:52 +02:00
updateGalleryDots ( ) ;
2026-02-06 18:36:03 +01:00
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 ;
2026-04-05 20:49:52 +02:00
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 ) ;
}
2026-02-06 18:36:03 +01:00
function updateGallery ( ) {
2026-04-05 20:49:52 +02:00
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 ) => {
2026-02-06 18:36:03 +01:00
s . style . transform = ` translateX( ${ ( i - galleryIndex ) * 100 } %) ` ;
s . classList . toggle ( 'active' , i === galleryIndex ) ;
} ) ;
2026-04-05 20:49:52 +02:00
// Clamp index
if ( galleryIndex >= visible . length ) galleryIndex = Math . max ( 0 , visible . length - 1 ) ;
2026-02-06 18:36:03 +01:00
}
2026-04-05 20:49:52 +02:00
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 ( ) ;
}
2026-02-06 18:36:03 +01:00
// 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 ;
2026-04-05 20:49:52 +02:00
// ── 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 > < / d i v >
< div class = "bento-stat-value" > $ { s . value } < / d i v >
< div class = "bento-stat-label" > $ { s . label } < / d i v >
< / d i v >
` ).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 } < / s p a n >
< h3 > $ { p . title } < / h 3 >
< p > $ { p . desc . substring ( 0 , 100 ) } $ { p . desc . length > 100 ? '…' : '' } < / p >
< / d i v >
< / d i v >
< div class = "bento-stats" > $ { statsHtml } < / d i v >
< div class = "bento-info" >
< h4 > Description < / h 4 >
< p > $ { p . desc } < / p >
< div class = "bento-features" > $ { featuresHtml } < / d i v >
< / d i v >
< / d i v >
$ { 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 ) ;
}
2026-02-06 18:36:03 +01:00
// Initial render
renderProjects ( ) ;
renderPagination ( ) ;
} ) ;