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 ( ) {
2026-04-07 01:51:50 +02:00
if ( ! portfolioGrid ) return ; // pas sur la page réalisations
2026-02-06 18:36:03 +01:00
const filtered = getFilteredProjects ( ) ;
const projects = getProjectsForPage ( filtered , currentPage ) ;
2026-04-07 01:51:50 +02:00
portfolioGrid . innerHTML = projects . map ( project => {
const prefix = isRoot ? '' : '../' ;
const firstFile = Array . isArray ( project . files ) && project . files . length > 0 ? project . files [ 0 ] : null ;
const coverImg = firstFile ? ` <img src=" ${ prefix } ${ project . images } ${ firstFile } " alt=" ${ project . title } " class="portfolio-cover-img" onerror="this.style.display='none'"> ` : '' ;
return `
< div class = "portfolio-item reveal" data - project - id = "${project.id}" >
< div class = "portfolio-bg" style = "background: ${project.gradient};" > $ { coverImg } < / d i v >
< div class = "portfolio-content" >
< span class = "portfolio-tag" > $ { getCategoryLabel ( project . category ) } < / s p a n >
< h4 > $ { project . title } < / h 4 >
< / d i v >
2026-02-06 18:36:03 +01:00
< / d i v >
2026-04-07 01:51:50 +02:00
` ;
} ) . join ( '' ) ;
2026-02-06 18:36:03 +01:00
// 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 ( ) {
2026-04-07 01:51:50 +02:00
if ( ! paginationContainer ) return ; // pas sur la page réalisations
2026-02-06 18:36:03 +01:00
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-07 01:51:50 +02:00
// 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:${project.gradient};" >
< div class = "yt-wrapper" >
< iframe
src = "https://www.youtube.com/embed/${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 ${project.title}"
loading = "lazy" >
< / i f r a m e >
< / d i v >
< / d i v > ` ;
}
if ( isLocalVideo ) {
return `
< div class = "project-gallery-slide slide-right" data - img - idx = "${i}" style = "background:${project.gradient};" >
< video class = "gallery-slide-img gallery-video" src = "${imgPath}${filename}" muted loop autoplay playsinline > < / v i d e o >
< 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" / > < / s v g >
< 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" / > < / s v g >
< / b u t t o n >
< / d i v > ` ;
}
return `
< div class = "project-gallery-slide slide-right" data - img - idx = "${i}" style = "background:${project.gradient};" >
< img src = "${imgPath}${filename}" alt = "${project.title} ${i + 1}"
class = "gallery-slide-img"
onerror = "this.parentElement.classList.add('no-image');this.style.display='none'" >
< / d i v > ` ;
} ) . join ( '' ) ;
} else {
// Fallback : une slide gradient unique
gallerySlides . innerHTML = `
< div class = "project-gallery-slide has-image" style = "background:${project.gradient};" > < / d i v > ` ;
2026-04-05 20:49:52 +02:00
}
2026-02-06 18:36:03 +01:00
2026-04-07 01:51:50 +02:00
// reset gallery + affichage immédiat
2026-02-06 18:36:03 +01:00
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' ) ;
2026-04-07 01:51:50 +02:00
// Réseaux sociaux du projet
const projectSocialEl = document . getElementById ( 'modal-project-social' ) ;
if ( projectSocialEl ) {
projectSocialEl . innerHTML = typeof window . renderSocialLinks === 'function'
? window . renderSocialLinks ( project . social )
: '' ;
}
2026-02-06 18:36:03 +01:00
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-07 01:51:50 +02:00
function getAllSlides ( ) {
2026-04-05 20:49:52 +02:00
if ( ! gallerySlides ) return [ ] ;
2026-04-07 01:51:50 +02:00
return Array . from ( gallerySlides . children ) . filter ( s => ! s . classList . contains ( 'no-image' ) ) ;
2026-04-05 20:49:52 +02:00
}
2026-02-06 18:36:03 +01:00
function updateGallery ( ) {
2026-04-07 01:51:50 +02:00
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
2026-02-06 18:36:03 +01:00
s . classList . toggle ( 'active' , i === galleryIndex ) ;
2026-04-07 01:51:50 +02:00
// 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' ) ;
}
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 ;
2026-04-07 01:51:50 +02:00
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 ) ) ;
2026-04-05 20:49:52 +02:00
} ) ;
2026-04-07 01:51:50 +02:00
} ) ;
}
function syncDots ( ) {
const galleryDots = document . getElementById ( 'gallery-dots' ) ;
if ( ! galleryDots ) return ;
galleryDots . querySelectorAll ( '.gallery-dot' ) . forEach ( ( d , i ) =>
d . classList . toggle ( 'active' , i === galleryIndex ) ) ;
2026-04-05 20:49:52 +02:00
}
function prevSlide ( ) {
2026-04-07 01:51:50 +02:00
galleryIndex = galleryIndex <= 0 ? getAllSlides ( ) . length - 1 : galleryIndex - 1 ;
2026-04-05 20:49:52 +02:00
updateGallery ( ) ;
2026-04-07 01:51:50 +02:00
syncDots ( ) ;
2026-04-05 20:49:52 +02:00
}
function nextSlide ( ) {
2026-04-07 01:51:50 +02:00
galleryIndex = galleryIndex >= getAllSlides ( ) . length - 1 ? 0 : galleryIndex + 1 ;
2026-04-05 20:49:52 +02:00
updateGallery ( ) ;
2026-04-07 01:51:50 +02:00
syncDots ( ) ;
2026-04-05 20:49:52 +02:00
}
2026-02-06 18:36:03 +01:00
2026-04-07 01:51:50 +02:00
// 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 ( ) ;
} ) ) ;
}
2026-02-06 18:36:03 +01:00
2026-04-07 01:51:50 +02:00
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 ) ;
} ) ;
}
2026-02-06 18:36:03 +01:00
2026-04-07 01:51:50 +02:00
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 ) ;
} ) ;
}
2026-02-06 18:36:03 +01:00
if ( modalClose ) modalClose . addEventListener ( 'click' , closeProjectModal ) ;
if ( modalOverlay ) modalOverlay . addEventListener ( 'click' , ( e ) => { if ( e . target === modalOverlay ) closeProjectModal ( ) ; } ) ;
2026-04-07 01:51:50 +02:00
window . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Escape' && modalOverlay && modalOverlay . classList . contains ( 'active' ) ) closeProjectModal ( ) ; } ) ;
2026-02-06 18:36:03 +01:00
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>' : '' ;
2026-04-07 01:51:50 +02:00
const prefix = isRoot ? '' : '../' ;
const firstFile = Array . isArray ( p . files ) && p . files . length > 0 ? p . files [ 0 ] : null ;
const bentoCover = firstFile
? ` <img src=" ${ prefix } ${ p . images } ${ firstFile } " alt=" ${ p . title } " class="bento-cover-img" onerror="this.style.display='none'"> `
: '' ;
2026-04-05 20:49:52 +02:00
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};" >
2026-04-07 01:51:50 +02:00
$ { bentoCover }
2026-04-05 20:49:52 +02:00
< 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 ( ) ;
} ) ;