diff --git a/attached_assets/image_1765756444333.png b/attached_assets/image_1765756444333.png new file mode 100644 index 0000000..aa6c3f5 Binary files /dev/null and b/attached_assets/image_1765756444333.png differ diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index abd71b7..7606dcf 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -4,28 +4,23 @@ import { Button } from "@/components/ui/button"; const SSH_COMMAND = "ssh portfolio@keshavanand.net"; -const CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()_+-=[]{}|;:,.<>?/\\~`"; +const CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()_+-=[]{}|;:,.<>?/\\~`アイウエオカキクケコサシスセソタチツテト"; -interface Particle { +interface MatrixColumn { x: number; y: number; - char: string; - opacity: number; - targetX: number; - targetY: number; - vx: number; - vy: number; - life: number; - maxLife: number; - size: number; - color: string; + speed: number; + chars: string[]; + length: number; + baseOpacity: number; } function ParticleBackground() { const canvasRef = useRef(null); - const particlesRef = useRef([]); - const mouseRef = useRef({ x: 0, y: 0 }); + const columnsRef = useRef([]); + const mouseRef = useRef({ x: -1000, y: -1000 }); const animationRef = useRef(); + const timeRef = useRef(0); useEffect(() => { const canvas = canvasRef.current; @@ -34,97 +29,147 @@ function ParticleBackground() { const ctx = canvas.getContext("2d"); if (!ctx) return; + const fontSize = 14; + const columnSpacing = fontSize * 1.2; + + const initColumns = () => { + const cols: MatrixColumn[] = []; + const numColumns = Math.ceil(canvas.width / columnSpacing); + + for (let i = 0; i < numColumns; i++) { + const length = Math.floor(Math.random() * 20) + 10; + const chars: string[] = []; + for (let j = 0; j < length; j++) { + chars.push(CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)]); + } + cols.push({ + x: i * columnSpacing, + y: Math.random() * canvas.height - canvas.height, + speed: Math.random() * 1.5 + 0.5, + chars, + length, + baseOpacity: Math.random() * 0.4 + 0.1, + }); + } + columnsRef.current = cols; + }; + const resizeCanvas = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; + initColumns(); }; 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 }; + }; + + const getColor = (y: number, distanceToMouse: number, charIndex: number, totalChars: number) => { + const heightRatio = y / canvas.height; + const mouseInfluence = Math.max(0, 1 - distanceToMouse / 250); + const headGlow = charIndex === totalChars - 1 ? 1 : 0; - for (let i = 0; i < 3; i++) { - if (particlesRef.current.length < 200) { - particlesRef.current.push(createParticle(e.clientX, e.clientY)); - } + if (headGlow && mouseInfluence > 0.3) { + return `rgba(255, 255, 255, ${0.9 + mouseInfluence * 0.1})`; } + + const r = Math.floor(30 + mouseInfluence * 50 + heightRatio * 20); + const g = Math.floor(180 + mouseInfluence * 75 - heightRatio * 40); + const b = Math.floor(160 + heightRatio * 95 + mouseInfluence * 40); + + return `rgb(${r}, ${g}, ${b})`; }; const animate = () => { - ctx.fillStyle = "#000000"; + timeRef.current++; + + ctx.fillStyle = "rgba(0, 0, 0, 0.1)"; ctx.fillRect(0, 0, canvas.width, canvas.height); - particlesRef.current = particlesRef.current.filter((p) => { - p.life++; + ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; + + columnsRef.current.forEach((col) => { + const dx = mouseRef.current.x - col.x; + const dy = mouseRef.current.y - col.y; + const distanceToMouse = Math.sqrt(dx * dx + dy * dy); - const dx = mouseRef.current.x - p.x; - const dy = mouseRef.current.y - p.y; - const distance = Math.sqrt(dx * dx + dy * dy); + const gravityRadius = 200; + let gravityEffect = 0; + let pullX = 0; - if (distance < 150) { - const force = (150 - distance) / 150; - p.vx += (dx / distance) * force * 0.3; - p.vy += (dy / distance) * force * 0.3; + if (distanceToMouse < gravityRadius) { + gravityEffect = (gravityRadius - distanceToMouse) / gravityRadius; + pullX = (dx / distanceToMouse) * gravityEffect * 2; + } + + for (let i = 0; i < col.chars.length; i++) { + const charY = col.y + i * fontSize; + + if (charY < -fontSize || charY > canvas.height + fontSize) continue; + + const charDx = mouseRef.current.x - (col.x + pullX); + const charDy = mouseRef.current.y - charY; + const charDistance = Math.sqrt(charDx * charDx + charDy * charDy); + + const fadeRatio = i / col.chars.length; + let opacity = col.baseOpacity * (0.3 + fadeRatio * 0.7); + + if (charDistance < 250) { + const boost = (250 - charDistance) / 250; + opacity = Math.min(1, opacity + boost * 0.8); + } + + if (i === col.chars.length - 1) { + opacity = Math.min(1, opacity * 1.5); + } + + const displayX = col.x + pullX * (1 - i / col.chars.length); + + ctx.globalAlpha = opacity; + ctx.fillStyle = getColor(charY, charDistance, i, col.chars.length); + ctx.fillText(col.chars[i], displayX, charY); + + if (charDistance < 150 && i === col.chars.length - 1) { + ctx.globalAlpha = opacity * 0.3; + ctx.shadowColor = getColor(charY, charDistance, i, col.chars.length); + ctx.shadowBlur = 10; + ctx.fillText(col.chars[i], displayX, charY); + ctx.shadowBlur = 0; + } } - p.vx *= 0.96; - p.vy *= 0.96; + ctx.globalAlpha = 1; - 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; + if (Math.random() < 0.02) { + const randomIndex = Math.floor(Math.random() * col.chars.length); + col.chars[randomIndex] = CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)]; } - return p.life < p.maxLife; + const speedMultiplier = 1 + gravityEffect * 0.5; + col.y += col.speed * speedMultiplier; + + if (col.y > canvas.height + col.length * fontSize) { + col.y = -col.length * fontSize; + col.speed = Math.random() * 1.5 + 0.5; + col.baseOpacity = Math.random() * 0.4 + 0.1; + } }); + if (mouseRef.current.x > 0 && mouseRef.current.y > 0) { + const gradient = ctx.createRadialGradient( + mouseRef.current.x, mouseRef.current.y, 0, + mouseRef.current.x, mouseRef.current.y, 150 + ); + gradient.addColorStop(0, "rgba(148, 226, 213, 0.03)"); + gradient.addColorStop(0.5, "rgba(137, 180, 250, 0.02)"); + gradient.addColorStop(1, "rgba(0, 0, 0, 0)"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + animationRef.current = requestAnimationFrame(animate); }; diff --git a/replit.md b/replit.md new file mode 100644 index 0000000..011eff9 --- /dev/null +++ b/replit.md @@ -0,0 +1,72 @@ +# Keshav Anand Portfolio + +## Overview + +A minimalist terminal-themed portfolio website featuring an interactive particle background with cursor-following character effects. The site displays a single SSH command that visitors can copy to connect. Built with React frontend and Express backend, using the Catppuccin Mocha color palette for a dark terminal aesthetic. + +## User Preferences + +Preferred communication style: Simple, everyday language. + +## System Architecture + +### Frontend Architecture +- **Framework**: React 18 with TypeScript +- **Routing**: Wouter (lightweight React router) +- **Styling**: Tailwind CSS with custom Catppuccin Mocha theme variables +- **UI Components**: shadcn/ui component library (New York style variant) +- **State Management**: TanStack React Query for server state +- **Build Tool**: Vite with path aliases (@/, @shared/, @assets/) + +### Backend Architecture +- **Runtime**: Node.js with Express +- **Language**: TypeScript (ESM modules) +- **API Pattern**: RESTful routes prefixed with /api +- **Storage**: Pluggable storage interface (currently in-memory, prepared for PostgreSQL) + +### Project Structure +``` +client/ # React frontend + src/ + components/ # UI components (shadcn/ui) + pages/ # Route pages + hooks/ # Custom React hooks + lib/ # Utilities and query client +server/ # Express backend + index.ts # Entry point + routes.ts # API route definitions + storage.ts # Data storage interface +shared/ # Shared types and schemas + schema.ts # Drizzle ORM schema definitions +``` + +### Design System +- **Theme**: Catppuccin Mocha (dark-only) +- **Typography**: JetBrains Mono monospace font +- **Key Colors**: + - Background: #1e1e2e + - Primary green: #a6e3a1 + - Teal accent: #94e2d5 + - Text: #cdd6f4 + +### Build Configuration +- Development: `npm run dev` (tsx with Vite dev server) +- Production: `npm run build` (esbuild for server, Vite for client) +- Output: `dist/` directory with `index.cjs` and `public/` folder + +## External Dependencies + +### Database +- **ORM**: Drizzle ORM with PostgreSQL dialect +- **Schema Location**: shared/schema.ts +- **Migrations**: ./migrations directory +- **Connection**: DATABASE_URL environment variable required for production + +### Third-Party Services +- **Google Fonts**: JetBrains Mono font family loaded via CDN + +### Key NPM Packages +- **UI**: Radix UI primitives, Lucide React icons, class-variance-authority +- **Forms**: react-hook-form with zod validation +- **Data**: @tanstack/react-query for async state +- **Utilities**: clsx, tailwind-merge, date-fns \ No newline at end of file