Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 298 additions & 0 deletions frontend/src/components/home/AnimatedHeroBackground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import React, { useRef, useEffect, useCallback } from 'react';

// ─── Types ───────────────────────────────────────────────────────────────────
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
alpha: number;
type: 'ember' | 'spark' | 'molten';
color: string;
}

interface MoltenStream {
x: number;
y: number;
width: number;
speed: number;
alpha: number;
hue: number;
}

// ─── Constants ───────────────────────────────────────────────────────────────
const EMBER_COLORS = ['#FF6B35', '#FF8C42', '#FFB347', '#FFD700', '#E040FB'];
const SPARK_COLORS = ['#00E676', '#7C3AED', '#E040FB', '#40C4FF'];
const MOLTEN_COLORS = ['#FF4500', '#FF6347', '#FF7F50', '#FF8C00'];

const MAX_PARTICLES = 120;
const STREAMS_COUNT = 4;

// ─── Component ───────────────────────────────────────────────────────────────
const AnimatedHeroBackground: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const streamsRef = useRef<MoltenStream[]>([]);
const rafRef = useRef<number>(0);
const mouseRef = useRef({ x: 0, y: 0 });

// ── Initialize particles ─────────────────────────────────────────────────
const spawnParticle = useCallback((w: number, h: number): Particle => {
const type = Math.random() < 0.5
? 'ember'
: Math.random() < 0.6
? 'spark'
: 'molten';

const colors = type === 'ember'
? EMBER_COLORS
: type === 'spark'
? SPARK_COLORS
: MOLTEN_COLORS;

const angle = type === 'molten'
? (Math.random() - 0.5) * 0.6 // mostly downward
: type === 'spark'
? -Math.random() * Math.PI - Math.PI * 0.25 // upward random
: -Math.random() * Math.PI * 0.8 - Math.PI * 0.1;

const speed = type === 'spark'
? 1.5 + Math.random() * 2.5
: type === 'molten'
? 0.3 + Math.random() * 0.6
: 0.4 + Math.random() * 1.2;

return {
x: Math.random() * w,
y: h + Math.random() * 40,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
maxLife: type === 'spark'
? 60 + Math.random() * 80
: type === 'molten'
? 120 + Math.random() * 160
: 80 + Math.random() * 120,
size: type === 'spark'
? 1.5 + Math.random() * 1.5
: type === 'molten'
? 3 + Math.random() * 4
: 2 + Math.random() * 3,
alpha: 0,
type,
color: colors[Math.floor(Math.random() * colors.length)],
};
}, []);

// ── Initialize molten streams ────────────────────────────────────────────
const initStreams = useCallback((w: number, h: number) => {
streamsRef.current = Array.from({ length: STREAMS_COUNT }, (_, i) => ({
x: (w / (STREAMS_COUNT + 1)) * (i + 1) + (Math.random() - 0.5) * 80,
y: 0,
width: 4 + Math.random() * 8,
speed: 0.15 + Math.random() * 0.25,
alpha: 0.06 + Math.random() * 0.08,
hue: 15 + Math.random() * 20,
}));
}, []);

// ── Draw a single particle ───────────────────────────────────────────────
const drawParticle = (ctx: CanvasRenderingContext2D, p: Particle) => {
const progress = p.life / p.maxLife;
const fadeIn = Math.min(progress * 3, 1); // fast fade-in
const fadeOut = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1;
p.alpha = fadeIn * fadeOut * 0.8;

if (p.alpha < 0.01) return;

ctx.save();
ctx.globalAlpha = p.alpha;

// Glow effect
if (p.size > 2) {
ctx.shadowBlur = p.size * 6;
ctx.shadowColor = p.color;
}

if (p.type === 'spark') {
// Spark = bright thin streak
ctx.strokeStyle = p.color;
ctx.lineWidth = p.size * 0.5;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * 4, p.y - p.vy * 4);
ctx.stroke();
} else {
// Ember / Molten = soft circle
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 2);
gradient.addColorStop(0, '#FFFFFF');
gradient.addColorStop(0.3, p.color);
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 2, 0, Math.PI * 2);
ctx.fill();
}

ctx.restore();
};

// ── Draw molten streams ──────────────────────────────────────────────────
const drawStreams = (ctx: CanvasRenderingContext2D, w: number, h: number, time: number) => {
for (const stream of streamsRef.current) {
const yOffset = (time * stream.speed * 60) % (h * 1.5);

ctx.save();
ctx.globalAlpha = stream.alpha;
ctx.shadowBlur = 20;
ctx.shadowColor = `hsl(${stream.hue}, 100%, 50%)`;

// Drip segments
for (let i = 0; i < 5; i++) {
const segY = (yOffset + i * (h * 0.2)) % (h * 1.5);
const segAlpha = 1 - (i / 5);
const segWidth = stream.width * (1 - i * 0.12);

ctx.fillStyle = `hsla(${stream.hue}, 100%, ${60 - i * 8}%, ${segAlpha * 0.5})`;
ctx.beginPath();
ctx.ellipse(stream.x, segY, segWidth, segWidth * 2.5, 0, 0, Math.PI * 2);
ctx.fill();
}

ctx.restore();
}
};

// ── Main animation loop ──────────────────────────────────────────────────
const animate = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;

const w = canvas.width;
const h = canvas.height;
const time = Date.now() / 1000;

// Clear with slight trail effect
ctx.fillStyle = 'rgba(5, 5, 5, 0.15)';
ctx.fillRect(0, 0, w, h);

// ── Update & draw molten streams ─────────────────────────────────────
drawStreams(ctx, w, h, time);

// ── Update particles ──────────────────────────────────────────────────
const particles = particlesRef.current;

for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life++;

if (p.life >= p.maxLife) {
particles.splice(i, 1);
continue;
}

// Apply physics
if (p.type === 'molten') {
p.vy += 0.02; // gravity
p.vx += (Math.random() - 0.5) * 0.1; // wobble
} else if (p.type === 'ember') {
p.vx += (Math.random() - 0.5) * 0.05;
p.vy -= 0.01; // slight upward drift
}

// Mouse interaction
const dx = p.x - mouseRef.current.x;
const dy = p.y - mouseRef.current.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120 && dist > 0) {
p.vx += (dx / dist) * 0.3;
p.vy += (dy / dist) * 0.3;
}

p.x += p.vx;
p.y += p.vy;

// Wrap around
if (p.x < -20) p.x = w + 20;
if (p.x > w + 20) p.x = -20;
if (p.y < -20) p.y = h + 20;

drawParticle(ctx, p);
}

// ── Spawn new particles ────────────────────────────────────────────────
const spawnRate = Math.min(3, Math.max(0.5, 3 - particles.length / 40));
if (particles.length < MAX_PARTICLES && Math.random() < spawnRate * 0.05) {
particles.push(spawnParticle(w, h));
}

// ── Bottom forge glow ──────────────────────────────────────────────────
const forgeGrad = ctx.createRadialGradient(w / 2, h + 60, 0, w / 2, h + 60, h * 0.6);
forgeGrad.addColorStop(0, 'rgba(255, 107, 53, 0.08)');
forgeGrad.addColorStop(0.4, 'rgba(124, 58, 237, 0.04)');
forgeGrad.addColorStop(1, 'transparent');
ctx.fillStyle = forgeGrad;
ctx.fillRect(0, 0, w, h);

// ── Ambient glow pulse ─────────────────────────────────────────────────
const pulse = Math.sin(time * 0.5) * 0.3 + 0.7;
const ambientGrad = ctx.createRadialGradient(w / 2, h * 0.3, 0, w / 2, h * 0.3, h * 0.5);
ambientGrad.addColorStop(0, `rgba(124, 58, 237, ${0.03 * pulse})`);
ambientGrad.addColorStop(1, 'transparent');
ctx.fillStyle = ambientGrad;
ctx.fillRect(0, 0, w, h);

rafRef.current = requestAnimationFrame(animate);
}, [spawnParticle]);

// ── Setup & teardown ────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;

const resize = () => {
const hero = canvas.parentElement;
if (!hero) return;
canvas.width = hero.clientWidth;
canvas.height = hero.clientHeight;
const w = canvas.width;
const h = canvas.height;

// Reset particles on resize (keep some)
particlesRef.current = particlesRef.current.filter(
p => p.x < w + 20 && p.y < h + 20
);

initStreams(w, h);
};

resize();
rafRef.current = requestAnimationFrame(animate);

window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
});

return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', resize);
};
}, [animate, initStreams]);

return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 0 }}
aria-hidden="true"
/>
);
};

export default AnimatedHeroBackground;
10 changes: 7 additions & 3 deletions frontend/src/components/home/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useStats } from '../../hooks/useStats';
import { getGitHubAuthorizeUrl } from '../../api/auth';
import { useAuth } from '../../hooks/useAuth';
import { buttonHover, fadeIn } from '../../lib/animations';
import AnimatedHeroBackground from './AnimatedHeroBackground';

const GitHubIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
Expand Down Expand Up @@ -89,9 +90,12 @@ export function HeroSection() {
return (
<section className="relative min-h-[90vh] flex flex-col items-center justify-center px-4 pt-24 pb-16 overflow-hidden">
{/* Background layers */}
<div className="absolute inset-0 bg-grid-forge bg-grid-forge pointer-events-none" style={{ backgroundSize: '40px 40px' }} />
<div className="absolute inset-0 bg-gradient-hero pointer-events-none" />
<EmberParticles count={5} />
{/* Animated forge background */}
<AnimatedHeroBackground />

{/* Grid overlay */}
<div className="absolute inset-0 bg-grid-forge bg-grid-forge pointer-events-none" style={{ backgroundSize: '40px 40px', zIndex: 1 }} />
<div className="absolute inset-0 bg-gradient-hero pointer-events-none" style={{ zIndex: 1 }} />

{/* Terminal card */}
<motion.div
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
--animate-shimmer: shimmer 2s linear infinite;
--animate-pulse-glow: pulse-glow 3s ease-in-out infinite;
--animate-gradient-shift: gradient-shift 6s ease infinite;
--animate-forge-glow: forge-glow 4s ease-in-out infinite;
--animate-molten-pulse: molten-pulse 2s ease-in-out infinite;

@keyframes typewriter {
from { width: 0; }
Expand Down Expand Up @@ -107,6 +109,14 @@
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes forge-glow {
0%, 100% { opacity: 0.3; filter: brightness(0.8); }
50% { opacity: 0.7; filter: brightness(1.2); }
}
@keyframes molten-pulse {
0%, 100% { opacity: 0.4; transform: scaleY(0.95); }
50% { opacity: 0.8; transform: scaleY(1.05); }
}
}

/* ============================================================================
Expand Down
10 changes: 10 additions & 0 deletions frontend/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export default {
'50%': { backgroundPosition: '100% 50%' },
'100%': { backgroundPosition: '0% 50%' },
},
'forge-glow': {
'0%, 100%': { opacity: '0.3', filter: 'brightness(0.8)' },
'50%': { opacity: '0.7', filter: 'brightness(1.2)' },
},
'molten-pulse': {
'0%, 100%': { opacity: '0.4', transform: 'scaleY(0.95)' },
'50%': { opacity: '0.8', transform: 'scaleY(1.05)' },
},
},
animation: {
typewriter: 'typewriter 2.5s steps(44) 0.5s forwards',
Expand All @@ -113,6 +121,8 @@ export default {
shimmer: 'shimmer 2s linear infinite',
'pulse-glow': 'pulse-glow 3s ease-in-out infinite',
'gradient-shift': 'gradient-shift 6s ease infinite',
'forge-glow': 'forge-glow 4s ease-in-out infinite',
'molten-pulse': 'molten-pulse 2s ease-in-out infinite',
},
},
},
Expand Down
Loading