1/** 2 * project-detail.js â Project Detail Page 3 * 4 * Lifecycle hooks registered in app.js: 5 * onEnter(metrics, data) â called every time project-detail page becomes visible 6 * onLeave() â called when navigating away 7 * 8 * Grid positioning: js/core/grid.js â positionProjectDetail() 9 * 10 * Features: 11 * - Back/Next navigation buttons 12 * - GSAP entrance timeline for detail sections 13 * - Dynamic data rendering from _portfolioData.projects 14 */ 15 16import { navigateTo, getPageData } from '../core/router.js'; 17import { ANIM as SHARED_ANIM } from '../animations/interactions.js'; 18 19const ANIM = { ...SHARED_ANIM }; 20 21var TECH_ICONS = { 22 html: 'fa-brands fa-html5', html5: 'fa-brands fa-html5', css: 'fa-brands fa-css3-alt', css3: 'fa-brands fa-css3-alt', 23 javascript: 'fa-brands fa-js', js: 'fa-brands fa-js', react: 'fa-brands fa-react', 24 'next.js': 'fa-solid fa-layer-group', nextjs: 'fa-solid fa-layer-group', 'node.js': 'fa-brands fa-node-js', nodejs: 'fa-brands fa-node-js', 25 sass: 'fa-brands fa-sass', scss: 'fa-brands fa-sass', git: 'fa-brands fa-git-alt', github: 'fa-brands fa-github', 26 php: 'fa-brands fa-php', python: 'fa-brands fa-python', mysql: 'fa-solid fa-database', 27 mongo: 'fa-solid fa-cube', mongodb: 'fa-solid fa-cube', bootstrap: 'fa-brands fa-bootstrap', 28 tailwind: 'fa-solid fa-wind', figma: 'fa-brands fa-figma', wordpress: 'fa-brands fa-wordpress', 29 typescript: 'fa-solid fa-code', docker: 'fa-brands fa-docker', firebase: 'fa-solid fa-fire', 30 aws: 'fa-solid fa-cloud', graphql: 'fa-solid fa-plug', redux: 'fa-solid fa-sitemap', 31 rest: 'fa-solid fa-server', 'rest api': 'fa-solid fa-server', api: 'fa-solid fa-server', 32 linux: 'fa-solid fa-terminal', stripe: 'fa-brands fa-stripe-s', express: 'fa-brands fa-node-js', 33 django: 'fa-brands fa-python', laravel: 'fa-brands fa-laravel', vue: 'fa-brands fa-vuejs', 'vue.js': 'fa-brands fa-vuejs', 34 postgresql: 'fa-solid fa-database', postgres: 'fa-solid fa-database', twilio: 'fa-brands fa-twilio', 35}; 36 37function getTechIcon(techName) { 38 var key = techName.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim(); 39 return TECH_ICONS[key] || TECH_ICONS[key.replace(/\s+/g, '')] || 'fa-solid fa-code'; 40} 41 42let _currentProject = null; 43let _allImages = []; 44let _activeImgIdx = 0; 45 46 47// âââ Cleanup registry âââââââââââââââââââââââââââââââââââââââââââââ 48let _abortCtrl = null; 49 50function _newSignal() { 51 _abortCtrl = new AbortController(); 52 return _abortCtrl.signal; 53} 54 55function _cleanup() { 56 if (_abortCtrl) { 57 _abortCtrl.abort(); 58 _abortCtrl = null; 59 } 60} 61 62 63// âââ Render Project Detail ââââââââââââââââââââââââââââââââââââ 64function renderProjectDetail(project) { 65 if (!project) return; 66 _currentProject = project; 67 68 // Title 69 var titleEl = document.querySelector('#projects-title-display span'); 70 if (titleEl) titleEl.textContent = project.name || 'Untitled Project'; 71 72 // Overview 73 var overviewDesc = document.querySelector('#projects-overview .overview-desc'); 74 if (overviewDesc) overviewDesc.textContent = project.desc || ''; 75 76 // Overview URL 77 var overviewUrl = document.querySelector('#projects-overview .overview-url'); 78 if (overviewUrl) { 79 var liveUrl = project.projectUrl || project.docsUrl || ''; 80 if (liveUrl) { 81 if (!/^https?:\/\//i.test(liveUrl)) liveUrl = 'https://' + liveUrl; 82 overviewUrl.href = liveUrl; 83 overviewUrl.textContent = liveUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); 84 overviewUrl.style.display = ''; 85 } else { 86 overviewUrl.style.display = 'none'; 87 } 88 } 89 90 // Content top â type, role, duration 91 var cprojRows = document.querySelectorAll('#projects-content-top .cproj-row'); 92 if (cprojRows[0]) { 93 var val = cprojRows[0].querySelector('.cproj-value'); 94 if (val) val.textContent = project.type || ''; 95 } 96 if (cprojRows[1]) { 97 var val = cprojRows[1].querySelector('.cproj-value'); 98 if (val) val.textContent = project.role || ''; 99 } 100 if (cprojRows[2]) { 101 var val = cprojRows[2].querySelector('.cproj-value'); 102 if (val) val.textContent = project.duration || ''; 103 } 104 105 // Featured image 106 var featuredImg = document.querySelector('#projects-featured-img img'); 107 _allImages = []; 108 _activeImgIdx = 0; 109 110 if (project.images) { 111 if (project.images.thumb) _allImages.push(project.images.thumb); 112 if (project.images.fullui && project.images.fullui !== project.images.thumb) _allImages.push(project.images.fullui); 113 if (project.images.hero && project.images.hero !== project.images.thumb) _allImages.push(project.images.hero); 114 if (project.images.img2) _allImages.push(project.images.img2); 115 if (project.images.img3) _allImages.push(project.images.img3); 116 } 117 if (_allImages.length === 0 && project.image) { 118 _allImages.push(project.image); 119 } 120 121 if (featuredImg && _allImages.length > 0) { 122 featuredImg.src = _allImages[0];
123 featuredImg.alt = (project.name || 'Project') + ' screenshot'; 124 } 125 126 // Side thumbnails (img2, img3) 127 var thumbA = document.querySelector('#projects-thumb-a img'); 128 var thumbC = document.querySelector('#projects-thumb-c img'); 129 var thumbAContainer = document.getElementById('projects-thumb-a'); 130 var thumbCContainer = document.getElementById('projects-thumb-c'); 131 132 if (project.images) { 133 var img2Url = project.images.img2 || ''; 134 var img3Url = project.images.img3 || ''; 135 if (thumbA) { 136 if (img2Url) { 137 thumbA.src = img2Url; 138 thumbA.alt = (project.name || 'Project') + ' screenshot 2'; 139 if (thumbAContainer) thumbAContainer.style.display = ''; 140 } else if (thumbAContainer) { 141 thumbAContainer.style.display = 'none'; 142 } 143 } 144 if (thumbC) { 145 if (img3Url) { 146 thumbC.src = img3Url; 147 thumbC.alt = (project.name || 'Project') + ' screenshot 3'; 148 if (thumbCContainer) thumbCContainer.style.display = ''; 149 } else if (thumbCContainer) { 150 thumbCContainer.style.display = 'none'; 151 } 152 } 153 } 154 155 // Click thumbnails to swap main image with thumbnail 156 function swapWithThumb(thumbImgEl, newIdx) { 157 if (newIdx < 0 || newIdx >= _allImages.length) return; 158 159 var mainImg = document.querySelector('#projects-featured-img img'); 160 if (!mainImg) return; 161 162 if (!thumbImgEl) { 163 var hasA = thumbA && thumbAContainer && thumbAContainer.style.display !== 'none'; 164 var hasC = thumbC && thumbCContainer && thumbCContainer.style.display !== 'none'; 165 thumbImgEl = hasA ? thumbA : hasC ? thumbC : null; 166 } 167 168 if (!thumbImgEl) return; 169 170 // Use thumbnail's ACTUAL current src (not original array) 171 var thumbCurrentSrc = thumbImgEl.src; 172 if (mainImg.src === thumbCurrentSrc) return; 173 174 var oldCenterSrc = mainImg.src; 175 176 if (!window.gsap) { 177 mainImg.src = thumbCurrentSrc; 178 thumbImgEl.src = oldCenterSrc; 179 return; 180 } 181 182 gsap.to([mainImg, thumbImgEl], { opacity: 0, scale: 0.95, duration: 0.2, ease: 'power2.in', 183 onComplete: function () { 184 mainImg.src = thumbCurrentSrc; 185 thumbImgEl.src = oldCenterSrc; 186 var show = function () { gsap.to([mainImg, thumbImgEl], { opacity: 1, scale: 1, duration: 0.3, ease: 'power2.out' }); }; 187 if (mainImg.complete && thumbImgEl.complete) show(); 188 else { var n = 0; var ck = function () { n++; if (n >= 2) show(); }; 189 mainImg.complete ? ck() : (mainImg.onload = ck); 190 thumbImgEl.complete ? ck() : (thumbImgEl.onload = ck); } 191 } 192 }); 193 } 194 195 if (thumbAContainer) { 196 thumbAContainer.style.cursor = 'pointer'; 197 thumbAContainer.onclick = function () { swapWithThumb(thumbA, _allImages.indexOf(project.images.img2)); }; 198 } 199 if (thumbCContainer) { 200 thumbCContainer.style.cursor = 'pointer'; 201 thumbCContainer.onclick = function () { swapWithThumb(thumbC, _allImages.indexOf(project.images.img3)); }; 202 } 203 204 // Click main image â cycle to next (simple swap, no thumb update) 205 var featuredContainer = document.getElementById('projects-featured-img'); 206 if (featuredContainer) { 207 featuredContainer.style.cursor = 'default'; 208 featuredContainer.onclick = null; 209 210 // Add magnify button 211 var existingBtn = featuredContainer.querySelector('.magnify-btn'); 212 if (existingBtn) existingBtn.remove(); 213 var magnifyBtn = document.createElement('button'); 214 magnifyBtn.className = 'magnify-btn'; 215 magnifyBtn.setAttribute('aria-label', 'View full image'); 216 magnifyBtn.innerHTML = '<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>'; 217 featuredContainer.appendChild(magnifyBtn); 218 219 magnifyBtn.addEventListener('click', function (e) { 220 e.stopPropagation(); 221 var img = featuredContainer.querySelector('img'); 222 if (!img || !img.src) return; 223 openLightbox(img.src, img.alt); 224 }); 225 } 226 227 // Full Web UI side panel 228 var sidePanel = document.getElementById('projects-side-panel'); 229 if (sidePanel) { 230 var fulluiUrl = (project.images && project.images.fullui) || ''; 231 if (fulluiUrl) { 232 sidePanel.innerHTML = '<img src="' + fulluiUrl + '" alt="' + (project.
232name || 'Project') + ' â Full Web UI" loading="lazy" decoding="async">'; 233 } 234 } 235 236 // Problem 237 var problemBody = document.querySelector('#projects-problem .pdetail-body'); 238 if (problemBody) problemBody.textContent = project.problem || ''; 239 240 // Solution 241 var solutionBody = document.querySelector('#projects-solution .pdetail-body'); 242 if (solutionBody) solutionBody.textContent = project.solution || ''; 243 244 // Key features 245 if (project.features && project.features.length > 0) { 246 var featuresGrid = document.querySelector('#projects-key-features .pkf-grid'); 247 if (featuresGrid) { 248 var html = ''; 249 project.features.forEach(function (f) { 250 html += '<div class="pkf-card" role="listitem">' + 251 '<i class="' + (f.icon || 'fa-solid fa-check') + '" aria-hidden="true"></i>' + 252 '<div class="pkf-card-text">' + 253 '<span class="pkf-card-name">' + (f.name || '') + '</span>' + 254 '<span class="pkf-card-desc">' + (f.desc || '') + '</span>' + 255 '</div></div>'; 256 }); 257 featuresGrid.innerHTML = html; 258 } 259 } 260 261 // Tech stack 262 if (project.tech) { 263 var techs = project.tech.split(',').map(function (t) { return t.trim(); }).filter(function (t) { return t; }); 264 var techList = document.querySelector('#projects-tech-stack .ptech-list'); 265 if (techList && techs.length > 0) { 266 var html = ''; 267 techs.forEach(function (t) { 268 var icon = getTechIcon(t); 269 var isFa = icon.startsWith('fa-'); 270 var iconHtml = isFa 271 ? '<i class="' + icon + '" aria-hidden="true"></i>' 272 : '<img src="' + icon + '" alt="' + t + '">'; 273 html += '<div class="ptech-item">' + 274 '<div class="ptech-icon">' + iconHtml + '</div>' + 275 '<span class="ptech-name">' + t + '</span></div>'; 276 }); 277 techList.innerHTML = html; 278 } 279 } 280 281 // Documentation URL 282 var docsContainer = document.getElementById('projects-view-docs'); 283 var docsBtn = document.getElementById('projects-view-docs-btn'); 284 if (docsBtn) { 285 if (project.docsUrl) { 286 if (docsContainer) docsContainer.style.display = ''; 287 docsBtn.onclick = function () { window.open(project.docsUrl, '_blank'); }; 288 } else { 289 if (docsContainer) docsContainer.style.display = 'none'; 290 } 291 } 292} 293 294// âââ Detail Page Lifecycle ââââââââââââââââââââââââââââââââââââââ 295function onEnter(metrics, data) { 296 const signal = _newSignal(); 297 298 function loadAndRender() { 299 var projectId = data && data.projectId; 300 if (projectId && window._portfolioData && window._portfolioData.projects) { 301 var project = window._portfolioData.projects.find(function (p) { return p.id === projectId; }); 302 if (project) renderProjectDetail(project); 303 } 304 } 305 306 loadAndRender(); 307 308 // Re-render when fresh data arrives from JSONBin 309 window.addEventListener('portfolioDataReady', loadAndRender, { signal }); 310 311 const backBtn = document.getElementById('projects-back-btn'); 312 if (backBtn) { 313 backBtn.addEventListener('click', () => { 314 navigateTo('projects'); 315 }, { signal, once: true }); 316 } 317 318 const nextBtn = document.getElementById('projects-next-btn'); 319 if (nextBtn) { 320 nextBtn.addEventListener('click', () => { 321 // Navigate to next project 322 if (window._portfolioData && window._portfolioData.projects && _currentProject) { 323 var projects = window._portfolioData.projects; 324 var currentIdx = projects.findIndex(function (p) { return p.id === _currentProject.id; }); 325 var nextIdx = (currentIdx + 1) % projects.length; 326 navigateTo('project-detail', { projectId: projects[nextIdx].id }); 327 } 328 }, { signal, once: true }); 329 } 330 331 if (window.gsap) { 332 const ease = ANIM.hover.ease.in; 333 const tl = gsap.timeline({ delay: 0.05 }); 334 tl.from('#projects-overview', { opacity: 0, x: -20, duration: 0.4, ease }) 335 .from('#projects-featured-img', { opacity: 0, scale: 0.97, duration: 0.45, ease }, '-=0.3') 336 .from('#projects-content-top', { opacity: 0, x: 20, duration: 0.35, ease }, '-=0.35') 337 .from('#projects-key-features', { opacity: 0, y: 15, duration: 0.35, ease }, '-=0.2') 338 .from('#projects-tech-stack', { opacity: 0, y: 15, duration: 0.35, ease }, '-=0.2') 339 .from('#projects-problem', { opacity: 0, y: 15, duration: 0.35, ease }, '-=0.2') 340 .from('#projects-solution', { opacity: 0, y: 15, duration: 0.35, ease }, '-=0.2') 341 .from(['#projects-next', '#projects-back'], { opacity: 0, x: 20, stagger: 0.08, duration: 0.3, ease }, '-=0.15'); 342 } 343} 344 345function onLeave() { 346 _cleanup(); 347 if (window.gsap) { 348 gsap.killTweensOf([ 349 '#projects-overview', '#projects-featured-img', '#projects-content-top', 350 '#projects-key-features', '#projects-tech-stack', '#projects-problem', 351 '#projects-solution', '#projects-next', '#projects-back', 352 ]); 353 } 354} 355 356// âââ Lightbox Modal ââââââââââââââââââââââââââââââââââââââââââââ 357function openLightbox(src, alt) {
358 var overlay = document.createElement('div'); 359 overlay.id = 'project-lightbox'; 360 overlay.innerHTML = 361 '<div class="lightbox-backdrop"></div>' + 362 '<div class="lightbox-content">' + 363 '<button class="lightbox-close" aria-label="Close"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>' + 364 '<img src="' + src + '" alt="' + (alt || 'Full image') + '">' + 365 '</div>'; 366 document.body.appendChild(overlay); 367 368 requestAnimationFrame(function () { overlay.classList.add('lightbox--active'); }); 369 370 function closeLightbox() { 371 overlay.classList.remove('lightbox--active'); 372 setTimeout(function () { overlay.remove(); }, 300); 373 } 374 375 overlay.querySelector('.lightbox-close').addEventListener('click', closeLightbox); 376 overlay.querySelector('.lightbox-backdrop').addEventListener('click', closeLightbox); 377 document.addEventListener('keydown', function handler(e) { 378 if (e.key === 'Escape') { closeLightbox(); document.removeEventListener('keydown', handler); } 379 }); 380} 381 382export const projectDetailHooks = { onEnter, onLeave };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.