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:
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||
<title>Keshav Anand - Portfolio</title>
|
||||
<meta name="description" content="Connect with Keshav Anand via SSH. A minimalist portfolio experience." />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -3,14 +3,13 @@ import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import Home from "@/pages/home";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
{/* Add pages below */}
|
||||
{/* <Route path="/" component={Home}/> */}
|
||||
{/* Fallback to 404 */}
|
||||
<Route path="/" component={Home} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -2,50 +2,50 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* LIGHT MODE */
|
||||
/* Catppuccin Mocha Theme - Dark Only */
|
||||
:root {
|
||||
--button-outline: rgba(0,0,0, .10);
|
||||
--badge-outline: rgba(0,0,0, .05);
|
||||
--opaque-button-border-intensity: -8;
|
||||
--elevate-1: rgba(0,0,0, .03);
|
||||
--elevate-2: rgba(0,0,0, .08);
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 9%;
|
||||
--border: 0 0% 89%;
|
||||
--card: 0 0% 98%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--card-border: 0 0% 94%;
|
||||
--sidebar: 0 0% 96%;
|
||||
--sidebar-foreground: 0 0% 9%;
|
||||
--sidebar-border: 0 0% 92%;
|
||||
--sidebar-primary: 115 54% 45%;
|
||||
--sidebar-primary-foreground: 115 54% 98%;
|
||||
--sidebar-accent: 0 0% 92%;
|
||||
--sidebar-accent-foreground: 0 0% 9%;
|
||||
--sidebar-ring: 115 54% 45%;
|
||||
--popover: 0 0% 94%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--popover-border: 0 0% 90%;
|
||||
--primary: 115 54% 35%;
|
||||
--primary-foreground: 115 54% 98%;
|
||||
--secondary: 0 0% 90%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 92%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 115 15% 92%;
|
||||
--accent-foreground: 115 15% 12%;
|
||||
--destructive: 0 72% 42%;
|
||||
--destructive-foreground: 0 72% 98%;
|
||||
--input: 0 0% 75%;
|
||||
--ring: 115 54% 45%;
|
||||
--chart-1: 115 54% 35%;
|
||||
--chart-2: 173 58% 32%;
|
||||
--chart-3: 197 37% 38%;
|
||||
--chart-4: 43 74% 42%;
|
||||
--chart-5: 27 87% 48%;
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--button-outline: rgba(255,255,255, .10);
|
||||
--badge-outline: rgba(255,255,255, .05);
|
||||
--opaque-button-border-intensity: 9;
|
||||
--elevate-1: rgba(255,255,255, .04);
|
||||
--elevate-2: rgba(255,255,255, .09);
|
||||
--background: 240 21% 15%;
|
||||
--foreground: 226 64% 88%;
|
||||
--border: 240 17% 20%;
|
||||
--card: 240 21% 17%;
|
||||
--card-foreground: 226 64% 88%;
|
||||
--card-border: 240 17% 22%;
|
||||
--sidebar: 240 21% 16%;
|
||||
--sidebar-foreground: 226 64% 88%;
|
||||
--sidebar-border: 240 17% 19%;
|
||||
--sidebar-primary: 115 54% 76%;
|
||||
--sidebar-primary-foreground: 240 21% 12%;
|
||||
--sidebar-accent: 240 17% 19%;
|
||||
--sidebar-accent-foreground: 226 64% 88%;
|
||||
--sidebar-ring: 115 54% 76%;
|
||||
--popover: 240 21% 18%;
|
||||
--popover-foreground: 226 64% 88%;
|
||||
--popover-border: 240 17% 21%;
|
||||
--primary: 115 54% 76%;
|
||||
--primary-foreground: 240 21% 12%;
|
||||
--secondary: 240 17% 22%;
|
||||
--secondary-foreground: 226 64% 88%;
|
||||
--muted: 240 17% 21%;
|
||||
--muted-foreground: 226 40% 65%;
|
||||
--accent: 170 57% 73%;
|
||||
--accent-foreground: 240 21% 12%;
|
||||
--destructive: 0 62% 32%;
|
||||
--destructive-foreground: 0 62% 96%;
|
||||
--input: 240 17% 35%;
|
||||
--ring: 115 54% 76%;
|
||||
--chart-1: 115 54% 76%;
|
||||
--chart-2: 170 57% 73%;
|
||||
--chart-3: 197 37% 62%;
|
||||
--chart-4: 43 74% 68%;
|
||||
--chart-5: 27 87% 70%;
|
||||
--font-sans: 'JetBrains Mono', monospace;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--radius: .5rem;
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
|
||||
@@ -87,83 +87,6 @@
|
||||
--destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--button-outline: rgba(255,255,255, .10);
|
||||
--badge-outline: rgba(255,255,255, .05);
|
||||
--opaque-button-border-intensity: 9;
|
||||
--elevate-1: rgba(255,255,255, .04);
|
||||
--elevate-2: rgba(255,255,255, .09);
|
||||
--background: 240 21% 12%;
|
||||
--foreground: 226 64% 88%;
|
||||
--border: 240 17% 20%;
|
||||
--card: 240 21% 14%;
|
||||
--card-foreground: 226 64% 88%;
|
||||
--card-border: 240 17% 17%;
|
||||
--sidebar: 240 21% 16%;
|
||||
--sidebar-foreground: 226 64% 88%;
|
||||
--sidebar-border: 240 17% 19%;
|
||||
--sidebar-primary: 115 54% 55%;
|
||||
--sidebar-primary-foreground: 240 21% 12%;
|
||||
--sidebar-accent: 240 17% 19%;
|
||||
--sidebar-accent-foreground: 226 64% 88%;
|
||||
--sidebar-ring: 115 54% 55%;
|
||||
--popover: 240 21% 18%;
|
||||
--popover-foreground: 226 64% 88%;
|
||||
--popover-border: 240 17% 21%;
|
||||
--primary: 115 54% 30%;
|
||||
--primary-foreground: 115 54% 96%;
|
||||
--secondary: 240 17% 22%;
|
||||
--secondary-foreground: 226 64% 88%;
|
||||
--muted: 240 17% 21%;
|
||||
--muted-foreground: 226 40% 65%;
|
||||
--accent: 115 12% 22%;
|
||||
--accent-foreground: 115 12% 88%;
|
||||
--destructive: 0 62% 32%;
|
||||
--destructive-foreground: 0 62% 96%;
|
||||
--input: 240 17% 35%;
|
||||
--ring: 115 54% 55%;
|
||||
--chart-1: 115 54% 65%;
|
||||
--chart-2: 173 58% 60%;
|
||||
--chart-3: 197 37% 62%;
|
||||
--chart-4: 43 74% 68%;
|
||||
--chart-5: 27 87% 70%;
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(240 21% 12% / 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(240 21% 12% / 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(240 21% 12% / 0.00), 0px 1px 2px -1px hsl(240 21% 12% / 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(240 21% 12% / 0.00), 0px 1px 2px -1px hsl(240 21% 12% / 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(240 21% 12% / 0.00), 0px 2px 4px -1px hsl(240 21% 12% / 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(240 21% 12% / 0.00), 0px 4px 6px -1px hsl(240 21% 12% / 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(240 21% 12% / 0.00), 0px 8px 10px -1px hsl(240 21% 12% / 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(240 21% 12% / 0.00);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--sidebar-primary-border: hsl(var(--sidebar-primary));
|
||||
--sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--sidebar-accent-border: hsl(var(--sidebar-accent));
|
||||
--sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--primary-border: hsl(var(--primary));
|
||||
--primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--secondary-border: hsl(var(--secondary));
|
||||
--secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--muted-border: hsl(var(--muted));
|
||||
--muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--accent-border: hsl(var(--accent));
|
||||
--accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
|
||||
/* Fallback for older browsers */
|
||||
--destructive-border: hsl(var(--destructive));
|
||||
--destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
|
||||
208
client/src/pages/home.tsx
Normal file
208
client/src/pages/home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user