/* ─── Custom cursor ───────────────────────────────────────────────────── */ (function () { const ring = document.getElementById('cursor'); const dot = document.getElementById('cursor-dot'); if (!ring || !dot) return; let mx = 0, my = 0, rx = 0, ry = 0; document.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; }); document.addEventListener('mouseenter', () => { ring.style.opacity = '1'; dot.style.opacity = '1'; }); document.addEventListener('mouseleave', () => { ring.style.opacity = '0'; dot.style.opacity = '0'; }); (function raf() { rx += (mx - rx) * 0.12; ry += (my - ry) * 0.12; ring.style.left = rx + 'px'; ring.style.top = ry + 'px'; dot.style.left = mx + 'px'; dot.style.top = my + 'px'; requestAnimationFrame(raf); })(); document.querySelectorAll('a,button,label,input,textarea,select').forEach(el => { el.addEventListener('mouseenter', () => ring.style.transform = 'translate(-50%,-50%) scale(1.6)'); el.addEventListener('mouseleave', () => ring.style.transform = 'translate(-50%,-50%) scale(1)'); }); })(); /* ─── Navbar scroll shadow ────────────────────────────────────────────── */ (function () { const nav = document.getElementById('navbar'); if (!nav) return; const update = () => nav.classList.toggle('scrolled', window.scrollY > 30); window.addEventListener('scroll', update, { passive: true }); update(); })(); /* ─── Mobile menu toggle ──────────────────────────────────────────────── */ (function () { const btn = document.getElementById('menu-btn'); const menu = document.getElementById('mobile-menu'); if (!btn || !menu) return; btn.addEventListener('click', () => { const open = menu.classList.toggle('hidden'); btn.setAttribute('aria-expanded', String(!open)); }); menu.querySelectorAll('a').forEach(a => a.addEventListener('click', () => menu.classList.add('hidden'))); })(); /* ─── Scroll-reveal (Intersection Observer) ───────────────────────────── */ (function () { const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); } }); }, { threshold: 0.12 }); document.querySelectorAll('.reveal').forEach(el => io.observe(el)); })(); /* ─── Particle canvas (hero background) ──────────────────────────────── */ (function () { const canvas = document.getElementById('particle-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); let W, H, particles = [], animId; function resize() { W = canvas.width = canvas.offsetWidth; H = canvas.height = canvas.offsetHeight; } function makeParticle() { return { x: Math.random() * W, y: Math.random() * H, vx: (Math.random() - 0.5) * 0.3, vy: (Math.random() - 0.5) * 0.3, r: Math.random() * 1.5 + 0.5, a: Math.random() * 0.5 + 0.1 }; } function init() { particles = Array.from({ length: 90 }, makeParticle); } function draw() { ctx.clearRect(0, 0, W, H); const MAX_DIST = 120; for (let i = 0; i < particles.length; i++) { const p = particles[i]; p.x += p.vx; p.y += p.vy; if (p.x < 0) p.x = W; if (p.x > W) p.x = 0; if (p.y < 0) p.y = H; if (p.y > H) p.y = 0; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fillStyle = `rgba(56,189,248,${p.a})`; ctx.fill(); for (let j = i + 1; j < particles.length; j++) { const q = particles[j]; const dx = p.x - q.x, dy = p.y - q.y; const d = Math.sqrt(dx * dx + dy * dy); if (d < MAX_DIST) { ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.strokeStyle = `rgba(56,189,248,${(1 - d / MAX_DIST) * 0.15})`; ctx.lineWidth = 0.5; ctx.stroke(); } } } animId = requestAnimationFrame(draw); } resize(); init(); draw(); window.addEventListener('resize', () => { resize(); }); // Pause when hidden document.addEventListener('visibilitychange', () => { if (document.hidden) { cancelAnimationFrame(animId); } else { draw(); } }); })(); /* ─── Typewriter ──────────────────────────────────────────────────────── */ (function () { const el = document.getElementById('typewriter'); if (!el) return; const words = JSON.parse(el.dataset.words || '[]'); if (!words.length) return; let wi = 0, ci = 0, deleting = false; function tick() { const word = words[wi]; if (!deleting) { el.textContent = word.slice(0, ++ci); if (ci === word.length) { setTimeout(tick, 1800); deleting = true; return; } setTimeout(tick, 80); } else { el.textContent = word.slice(0, --ci); if (ci === 0) { deleting = false; wi = (wi + 1) % words.length; setTimeout(tick, 300); return; } setTimeout(tick, 40); } } tick(); })(); /* ─── Animated counters ───────────────────────────────────────────────── */ (function () { const io = new IntersectionObserver(entries => { entries.forEach(e => { if (!e.isIntersecting) return; const el = e.target; const target = parseInt(el.dataset.target || '0', 10); const duration = 1200; const start = performance.now(); function step(now) { const t = Math.min((now - start) / duration, 1); const ease = 1 - Math.pow(1 - t, 3); el.textContent = el.dataset.prefix + Math.round(target * ease) + (el.dataset.suffix || ''); if (t < 1) requestAnimationFrame(step); } requestAnimationFrame(step); io.unobserve(el); }); }, { threshold: 0.5 }); document.querySelectorAll('.counter').forEach(el => io.observe(el)); })(); /* ─── Expertise bars (animate on scroll) ─────────────────────────────── */ (function () { const io = new IntersectionObserver(entries => { entries.forEach(e => { if (!e.isIntersecting) return; const fill = e.target.querySelector('.bar-fill'); if (fill) { fill.style.width = fill.dataset.w; } io.unobserve(e.target); }); }, { threshold: 0.3 }); document.querySelectorAll('.bar-track').forEach(el => io.observe(el)); })(); /* ─── Portfolio modal ─────────────────────────────────────────────────── */ (function () { const modal = document.getElementById('portfolio-modal'); const overlay = document.getElementById('modal-overlay'); if (!modal) return; let images = [], idx = 0; const imgEl = document.getElementById('modal-img'); const titleEl = document.getElementById('modal-title'); const bodyEl = document.getElementById('modal-body'); const prevBtn = document.getElementById('modal-prev'); const nextBtn = document.getElementById('modal-next'); const closeBtn = document.getElementById('modal-close'); function showModal(card) { images = JSON.parse(card.dataset.gallery || '[]'); idx = 0; if (titleEl) titleEl.textContent = card.dataset.title || ''; if (bodyEl) bodyEl.innerHTML = card.dataset.summary || ''; updateImg(); modal.classList.remove('hidden'); modal.style.opacity = '0'; requestAnimationFrame(() => { modal.style.opacity = '1'; }); document.body.style.overflow = 'hidden'; } function hideModal() { modal.style.opacity = '0'; setTimeout(() => { modal.classList.add('hidden'); document.body.style.overflow = ''; }, 250); } function updateImg() { if (!imgEl) return; imgEl.src = images[idx] || ''; if (prevBtn) prevBtn.disabled = idx === 0; if (nextBtn) nextBtn.disabled = idx === images.length - 1; } document.querySelectorAll('[data-portfolio-card]').forEach(card => { card.addEventListener('click', () => showModal(card)); card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showModal(card); } }); }); if (closeBtn) closeBtn.addEventListener('click', hideModal); if (overlay) overlay.addEventListener('click', hideModal); if (prevBtn) prevBtn.addEventListener('click', () => { if (idx > 0) { idx--; updateImg(); } }); if (nextBtn) nextBtn.addEventListener('click', () => { if (idx < images.length - 1) { idx++; updateImg(); } }); document.addEventListener('keydown', e => { if (modal.classList.contains('hidden')) return; if (e.key === 'Escape') hideModal(); if (e.key === 'ArrowLeft') { if (idx > 0) { idx--; updateImg(); } } if (e.key === 'ArrowRight') { if (idx < images.length - 1) { idx++; updateImg(); } } }); })(); /* ─── Contact form (AJAX) ─────────────────────────────────────────────── */ (function () { const form = document.getElementById('contact-form'); if (!form) return; const status = document.getElementById('contact-status'); form.addEventListener('submit', async e => { e.preventDefault(); const data = Object.fromEntries(new FormData(form)); const btn = form.querySelector('[type="submit"]'); btn.disabled = true; try { const res = await fetch('/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (res.ok) { if (status) { status.textContent = form.dataset.successMsg || 'Sent!'; status.className = 'mt-3 text-sm text-emerald-400'; } form.reset(); } else { if (status) { status.textContent = form.dataset.errorMsg || 'Something went wrong.'; status.className = 'mt-3 text-sm text-red-400'; } } } catch { if (status) { status.textContent = form.dataset.errorMsg || 'Network error.'; status.className = 'mt-3 text-sm text-red-400'; } } finally { btn.disabled = false; } }); })();