Add interactive particle background and SSH command display to portfolio

Update index.html title and description, replace placeholder in App.tsx with Home component, and implement the ParticleBackground component with Catppuccin Mocha styling in index.css.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 8a5dc88f-13c6-40ab-96e7-e09ad06db4dd
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 79c937b9-88ad-4bd4-8ed9-0b29de18077b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/804258ec-282b-434a-9b89-a7ccc1690e42/8a5dc88f-13c6-40ab-96e7-e09ad06db4dd/ztwlduV
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
keshavanandmusi
2025-12-14 23:52:23 +00:00
parent 7982b4d5ad
commit 0b8673dc9f
4 changed files with 254 additions and 122 deletions

208
client/src/pages/home.tsx Normal file
View File

@@ -0,0 +1,208 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
const SSH_COMMAND = "ssh portfolio@keshavanand.net";
const CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()_+-=[]{}|;:,.<>?/\\~`";
interface Particle {
x: number;
y: number;
char: string;
opacity: number;
targetX: number;
targetY: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
color: string;
}
function ParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const mouseRef = useRef({ x: 0, y: 0 });
const animationRef = useRef<number>();
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
const colors = [
"#a6e3a1",
"#94e2d5",
"#89dceb",
"#74c7ec",
"#89b4fa",
"#cba6f7",
];
const createParticle = (x: number, y: number): Particle => {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 2 + 1;
const color = colors[Math.floor(Math.random() * colors.length)];
return {
x: x + (Math.random() - 0.5) * 100,
y: y + (Math.random() - 0.5) * 100,
char: CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)],
opacity: Math.random() * 0.8 + 0.2,
targetX: x,
targetY: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
maxLife: Math.random() * 60 + 40,
size: Math.random() * 10 + 12,
color,
};
};
const handleMouseMove = (e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
for (let i = 0; i < 3; i++) {
if (particlesRef.current.length < 200) {
particlesRef.current.push(createParticle(e.clientX, e.clientY));
}
}
};
const animate = () => {
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
particlesRef.current = particlesRef.current.filter((p) => {
p.life++;
const dx = mouseRef.current.x - p.x;
const dy = mouseRef.current.y - p.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150) {
const force = (150 - distance) / 150;
p.vx += (dx / distance) * force * 0.3;
p.vy += (dy / distance) * force * 0.3;
}
p.vx *= 0.96;
p.vy *= 0.96;
p.x += p.vx;
p.y += p.vy;
const lifeRatio = p.life / p.maxLife;
const fadeOpacity = lifeRatio < 0.2
? lifeRatio * 5
: lifeRatio > 0.7
? (1 - lifeRatio) * 3.33
: 1;
const distanceOpacity = Math.max(0, 1 - distance / 300);
const finalOpacity = p.opacity * fadeOpacity * distanceOpacity;
if (finalOpacity > 0.01) {
ctx.font = `${p.size}px 'JetBrains Mono', monospace`;
ctx.fillStyle = p.color;
ctx.globalAlpha = finalOpacity;
ctx.fillText(p.char, p.x, p.y);
ctx.globalAlpha = 1;
}
return p.life < p.maxLife;
});
animationRef.current = requestAnimationFrame(animate);
};
window.addEventListener("mousemove", handleMouseMove);
animate();
return () => {
window.removeEventListener("resize", resizeCanvas);
window.removeEventListener("mousemove", handleMouseMove);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 z-0"
style={{ background: "#000000" }}
data-testid="canvas-background"
/>
);
}
function CommandBox() {
const [copied, setCopied] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(SSH_COMMAND);
setIsAnimating(true);
setCopied(true);
setTimeout(() => setIsAnimating(false), 150);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, []);
return (
<div
className="relative z-10 flex items-center gap-4 px-6 py-4 rounded-xl border border-border/30 backdrop-blur-sm"
style={{ backgroundColor: "rgba(49, 50, 68, 0.8)" }}
data-testid="command-box"
>
<span className="text-muted-foreground mr-1" data-testid="text-prompt">$</span>
<code
className="text-lg md:text-xl font-mono"
style={{ color: "#a6e3a1" }}
data-testid="text-ssh-command"
>
{SSH_COMMAND}
</code>
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className={`ml-2 text-muted-foreground transition-transform duration-150 ${isAnimating ? "scale-90" : "scale-100"}`}
aria-label={copied ? "Copied to clipboard" : "Copy SSH command"}
data-testid="button-copy"
>
{copied ? (
<Check className="h-5 w-5" style={{ color: "#a6e3a1" }} />
) : (
<Copy className="h-5 w-5" />
)}
</Button>
</div>
);
}
export default function Home() {
return (
<div className="min-h-screen w-full flex items-center justify-center overflow-hidden">
<ParticleBackground />
<CommandBox />
</div>
);
}