|
| 1 | +import { useRef, useEffect, useState } from 'react' |
| 2 | +import { motion, AnimatePresence } from 'framer-motion' |
| 3 | +import { Search, Loader2 } from 'lucide-react' |
| 4 | +import { HeroSearch, type HeroSearchHandle } from './HeroSearch' |
| 5 | +import { useDemoSearch, DEMO_REPOS, type DemoRepo } from '@/hooks/useDemoSearch' |
| 6 | +import type { SearchResult } from '@/types' |
| 7 | + |
| 8 | +interface Props { |
| 9 | + onResultsReady?: (results: SearchResult[], query: string, repoId: string, time: number | null) => void |
| 10 | +} |
| 11 | + |
| 12 | +const PYTHON_REPOS = DEMO_REPOS.filter(r => ['flask', 'fastapi'].includes(r.id)) |
| 13 | + |
| 14 | +export function Hero({ onResultsReady }: Props) { |
| 15 | + const searchRef = useRef<HeroSearchHandle>(null) |
| 16 | + const cardRef = useRef<HTMLDivElement>(null) |
| 17 | + const { query, repo, results, loading, searchTime, setQuery, setRepo, search } = useDemoSearch(false) |
| 18 | + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) |
| 19 | + |
| 20 | + useEffect(() => { |
| 21 | + if (results.length) onResultsReady?.(results, query, repo.id, searchTime) |
| 22 | + }, [results, query, repo.id, searchTime, onResultsReady]) |
| 23 | + |
| 24 | + useEffect(() => { |
| 25 | + const onKey = (e: KeyboardEvent) => { |
| 26 | + const tag = (e.target as HTMLElement).tagName |
| 27 | + if (e.key === '/' && tag !== 'INPUT' && tag !== 'TEXTAREA') { |
| 28 | + e.preventDefault() |
| 29 | + searchRef.current?.focus() |
| 30 | + } |
| 31 | + } |
| 32 | + window.addEventListener('keydown', onKey) |
| 33 | + return () => window.removeEventListener('keydown', onKey) |
| 34 | + }, []) |
| 35 | + |
| 36 | + const handleMouseMove = (e: React.MouseEvent) => { |
| 37 | + if (!cardRef.current) return |
| 38 | + const rect = cardRef.current.getBoundingClientRect() |
| 39 | + setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }) |
| 40 | + } |
| 41 | + |
| 42 | + const switchRepo = (r: DemoRepo) => { |
| 43 | + setRepo(r) |
| 44 | + } |
| 45 | + |
| 46 | + const topResult = results[0] |
| 47 | + |
| 48 | + return ( |
| 49 | + <section className="relative min-h-screen flex flex-col justify-center pt-20 pb-12 px-6 overflow-hidden"> |
| 50 | + {/* Animated gradient orbs - Linear style */} |
| 51 | + <div className="absolute inset-0 overflow-hidden pointer-events-none"> |
| 52 | + <motion.div |
| 53 | + className="absolute top-1/4 left-1/4 w-[500px] h-[500px] rounded-full" |
| 54 | + style={{ background: 'radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)' }} |
| 55 | + animate={{ x: [0, 30, 0], y: [0, -20, 0], scale: [1, 1.1, 1] }} |
| 56 | + transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }} |
| 57 | + /> |
| 58 | + <motion.div |
| 59 | + className="absolute top-1/3 right-1/4 w-[400px] h-[400px] rounded-full" |
| 60 | + style={{ background: 'radial-gradient(circle, rgba(139,92,246,0.1) 0%, transparent 70%)' }} |
| 61 | + animate={{ x: [0, -25, 0], y: [0, 25, 0], scale: [1, 0.9, 1] }} |
| 62 | + transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }} |
| 63 | + /> |
| 64 | + <motion.div |
| 65 | + className="absolute bottom-1/4 left-1/3 w-[350px] h-[350px] rounded-full" |
| 66 | + style={{ background: 'radial-gradient(circle, rgba(34,211,238,0.08) 0%, transparent 70%)' }} |
| 67 | + animate={{ x: [0, 20, 0], y: [0, -15, 0] }} |
| 68 | + transition={{ duration: 12, repeat: Infinity, ease: 'easeInOut' }} |
| 69 | + /> |
| 70 | + </div> |
| 71 | + |
| 72 | + <div className="relative max-w-3xl mx-auto w-full"> |
| 73 | + {/* Headline */} |
| 74 | + <motion.div |
| 75 | + className="text-center mb-10" |
| 76 | + initial={{ opacity: 0, y: 20 }} |
| 77 | + animate={{ opacity: 1, y: 0 }} |
| 78 | + transition={{ duration: 0.5 }} |
| 79 | + > |
| 80 | + <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white leading-[1.1] tracking-tight"> |
| 81 | + Find code by meaning, |
| 82 | + <br /> |
| 83 | + <span className="bg-gradient-to-r from-blue-400 via-violet-400 to-cyan-400 bg-clip-text text-transparent"> |
| 84 | + not by keywords. |
| 85 | + </span> |
| 86 | + </h1> |
| 87 | + <p className="mt-5 text-lg text-zinc-400 max-w-lg mx-auto"> |
| 88 | + Stop grep-ing through thousands of files. |
| 89 | + <br /> |
| 90 | + Describe what you need and get the exact function. |
| 91 | + </p> |
| 92 | + </motion.div> |
| 93 | + |
| 94 | + {/* Search */} |
| 95 | + <motion.div |
| 96 | + initial={{ opacity: 0, y: 20 }} |
| 97 | + animate={{ opacity: 1, y: 0 }} |
| 98 | + transition={{ duration: 0.5, delay: 0.1 }} |
| 99 | + > |
| 100 | + <HeroSearch |
| 101 | + ref={searchRef} |
| 102 | + value={query} |
| 103 | + onChange={setQuery} |
| 104 | + onSubmit={() => search()} |
| 105 | + searching={loading} |
| 106 | + repoName={repo.name} |
| 107 | + /> |
| 108 | + </motion.div> |
| 109 | + |
| 110 | + {/* Repo switcher */} |
| 111 | + <motion.div |
| 112 | + className="mt-4 flex items-center justify-center gap-2" |
| 113 | + initial={{ opacity: 0 }} |
| 114 | + animate={{ opacity: 1 }} |
| 115 | + transition={{ duration: 0.4, delay: 0.2 }} |
| 116 | + > |
| 117 | + <span className="text-xs text-zinc-600">Try on:</span> |
| 118 | + {PYTHON_REPOS.map(r => ( |
| 119 | + <button |
| 120 | + key={r.id} |
| 121 | + onClick={() => switchRepo(r)} |
| 122 | + disabled={loading} |
| 123 | + className={` |
| 124 | + px-3 py-1.5 text-xs rounded-lg transition-all font-medium |
| 125 | + ${repo.id === r.id |
| 126 | + ? 'bg-white/10 text-white border border-white/10' |
| 127 | + : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5' |
| 128 | + } |
| 129 | + `} |
| 130 | + > |
| 131 | + {r.name} |
| 132 | + </button> |
| 133 | + ))} |
| 134 | + </motion.div> |
| 135 | + |
| 136 | + {/* Result card - only shows when loading or has results */} |
| 137 | + <AnimatePresence> |
| 138 | + {(loading || topResult) && ( |
| 139 | + <motion.div |
| 140 | + className="mt-8" |
| 141 | + initial={{ opacity: 0, y: 20, height: 0 }} |
| 142 | + animate={{ opacity: 1, y: 0, height: 'auto' }} |
| 143 | + exit={{ opacity: 0, y: -10, height: 0 }} |
| 144 | + transition={{ duration: 0.3 }} |
| 145 | + > |
| 146 | + <div |
| 147 | + ref={cardRef} |
| 148 | + onMouseMove={handleMouseMove} |
| 149 | + className="relative group" |
| 150 | + > |
| 151 | + {/* Mouse glow effect */} |
| 152 | + <div |
| 153 | + className="absolute -inset-px rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" |
| 154 | + style={{ |
| 155 | + background: `radial-gradient(400px circle at ${mousePos.x}px ${mousePos.y}px, rgba(59,130,246,0.15), transparent 40%)` |
| 156 | + }} |
| 157 | + /> |
| 158 | + |
| 159 | + {/* Card */} |
| 160 | + <div className="relative rounded-xl border border-white/[0.08] bg-zinc-900/50 backdrop-blur-sm overflow-hidden"> |
| 161 | + <AnimatePresence mode="wait"> |
| 162 | + {loading ? ( |
| 163 | + <motion.div |
| 164 | + key="loading" |
| 165 | + initial={{ opacity: 0 }} |
| 166 | + animate={{ opacity: 1 }} |
| 167 | + exit={{ opacity: 0 }} |
| 168 | + className="p-6" |
| 169 | + > |
| 170 | + <div className="flex items-center gap-3 mb-4"> |
| 171 | + <Loader2 className="w-4 h-4 animate-spin text-blue-400" /> |
| 172 | + <span className="text-sm text-zinc-500">Searching {repo.name}...</span> |
| 173 | + </div> |
| 174 | + <div className="space-y-3 animate-pulse"> |
| 175 | + <div className="h-5 w-48 bg-white/5 rounded" /> |
| 176 | + <div className="h-3 w-32 bg-white/5 rounded" /> |
| 177 | + <div className="h-24 bg-white/[0.02] rounded-lg mt-3" /> |
| 178 | + </div> |
| 179 | + </motion.div> |
| 180 | + ) : topResult ? ( |
| 181 | + <motion.div |
| 182 | + key="result" |
| 183 | + initial={{ opacity: 0 }} |
| 184 | + animate={{ opacity: 1 }} |
| 185 | + exit={{ opacity: 0 }} |
| 186 | + > |
| 187 | + {/* Header */} |
| 188 | + <div className="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between bg-white/[0.02]"> |
| 189 | + <div className="flex items-center gap-3"> |
| 190 | + <div className="flex items-center gap-2"> |
| 191 | + <div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> |
| 192 | + <span className="text-xs text-zinc-500">Found in {searchTime}ms</span> |
| 193 | + </div> |
| 194 | + <span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 font-medium"> |
| 195 | + {Math.round(topResult.score * 100)}% match |
| 196 | + </span> |
| 197 | + </div> |
| 198 | + <span className="text-xs text-zinc-600">{repo.name}</span> |
| 199 | + </div> |
| 200 | + |
| 201 | + {/* Content */} |
| 202 | + <div className="p-5"> |
| 203 | + <div className="flex items-start gap-3 mb-4"> |
| 204 | + <div className="flex-1"> |
| 205 | + <div className="flex items-center gap-2"> |
| 206 | + <span className="font-mono text-sm font-semibold text-white">{topResult.name}</span> |
| 207 | + <span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400 uppercase font-medium"> |
| 208 | + {topResult.type} |
| 209 | + </span> |
| 210 | + </div> |
| 211 | + <div className="text-xs text-zinc-600 font-mono mt-1">{topResult.file_path}</div> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + |
| 215 | + {/* Code preview */} |
| 216 | + <div className="relative rounded-lg overflow-hidden"> |
| 217 | + <div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-violet-500/5" /> |
| 218 | + <pre className="relative text-xs text-zinc-300 bg-black/40 p-4 overflow-x-auto font-mono leading-relaxed"> |
| 219 | + <code>{topResult.content?.slice(0, 250)}...</code> |
| 220 | + </pre> |
| 221 | + </div> |
| 222 | + </div> |
| 223 | + |
| 224 | + {/* Footer */} |
| 225 | + {results.length > 1 && ( |
| 226 | + <div className="px-5 py-3 border-t border-white/[0.06] bg-white/[0.01]"> |
| 227 | + <span className="text-xs text-zinc-600">+{results.length - 1} more results</span> |
| 228 | + </div> |
| 229 | + )} |
| 230 | + </motion.div> |
| 231 | + ) : null} |
| 232 | + </AnimatePresence> |
| 233 | + </div> |
| 234 | + </div> |
| 235 | + </motion.div> |
| 236 | + )} |
| 237 | + </AnimatePresence> |
| 238 | + |
| 239 | + {/* CTA */} |
| 240 | + <motion.div |
| 241 | + className="mt-10 text-center" |
| 242 | + initial={{ opacity: 0 }} |
| 243 | + animate={{ opacity: 1 }} |
| 244 | + transition={{ delay: 0.4 }} |
| 245 | + > |
| 246 | + <a |
| 247 | + href="/signup" |
| 248 | + className="inline-flex items-center gap-2 px-6 py-3 text-sm font-medium text-zinc-300 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:text-white hover:bg-white/5 transition-all" |
| 249 | + > |
| 250 | + Index your first repo free → |
| 251 | + </a> |
| 252 | + <p className="text-xs text-zinc-500 mt-4"> |
| 253 | + Works with any Python repository • Now in beta |
| 254 | + </p> |
| 255 | + </motion.div> |
| 256 | + </div> |
| 257 | + </section> |
| 258 | + ) |
| 259 | +} |
0 commit comments