209 lines
5.7 KiB
TypeScript
209 lines
5.7 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: 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>
|
|
);
|
|
}
|