feat: landing page inicial para devlab.lat

- Hero con animación de partículas y efecto typewriter
- Sección de servicios con animación scroll-reveal
- Estadísticas animadas con contadores progresivos
- Galería de proyectos con overlay hover
- Sección de contacto con enlaces
- Footer completo
- Diseño responsive mobile-first
- Soporte prefers-reduced-motion
This commit is contained in:
DevLab Bot
2026-06-11 17:55:13 -04:00
commit 9f708cc983
4 changed files with 1381 additions and 0 deletions

311
main.js Normal file
View File

@@ -0,0 +1,311 @@
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ─── Particles ───────────────────────────────────────────
if (!prefersReducedMotion) {
const canvas = document.getElementById('particles-canvas');
const ctx = canvas.getContext('2d');
let particles = [];
let animId;
let mouseX = -1000;
let mouseY = -1000;
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
function createParticles(count) {
particles = [];
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.8,
vy: (Math.random() - 0.5) * 0.8,
size: Math.random() * 2.5 + 0.5,
opacity: Math.random() * 0.5 + 0.2,
});
}
}
function drawParticles() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
const dx = p.x - mouseX;
const dy = p.y - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
p.vx += dx * 0.0005;
p.vy += dy * 0.0005;
const maxSpeed = 2;
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (speed > maxSpeed) {
p.vx = (p.vx / speed) * maxSpeed;
p.vy = (p.vy / speed) * maxSpeed;
}
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(124, 58, 237, ${p.opacity})`;
ctx.fill();
}
// draw connections
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 150) {
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.strokeStyle = `rgba(6, 182, 212, ${0.12 * (1 - dist / 150)})`;
ctx.lineWidth = 0.8;
ctx.stroke();
}
}
}
animId = requestAnimationFrame(drawParticles);
}
function initParticles() {
resizeCanvas();
const count = Math.min(Math.floor((canvas.width * canvas.height) / 12000), 120);
createParticles(count);
drawParticles();
}
window.addEventListener('resize', () => {
resizeCanvas();
createParticles(Math.min(Math.floor((canvas.width * canvas.height) / 12000), 120));
});
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
});
document.addEventListener('touchmove', (e) => {
const touch = e.touches[0];
mouseX = touch.clientX;
mouseY = touch.clientY;
});
initParticles();
}
// ─── Typewriter ─────────────────────────────────────────
const typewriterEl = document.querySelector('.typewriter');
if (typewriterEl) {
const text = typewriterEl.dataset.text || '';
const speed = 60;
let charIndex = 0;
let isDeleting = false;
const cursor = document.createElement('span');
cursor.className = 'cursor';
cursor.setAttribute('aria-hidden', 'true');
typewriterEl.appendChild(cursor);
// clear initial content
typewriterEl.childNodes.forEach((node, i) => {
if (node.nodeType === Node.TEXT_NODE) node.textContent = '';
});
function typeStep() {
if (!isDeleting) {
if (charIndex < text.length) {
typewriterEl.textContent = text.slice(0, charIndex + 1);
typewriterEl.appendChild(cursor);
charIndex++;
setTimeout(typeStep, speed);
} else {
if (!prefersReducedMotion) {
setTimeout(() => { isDeleting = true; typeStep(); }, 3000);
}
}
} else {
if (charIndex > 0) {
charIndex--;
typewriterEl.textContent = text.slice(0, charIndex);
typewriterEl.appendChild(cursor);
setTimeout(typeStep, speed * 0.6);
} else {
isDeleting = false;
setTimeout(typeStep, 1000);
}
}
}
typeStep();
}
// ─── Navbar scroll effect ───────────────────────────────
const navbar = document.querySelector('.navbar');
let lastScroll = 0;
function handleNavScroll() {
const currentScroll = window.scrollY;
if (currentScroll > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
lastScroll = currentScroll;
}
window.addEventListener('scroll', handleNavScroll, { passive: true });
// ─── Hamburger menu ─────────────────────────────────────
const hamburger = document.querySelector('.hamburger');
const navLinks = document.querySelector('.nav-links');
function toggleMenu() {
const isOpen = navLinks.classList.toggle('open');
hamburger.classList.toggle('active');
hamburger.setAttribute('aria-expanded', isOpen);
document.body.style.overflow = isOpen ? 'hidden' : '';
}
if (hamburger) {
hamburger.addEventListener('click', toggleMenu);
}
// close menu on link click
document.querySelectorAll('.nav-links a').forEach(link => {
link.addEventListener('click', () => {
if (navLinks.classList.contains('open')) {
toggleMenu();
}
});
});
// close menu on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && navLinks.classList.contains('open')) {
toggleMenu();
}
});
// ─── Active nav link ────────────────────────────────────
const sections = document.querySelectorAll('section[id]');
const navAnchors = document.querySelectorAll('.nav-links a');
function updateActiveLink() {
let current = '';
sections.forEach(section => {
const top = section.offsetTop - 120;
if (window.scrollY >= top) {
current = section.getAttribute('id');
}
});
navAnchors.forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === `#${current}`);
});
}
window.addEventListener('scroll', updateActiveLink, { passive: true });
// ─── Scroll Reveal (IntersectionObserver) ───────────────
if (!prefersReducedMotion) {
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el));
} else {
document.querySelectorAll('.reveal').forEach(el => el.classList.add('visible'));
}
// ─── Counter Animation ──────────────────────────────────
const counters = document.querySelectorAll('.stat-number');
function animateCounter(el) {
const target = parseInt(el.dataset.target, 10);
const suffix = el.dataset.suffix || '';
const duration = 2000;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.floor(eased * target);
el.textContent = current + suffix;
if (progress < 1) {
requestAnimationFrame(update);
} else {
el.textContent = target + suffix;
}
}
requestAnimationFrame(update);
}
if (!prefersReducedMotion) {
const counterObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const counter = entry.target;
animateCounter(counter);
counterObserver.unobserve(counter);
}
});
}, { threshold: 0.5 });
counters.forEach(c => counterObserver.observe(c));
} else {
counters.forEach(c => {
c.textContent = c.dataset.target + (c.dataset.suffix || '');
});
}
// ─── Service cards staggered reveal ─────────────────────
if (!prefersReducedMotion) {
const cardObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const delay = parseInt(entry.target.dataset.delay || '0', 10);
setTimeout(() => {
entry.target.classList.add('visible');
}, delay);
cardObserver.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.service-card.reveal, .stat-item.reveal, .proyecto-card.reveal').forEach(
el => cardObserver.observe(el)
);
}
// ─── Smooth scroll for anchor links (fallback) ──────────
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', (e) => {
const targetId = anchor.getAttribute('href');
if (targetId === '#') return;
const target = document.querySelector(targetId);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
});