PageSourceSearch

https://saad-qadri.netlify.app/js/pages/project-detail.js

js saad-qadri.netlify.app collected 2026-10-03 11:21:48 UTC 15,371 bytes, 382 lines download raw bytes

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.