From 26a77b0a7a433b5ebc986c489ea8124c8f493159 Mon Sep 17 00:00:00 2001 From: NoCodeDevs <119425316+NoCodeDevs@users.noreply.github.com> Date: Mon, 11 Aug 2025 08:18:55 -0400 Subject: [PATCH 01/33] init commit --- .local | 0 README.md | 8 +- app/api/create-ai-sandbox/route.ts | 15 +- app/api/get-sandbox-files/route.ts | 18 +- app/api/restart-vite/route.ts | 18 +- app/api/sandbox-logs/route.ts | 14 + app/api/sandbox-status/route.ts | 24 +- app/layout.tsx | 2 +- app/page.tsx | 529 +++--- components/AnimatedCodeBackground.tsx | 121 ++ components/DemoFlow.tsx | 246 +++ config/app.config.ts | 20 +- lib/icons.ts | 2 +- package-lock.json | 2195 ++++++++++++++++++++++--- package.json | 2 +- public/devs-dev.svg | 14 + 16 files changed, 2649 insertions(+), 579 deletions(-) create mode 100644 .local create mode 100644 components/AnimatedCodeBackground.tsx create mode 100644 components/DemoFlow.tsx create mode 100644 public/devs-dev.svg diff --git a/.local b/.local new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index 11facd13..1ae2e267 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@
-# Open Lovable +# Makerthrive Cloner Chat with AI to build React apps instantly. -Open Lovable Demo +Makerthrive Cloner Demo
@@ -12,8 +12,8 @@ Chat with AI to build React apps instantly. 1. **Clone & Install** ```bash -git clone https://github.com/mendableai/open-lovable.git -cd open-lovable +git clone +cd makerthrive-cloner npm install ``` diff --git a/app/api/create-ai-sandbox/route.ts b/app/api/create-ai-sandbox/route.ts index 257ce1db..9f7d465a 100644 --- a/app/api/create-ai-sandbox/route.ts +++ b/app/api/create-ai-sandbox/route.ts @@ -100,7 +100,8 @@ export default defineConfig({ port: 5173, strictPort: true, hmr: false, - allowedHosts: ['.e2b.app', 'localhost', '127.0.0.1'] + // Allow both e2b.dev and e2b.app subdomains that proxy the sandbox + allowedHosts: ['.e2b.app', '.e2b.dev', 'e2b.app', 'e2b.dev', 'localhost', '127.0.0.1'] } })""" @@ -228,15 +229,19 @@ print('\\nAll files created successfully!') // Execute the setup script await sandbox.runCode(setupScript); - // Install dependencies + // Install dependencies (optionally with --legacy-peer-deps) console.log('[create-ai-sandbox] Installing dependencies...'); await sandbox.runCode(` import subprocess import sys print('Installing npm packages...') +cmd = ['npm', 'install'] +# Prefer legacy peers to avoid peer resolution fails in constrained envs +cmd.append('--legacy-peer-deps') + result = subprocess.run( - ['npm', 'install'], + cmd, cwd='/home/user/app', capture_output=True, text=True @@ -245,8 +250,8 @@ result = subprocess.run( if result.returncode == 0: print('✓ Dependencies installed successfully') else: - print(f'⚠ Warning: npm install had issues: {result.stderr}') - # Continue anyway as it might still work + print(f'❌ npm install failed: {result.stderr}') + raise SystemExit('npm install failed') `); // Start Vite dev server diff --git a/app/api/get-sandbox-files/route.ts b/app/api/get-sandbox-files/route.ts index d892046e..4ae4a0a9 100644 --- a/app/api/get-sandbox-files/route.ts +++ b/app/api/get-sandbox-files/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { Sandbox } from '@e2b/code-interpreter'; import { parseJavaScriptFile, buildComponentTree } from '@/lib/file-parser'; import { FileManifest, FileInfo, RouteInfo } from '@/types/file-manifest'; import type { SandboxState } from '@/types/sandbox'; @@ -7,8 +8,21 @@ declare global { var activeSandbox: any; } -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url); + const sandboxId = searchParams.get('sandbox') || undefined; + + if (!global.activeSandbox && sandboxId) { + try { + console.log(`[get-sandbox-files] Attempting reconnect to sandbox ${sandboxId}...`); + const sandbox = await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY }); + global.activeSandbox = sandbox; + } catch (e) { + console.error('[get-sandbox-files] Reconnect failed:', e); + } + } + if (!global.activeSandbox) { return NextResponse.json({ success: false, diff --git a/app/api/restart-vite/route.ts b/app/api/restart-vite/route.ts index ca6b4ba1..7fd14530 100644 --- a/app/api/restart-vite/route.ts +++ b/app/api/restart-vite/route.ts @@ -1,11 +1,25 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { Sandbox } from '@e2b/code-interpreter'; declare global { var activeSandbox: any; } -export async function POST() { +export async function POST(request: NextRequest) { try { + const body = await request.json().catch(() => ({})); + const sandboxId = body?.sandboxId as string | undefined; + + if (!global.activeSandbox && sandboxId) { + try { + console.log(`[restart-vite] Attempting reconnect to sandbox ${sandboxId}...`); + const sandbox = await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY }); + global.activeSandbox = sandbox; + } catch (e) { + console.error('[restart-vite] Reconnect failed:', e); + } + } + if (!global.activeSandbox) { return NextResponse.json({ success: false, diff --git a/app/api/sandbox-logs/route.ts b/app/api/sandbox-logs/route.ts index 84d02088..b38badc5 100644 --- a/app/api/sandbox-logs/route.ts +++ b/app/api/sandbox-logs/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { Sandbox } from '@e2b/code-interpreter'; declare global { var activeSandbox: any; @@ -6,6 +7,19 @@ declare global { export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url); + const sandboxId = searchParams.get('sandbox') || undefined; + + if (!global.activeSandbox && sandboxId) { + try { + console.log(`[sandbox-logs] Attempting reconnect to sandbox ${sandboxId}...`); + const sandbox = await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY }); + global.activeSandbox = sandbox; + } catch (e) { + console.error('[sandbox-logs] Reconnect failed:', e); + } + } + if (!global.activeSandbox) { return NextResponse.json({ success: false, diff --git a/app/api/sandbox-status/route.ts b/app/api/sandbox-status/route.ts index 7f5e0b56..d9b50a2f 100644 --- a/app/api/sandbox-status/route.ts +++ b/app/api/sandbox-status/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { Sandbox } from '@e2b/code-interpreter'; declare global { var activeSandbox: any; @@ -6,10 +7,27 @@ declare global { var existingFiles: Set; } -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url); + const sandboxId = searchParams.get('sandbox') || undefined; + // Check if sandbox exists - const sandboxExists = !!global.activeSandbox; + let sandboxExists = !!global.activeSandbox; + + // If not, but a sandboxId is provided, attempt reconnect + if (!sandboxExists && sandboxId) { + try { + console.log(`[sandbox-status] Attempting reconnect to sandbox ${sandboxId}...`); + const sandbox = await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY }); + global.activeSandbox = sandbox; + const host = (sandbox as any).getHost(5173); + global.sandboxData = { sandboxId, url: `https://${host}` }; + sandboxExists = true; + } catch (e) { + console.error('[sandbox-status] Reconnect failed:', e); + } + } let sandboxHealthy = false; let sandboxInfo = null; diff --git a/app/layout.tsx b/app/layout.tsx index 8c11a46a..bd305928 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,7 +5,7 @@ import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Open Lovable", + title: "Makerthrive Cloner", description: "Re-imagine any website in seconds with AI-powered website builder.", }; diff --git a/app/page.tsx b/app/page.tsx index 43b01d53..5eb40ddc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,7 +12,6 @@ import { FiFile, FiChevronRight, FiChevronDown, - FiGithub, BsFolderFill, BsFolder2Open, SiJavascript, @@ -22,6 +21,8 @@ import { } from '@/lib/icons'; import { motion, AnimatePresence } from 'framer-motion'; import CodeApplicationProgress, { type CodeApplicationState } from '@/components/CodeApplicationProgress'; +import AnimatedCodeBackground from '@/components/AnimatedCodeBackground'; +// import DemoFlow from '@/components/DemoFlow'; interface SandboxData { sandboxId: string; @@ -60,10 +61,7 @@ export default function AISandboxPage() { const [aiEnabled] = useState(true); const searchParams = useSearchParams(); const router = useRouter(); - const [aiModel, setAiModel] = useState(() => { - const modelParam = searchParams.get('model'); - return appConfig.ai.availableModels.includes(modelParam || '') ? modelParam! : appConfig.ai.defaultModel; - }); + const [aiModel] = useState(appConfig.ai.defaultModel); const [urlOverlayVisible, setUrlOverlayVisible] = useState(false); const [urlInput, setUrlInput] = useState(''); const [urlStatus, setUrlStatus] = useState([]); @@ -151,19 +149,39 @@ export default function AISandboxPage() { // Check if sandbox ID is in URL const sandboxIdParam = searchParams.get('sandbox'); - + if (sandboxIdParam) { - // Try to restore existing sandbox + // Try to restore existing sandbox by reconnecting on the server console.log('[home] Attempting to restore sandbox:', sandboxIdParam); setLoading(true); try { - // For now, just create a new sandbox - you could enhance this to actually restore - // the specific sandbox if your backend supports it - await createSandbox(true); + const res = await fetch(`/api/sandbox-status?sandbox=${encodeURIComponent(sandboxIdParam)}`); + const data = await res.json(); + + if (res.ok && data?.active && data?.sandboxData?.sandboxId) { + setSandboxData(data.sandboxData); + updateStatus('Sandbox active', true); + + // Ensure URL reflects the restored sandbox ID and selected model + const newParams = new URLSearchParams(searchParams.toString()); + newParams.set('sandbox', data.sandboxData.sandboxId); + newParams.set('model', aiModel); + router.push(`/?${newParams.toString()}`, { scroll: false }); + + // Fade out loading background after restore + setTimeout(() => { + setShowLoadingBackground(false); + }, 1000); + } else { + // Fall back to creating a new sandbox if restore fails + await createSandbox(true); + } } catch (error) { console.error('[ai-sandbox] Failed to restore sandbox:', error); // Create new sandbox on error await createSandbox(true); + } finally { + setLoading(false); } } else { // Automatically create new sandbox @@ -334,7 +352,7 @@ export default function AISandboxPage() { const checkSandboxStatus = async () => { try { - const response = await fetch('/api/sandbox-status'); + const response = await fetch(`/api/sandbox-status${sandboxData?.sandboxId ? `?sandbox=${sandboxData.sandboxId}` : ''}`); const data = await response.json(); if (data.active && data.healthy && data.sandboxData) { @@ -405,7 +423,7 @@ export default function AISandboxPage() { const restartResponse = await fetch('/api/restart-vite', { method: 'POST', headers: { 'Content-Type': 'application/json' } - }); + , body: JSON.stringify({ sandboxId: data.sandboxId }) }); if (restartResponse.ok) { const restartData = await restartResponse.json(); @@ -425,9 +443,10 @@ export default function AISandboxPage() { Tip: I automatically detect and install npm packages from your code imports (like react-router-dom, axios, etc.)`, 'system'); } + // Reload cross-origin iframe by resetting its src with a cache-buster setTimeout(() => { if (iframeRef.current) { - iframeRef.current.src = data.url; + iframeRef.current.src = `${data.url}?t=${Date.now()}`; } }, 100); } else { @@ -768,13 +787,9 @@ Tip: I automatically detect and install npm packages from your code imports (lik // Method 2: Force reload after a short delay setTimeout(() => { - try { - if (iframeRef.current?.contentWindow) { - iframeRef.current.contentWindow.location.reload(); - console.log('[home] Force reloaded iframe content'); - } - } catch (e) { - console.log('[home] Could not reload iframe (cross-origin):', e); + // Cross-origin: reset src instead of accessing contentWindow + if (iframeRef.current && sandboxData?.url) { + iframeRef.current.src = `${sandboxData.url}?t=${Date.now()}&forceReload=1`; } }, 1000); } @@ -907,7 +922,8 @@ Tip: I automatically detect and install npm packages from your code imports (lik const response = await fetch('/api/restart-vite', { method: 'POST', - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sandboxId: sandboxData?.sandboxId }) }); if (response.ok) { @@ -1393,7 +1409,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik ref={iframeRef} src={sandboxData.url} className="w-full h-full border-none" - title="Open Lovable Sandbox" + title="Makerthrive Cloner Sandbox" allow="clipboard-write" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals" /> @@ -2115,7 +2131,7 @@ Focus on the key sections and content, making it clean and modern while preservi prompt: recreatePrompt, model: aiModel, context: { - sandboxId: sandboxData?.id, + sandboxId: sandboxData?.sandboxId, structure: structureContent, conversationContext: conversationContext } @@ -2726,297 +2742,223 @@ Focus on the key sections and content, making it clean and modern.`; return (
{/* Home Screen Overlay */} - {showHomeScreen && ( -
- {/* Simple Sun Gradient Background */} -
- {/* Main Sun - Pulsing */} -
- - {/* Inner Sun Core - Brighter */} -
- - {/* Outer Glow - Subtle */} -
- - {/* Giant Glowing Orb - Center Bottom */} -
-
-
-
-
-
-
+
+ {/* Animated code background */} +
+ +
+ + + {/* Close button on hover */} + + + {/* Header */} +
+ devs.dev +
+ + {/* Main content */} +
+
+ {/* Firecrawl-style Header */} +
+

+ Clone any website + Clone any website + _ +

+ + Prompt visual changes. Get a clean React app you can run and deploy. +
-
- - - {/* Close button on hover */} - - - {/* Header */} - - - {/* Main content */} -
-
- {/* Firecrawl-style Header */} -
-

- Open Lovable - Open Lovable -

- +
+ { + const value = e.target.value; + setHomeUrlInput(value); + + // Check if it's a valid domain + const domainRegex = /^(https?:\/\/)?(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(\/?.*)?$/; + if (domainRegex.test(value) && value.length > 5) { + // Small delay to make the animation feel smoother + setTimeout(() => setShowStyleSelector(true), 100); + } else { + setShowStyleSelector(false); + setSelectedStyle(null); + } + }} + placeholder=" " + aria-placeholder="https://example.com" + className="h-[3.25rem] w-full resize-none focus-visible:outline-none focus-visible:ring-orange-500 focus-visible:ring-2 rounded-[18px] text-sm text-[#36322F] px-4 pr-12 border-[.75px] border-border bg-white" + style={{ + boxShadow: '0 0 0 1px #e3e1de66, 0 1px 2px #5f4a2e14, 0 4px 6px #5f4a2e0a, 0 40px 40px -24px #684b2514', + filter: 'drop-shadow(rgba(249, 224, 184, 0.3) -0.731317px -0.731317px 35.6517px)' }} - transition={{ duration: 0.3, ease: "easeOut" }} + autoFocus + /> + - -
-
- { - const value = e.target.value; - setHomeUrlInput(value); - - // Check if it's a valid domain - const domainRegex = /^(https?:\/\/)?(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(\/?.*)?$/; - if (domainRegex.test(value) && value.length > 5) { - // Small delay to make the animation feel smoother - setTimeout(() => setShowStyleSelector(true), 100); - } else { - setShowStyleSelector(false); - setSelectedStyle(null); - } - }} - placeholder=" " - aria-placeholder="https://firecrawl.dev" - className="h-[3.25rem] w-full resize-none focus-visible:outline-none focus-visible:ring-orange-500 focus-visible:ring-2 rounded-[18px] text-sm text-[#36322F] px-4 pr-12 border-[.75px] border-border bg-white" - style={{ - boxShadow: '0 0 0 1px #e3e1de66, 0 1px 2px #5f4a2e14, 0 4px 6px #5f4a2e0a, 0 40px 40px -24px #684b2514', - filter: 'drop-shadow(rgba(249, 224, 184, 0.3) -0.731317px -0.731317px 35.6517px)' - }} - autoFocus - /> - - + + https://example.com +
- - {/* Style Selector - Slides out when valid domain is entered */} - {showStyleSelector && ( -
-
-
-

How do you want your site to look?

-
- {[ - { name: 'Neobrutalist', description: 'Bold colors, thick borders' }, - { name: 'Glassmorphism', description: 'Frosted glass effects' }, - { name: 'Minimalist', description: 'Clean and simple' }, - { name: 'Dark Mode', description: 'Dark theme' }, - { name: 'Gradient', description: 'Colorful gradients' }, - { name: 'Retro', description: '80s/90s aesthetic' }, - { name: 'Modern', description: 'Contemporary design' }, - { name: 'Monochrome', description: 'Black and white' } - ].map((style) => ( - - ))} -
- - {/* Additional context input - part of the style selector */} -
- { - if (!selectedStyle) return homeContextInput; - // Extract additional context by removing the style theme part - const additional = homeContextInput.replace(new RegExp('^' + selectedStyle.toLowerCase() + ' theme\\s*,?\\s*', 'i'), ''); - return additional; - })()} - onChange={(e) => { - const additionalContext = e.target.value; - if (selectedStyle) { - setHomeContextInput(selectedStyle.toLowerCase() + ' theme' + (additionalContext.trim() ? ', ' + additionalContext : '')); - } else { - setHomeContextInput(additionalContext); - } - }} + +
+ + {/* Style Selector - Slides out when valid domain is entered */} + {showStyleSelector && ( +
+
+
+

How do you want your site to look?

+
+ {[ + { name: 'Neobrutalist', description: 'Bold colors, thick borders' }, + { name: 'Glassmorphism', description: 'Frosted glass effects' }, + { name: 'Minimalist', description: 'Clean and simple' }, + { name: 'Dark Mode', description: 'Dark theme' }, + { name: 'Gradient', description: 'Colorful gradients' }, + { name: 'Retro', description: '80s/90s aesthetic' }, + { name: 'Modern', description: 'Contemporary design' }, + { name: 'Monochrome', description: 'Black and white' } + ].map((style) => ( +
+ onClick={() => { + if (selectedStyle === style.name) { + // Deselect if clicking the same style + setSelectedStyle(null); + // Keep only additional context, remove the style theme part + const currentAdditional = homeContextInput.replace(/^[^,]+theme\s*,?\s*/, '').trim(); + setHomeContextInput(currentAdditional); + } else { + // Select new style + setSelectedStyle(style.name); + // Extract any additional context (everything after the style theme) + const currentAdditional = homeContextInput.replace(/^[^,]+theme\s*,?\s*/, '').trim(); + setHomeContextInput(style.name.toLowerCase() + ' theme' + (currentAdditional ? ', ' + currentAdditional : '')); + } + }} + className={`p-3 rounded-lg border transition-all ${ + selectedStyle === style.name + ? 'border-orange-400 bg-orange-50 text-gray-900 shadow-sm' + : 'border-gray-200 bg-white hover:border-orange-200 hover:bg-orange-50/50 text-gray-700' + }`} + > +
{style.name}
+
{style.description}
+ + ))}
-
+ + {/* Additional context input - part of the style selector */} +
+ { + if (!selectedStyle) return homeContextInput; + // Extract additional context by removing the style theme part + const additional = homeContextInput.replace(new RegExp('^' + selectedStyle.toLowerCase() + ' theme\\s*,?\\s*', 'i'), ''); + return additional; + })()} + onChange={(e) => { + const additionalContext = e.target.value; + if (selectedStyle) { + setHomeContextInput(selectedStyle.toLowerCase() + ' theme' + (additionalContext.trim() ? ', ' + additionalContext : '')); + } else { + setHomeContextInput(additionalContext); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const form = e.currentTarget.closest('form'); + if (form) { + form.requestSubmit(); + } + } + }} + placeholder="Add more details: specific features, color preferences..." + className="w-full px-4 py-2 text-sm bg-white border border-gray-200 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:border-orange-300 focus:ring-2 focus:ring-orange-100 transition-all duration-200" + />
- )} - - - {/* Model Selector */} -
- -
+
+
+
+ )} + + + {/** Demo disabled for now **/} + {/* +
+
+ */}
- )} +
+ {!showHomeScreen && (
- Firecrawl + devs.dev
- {/* Model Selector - Left side */} - + {/* Model Selector removed - model is forced in config */}
+ )}
{/* Center Panel - AI Chat (1/3 of remaining width) */} @@ -3406,10 +3349,6 @@ Focus on the key sections and content, making it clean and modern.`;
- - - -
); } \ No newline at end of file diff --git a/components/AnimatedCodeBackground.tsx b/components/AnimatedCodeBackground.tsx new file mode 100644 index 00000000..19dad7e0 --- /dev/null +++ b/components/AnimatedCodeBackground.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface AnimatedCodeBackgroundProps { + className?: string; + density?: number; // characters per 100px width + speed?: number; // pixels per frame + color?: string; // rgba color for glyphs +} + +// Subtle moving code background rendered on a canvas. Optimized for low CPU usage. +export default function AnimatedCodeBackground({ + className, + density = 1.2, + speed = 1.2, + color = 'rgba(31,41,55,0.08)', // slate-800 @ 8% +}: AnimatedCodeBackgroundProps) { + const canvasRef = useRef(null); + const rafRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Setup size and DPR + const setSize = () => { + const { innerWidth, innerHeight, devicePixelRatio } = window; + const dpr = Math.min(devicePixelRatio || 1, 2); + canvas.width = Math.floor(innerWidth * dpr); + canvas.height = Math.floor(innerHeight * dpr); + canvas.style.width = `${innerWidth}px`; + canvas.style.height = `${innerHeight}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + setSize(); + + const chars = '{ } ( ) [ ] < > = + - * / ! & | ^ : ; , . 0 1 2 3 4 5 6 7 8 9 a b c d e f g h i j k l m n o p q r s t u v w x y z'.split(' '); + + type Stream = { + x: number; // column x in px + y: number; // current y in px + fontSize: number; // px + step: number; // drift speed multiplier + }; + + let streams: Stream[] = []; + + const initStreams = () => { + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const baseFont = 14; // base font size in px + const columns = Math.max(16, Math.floor((width / 100) * density * 10)); + streams = new Array(columns).fill(0).map((_, i) => { + const fontSize = baseFont + Math.floor(Math.random() * 6); // 14-20 + const x = Math.floor((i + Math.random() * 0.5) * (width / columns)); + const y = Math.floor(-Math.random() * height); + const step = 0.75 + Math.random() * 0.75; // 0.75 - 1.5 + return { x, y, fontSize, step }; + }); + }; + initStreams(); + + let frame = 0; + const draw = () => { + frame++; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + + // Fade the canvas slightly to create trailing effect + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = color; + ctx.textBaseline = 'top'; + + for (let i = 0; i < streams.length; i++) { + const s = streams[i]; + ctx.font = `${s.fontSize}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`; + // Draw 2-3 characters per column each frame for subtle density + const count = 2 + (frame % 3 === 0 ? 1 : 0); + for (let j = 0; j < count; j++) { + const ch = chars[(Math.random() * chars.length) | 0]; + const jitterX = (Math.random() - 0.5) * 2; // tiny jitter + ctx.fillText(ch, s.x + jitterX, s.y + j * s.fontSize); + } + s.y += speed * s.step * s.fontSize * 0.35; + if (s.y > height + 40) { + s.y = -Math.random() * height * 0.5; + } + } + + rafRef.current = requestAnimationFrame(draw); + }; + + rafRef.current = requestAnimationFrame(draw); + + const handleResize = () => { + setSize(); + initStreams(); + }; + window.addEventListener('resize', handleResize); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + window.removeEventListener('resize', handleResize); + }; + }, [color, density, speed]); + + return ( +