Files
terminal-portfolio-site/client/src/pages/home.tsx
2026-01-17 23:47:09 -06:00

283 lines
8.1 KiB
TypeScript

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: window.innerWidth / 2,
y: window.innerHeight / 2,
});
const animationRef = useRef<number>();
const frameCountRef = useRef(0);
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,
isAmbient = false
): Particle => {
const angle = Math.random() * Math.PI * 2;
const speed = isAmbient
? Math.random() * 0.5 + 0.2
: Math.random() * 2 + 1;
const color = colors[Math.floor(Math.random() * colors.length)];
const spread = isAmbient ? 200 : 120;
return {
x: x + (Math.random() - 0.5) * spread,
y: y + (Math.random() - 0.5) * spread,
char: CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)],
opacity: isAmbient
? Math.random() * 0.4 + 0.1
: Math.random() * 0.8 + 0.2,
targetX: x,
targetY: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
maxLife: isAmbient ? Math.random() * 120 + 80 : Math.random() * 80 + 50,
size: Math.random() * 10 + 12,
color,
};
};
const handleMouseMove = (e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
for (let i = 0; i < 5; i++) {
if (particlesRef.current.length < 400) {
particlesRef.current.push(createParticle(e.clientX, e.clientY));
}
}
};
const animate = () => {
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
frameCountRef.current++;
if (
frameCountRef.current % 3 === 0 &&
particlesRef.current.length < 400
) {
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
particlesRef.current.push(createParticle(mx, my, true));
}
if (
frameCountRef.current % 8 === 0 &&
particlesRef.current.length < 400
) {
const rx = Math.random() * canvas.width;
const ry = Math.random() * canvas.height;
particlesRef.current.push(createParticle(rx, ry, true));
}
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) || 1;
if (distance < 200) {
const force = (200 - distance) / 200;
p.vx += (dx / distance) * force * 0.4;
p.vy += (dy / distance) * force * 0.4;
}
p.vx *= 0.97;
p.vy *= 0.97;
p.x += p.vx;
p.y += p.vy;
const lifeRatio = p.life / p.maxLife;
const fadeOpacity =
lifeRatio < 0.15
? lifeRatio * 6.67
: lifeRatio > 0.7
? (1 - lifeRatio) * 3.33
: 1;
const distanceOpacity = Math.max(0.15, 1 - distance / 400);
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 [showHowItWorks, setShowHowItWorks] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setShowHowItWorks(true);
}, 2000);
return () => clearTimeout(timer);
}, []);
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="flex flex-col items-center justify-center min-h-screen p-4 sm:p-6">
<div className="flex-1 flex items-center justify-center w-full max-w-[90vw] md:max-w-none">
<div
className="relative z-10 flex flex-wrap items-center justify-center md:justify-start gap-3 md:gap-4 px-4 py-3 md:px-6 md:py-4 rounded-xl border border-border/30 backdrop-blur-sm shadow-2xl w-full md:w-auto overflow-hidden"
style={{ backgroundColor: "rgba(49, 50, 68, 0.8)" }}
data-testid="command-box"
>
<div className="flex items-center gap-2 md:gap-4 flex-1 md:flex-none justify-center md:justify-start">
<span
className="text-muted-foreground shrink-0"
data-testid="text-prompt"
>
$
</span>
<code
className="text-sm sm:text-base md:text-lg lg:text-xl font-mono truncate"
style={{ color: "#a6e3a1" }}
data-testid="text-ssh-command"
>
{SSH_COMMAND}
</code>
</div>
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className={`shrink-0 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-4 w-4 md:h-5 md:w-5"
style={{ color: "#a6e3a1" }}
/>
) : (
<Copy className="h-4 w-4 md:h-5 md:w-5" />
)}
</Button>
</div>
</div>
<div className="pb-12 md:pb-16">
<a
href="/docs"
className={`relative z-10 px-6 py-3 rounded-lg border border-border/20 backdrop-blur-md text-sm font-mono text-muted-foreground transition-all duration-1000 hover:border-primary/40 hover:text-primary hover-elevate ${
showHowItWorks
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{ backgroundColor: "rgba(30, 30, 46, 0.4)" }}
data-testid="link-how-it-works"
>
How it Works
</a>
</div>
</div>
);
}
export default function Home() {
return (
<div className="min-h-screen w-full relative overflow-hidden bg-black">
<ParticleBackground />
<CommandBox />
</div>
);
}