diff --git a/frontend/src/components/landing/CompactSearchBar.tsx b/frontend/src/components/landing/CompactSearchBar.tsx new file mode 100644 index 0000000..678b69c --- /dev/null +++ b/frontend/src/components/landing/CompactSearchBar.tsx @@ -0,0 +1,70 @@ +import { motion } from 'framer-motion' +import { Search, ArrowLeft, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface Props { + query: string + onQueryChange: (q: string) => void + onSearch: () => void + onBack: () => void + loading: boolean + remaining: number +} + +export function CompactSearchBar({ query, onQueryChange, onSearch, onBack, loading, remaining }: Props) { + const canSearch = query.trim() && !loading && remaining > 0 + + const submit = (e: React.FormEvent) => { + e.preventDefault() + if (canSearch) onSearch() + } + + return ( + +
+
+ + +
+
+
+ {loading ? : } +
+ onQueryChange(e.target.value)} + placeholder="Search again..." + className={cn( + "w-full bg-zinc-900/80 border rounded-xl pl-12 pr-4 py-3 text-white placeholder:text-zinc-500 focus:outline-none transition-all", + loading ? "border-indigo-500/50 shadow-lg shadow-indigo-500/20" : "border-zinc-800 focus:border-zinc-700" + )} + /> +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/components/landing/Hero.tsx b/frontend/src/components/landing/Hero.tsx new file mode 100644 index 0000000..d776570 --- /dev/null +++ b/frontend/src/components/landing/Hero.tsx @@ -0,0 +1,259 @@ +import { useRef, useEffect, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Search, Loader2 } from 'lucide-react' +import { HeroSearch, type HeroSearchHandle } from './HeroSearch' +import { useDemoSearch, DEMO_REPOS, type DemoRepo } from '@/hooks/useDemoSearch' +import type { SearchResult } from '@/types' + +interface Props { + onResultsReady?: (results: SearchResult[], query: string, repoId: string, time: number | null) => void +} + +const PYTHON_REPOS = DEMO_REPOS.filter(r => ['flask', 'fastapi'].includes(r.id)) + +export function Hero({ onResultsReady }: Props) { + const searchRef = useRef(null) + const cardRef = useRef(null) + const { query, repo, results, loading, searchTime, setQuery, setRepo, search } = useDemoSearch(false) + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + + useEffect(() => { + if (results.length) onResultsReady?.(results, query, repo.id, searchTime) + }, [results, query, repo.id, searchTime, onResultsReady]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (e.key === '/' && tag !== 'INPUT' && tag !== 'TEXTAREA') { + e.preventDefault() + searchRef.current?.focus() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + const handleMouseMove = (e: React.MouseEvent) => { + if (!cardRef.current) return + const rect = cardRef.current.getBoundingClientRect() + setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }) + } + + const switchRepo = (r: DemoRepo) => { + setRepo(r) + } + + const topResult = results[0] + + return ( +
+ {/* Animated gradient orbs - Linear style */} +
+ + + +
+ +
+ {/* Headline */} + +

+ Find code by meaning, +
+ + not by keywords. + +

+

+ Stop grep-ing through thousands of files. +
+ Describe what you need and get the exact function. +

+
+ + {/* Search */} + + search()} + searching={loading} + repoName={repo.name} + /> + + + {/* Repo switcher */} + + Try on: + {PYTHON_REPOS.map(r => ( + + ))} + + + {/* Result card - only shows when loading or has results */} + + {(loading || topResult) && ( + +
+ {/* Mouse glow effect */} +
+ + {/* Card */} +
+ + {loading ? ( + +
+ + Searching {repo.name}... +
+
+
+
+
+
+ + ) : topResult ? ( + + {/* Header */} +
+
+
+
+ Found in {searchTime}ms +
+ + {Math.round(topResult.score * 100)}% match + +
+ {repo.name} +
+ + {/* Content */} +
+
+
+
+ {topResult.name} + + {topResult.type} + +
+
{topResult.file_path}
+
+
+ + {/* Code preview */} +
+
+
+                              {topResult.content?.slice(0, 250)}...
+                            
+
+
+ + {/* Footer */} + {results.length > 1 && ( +
+ +{results.length - 1} more results +
+ )} + + ) : null} + +
+
+
+ )} + + + {/* CTA */} + + + Index your first repo free → + +

+ Works with any Python repository • Now in beta +

+
+
+
+ ) +} diff --git a/frontend/src/components/landing/HeroSearch.tsx b/frontend/src/components/landing/HeroSearch.tsx new file mode 100644 index 0000000..2f4ca03 --- /dev/null +++ b/frontend/src/components/landing/HeroSearch.tsx @@ -0,0 +1,183 @@ +import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react' +import { motion } from 'framer-motion' +import { Search, X, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +type VisualState = 'idle' | 'focused' | 'searching' | 'done' + +const glowStyles: Record = { + idle: 'shadow-[0_0_0_1px_rgba(75,139,190,0.3)]', + focused: 'shadow-[0_0_0_2px_var(--python-blue),0_0_20px_rgba(75,139,190,0.4)]', + searching: 'shadow-[0_0_0_2px_var(--python-yellow),0_0_30px_rgba(255,212,59,0.3)]', + done: 'shadow-[0_0_0_2px_#34D399,0_0_20px_rgba(52,211,153,0.4)]', +} + +const EXAMPLE_QUERIES = [ + 'authentication decorator', + 'error handling middleware', + 'database connection pool', + 'request validation logic', + 'caching implementation', +] + +function useTypewriter(phrases: string[], typingSpeed = 80, deleteSpeed = 40, pauseDuration = 2000) { + const [text, setText] = useState('') + const [phraseIndex, setPhraseIndex] = useState(0) + const [isDeleting, setIsDeleting] = useState(false) + + useEffect(() => { + const currentPhrase = phrases[phraseIndex] + + const timeout = setTimeout(() => { + if (!isDeleting) { + if (text.length < currentPhrase.length) { + setText(currentPhrase.slice(0, text.length + 1)) + } else { + setTimeout(() => setIsDeleting(true), pauseDuration) + } + } else { + if (text.length > 0) { + setText(text.slice(0, -1)) + } else { + setIsDeleting(false) + setPhraseIndex((prev) => (prev + 1) % phrases.length) + } + } + }, isDeleting ? deleteSpeed : typingSpeed) + + return () => clearTimeout(timeout) + }, [text, phraseIndex, isDeleting, phrases, typingSpeed, deleteSpeed, pauseDuration]) + + return text +} + +interface Props { + value: string + onChange: (value: string) => void + onSubmit: () => void + searching?: boolean + repoName?: string +} + +export interface HeroSearchHandle { + focus: () => void +} + +export const HeroSearch = forwardRef(function HeroSearch( + { value, onChange, onSubmit, searching = false, repoName = 'flask' }, + ref +) { + const inputRef = useRef(null) + const [focused, setFocused] = useState(false) + const [showDone, setShowDone] = useState(false) + const animatedPlaceholder = useTypewriter(EXAMPLE_QUERIES) + + useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus() })) + + useEffect(() => { + if (searching) return + if (!value) return + + setShowDone(true) + const t = setTimeout(() => setShowDone(false), 800) + return () => clearTimeout(t) + }, [searching, value]) + + const state: VisualState = searching ? 'searching' + : showDone ? 'done' + : focused ? 'focused' + : 'idle' + + const submit = (e: React.FormEvent) => { + e.preventDefault() + if (value.trim()) onSubmit() + } + + const showAnimatedPlaceholder = !value && !focused + + return ( +
+ + {/* Shimmer border effect */} +
+
+
+ + {searching && ( + + )} + +
+
+ {searching ? : } +
+ +
+ onChange(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + placeholder={focused ? 'Search for anything...' : ''} + className="w-full bg-transparent text-white text-lg placeholder:text-zinc-500 focus:outline-none" + /> + {showAnimatedPlaceholder && ( +
+ + {animatedPlaceholder} + | + +
+ )} +
+ + {value && !searching && ( + + )} + + +
+ + {searching && ( +
+ Searching {repoName}... +
+ )} +
+ + ) +}) diff --git a/frontend/src/components/landing/Navbar.tsx b/frontend/src/components/landing/Navbar.tsx new file mode 100644 index 0000000..3f8d373 --- /dev/null +++ b/frontend/src/components/landing/Navbar.tsx @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' + +interface NavbarProps { + minimal?: boolean +} + +export function Navbar({ minimal }: NavbarProps) { + const navigate = useNavigate() + const [scrolled, setScrolled] = useState(false) + + useEffect(() => { + const handleScroll = () => setScrolled(window.scrollY > 20) + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + return ( + + ) +} diff --git a/frontend/src/components/landing/RepoSwitcher.tsx b/frontend/src/components/landing/RepoSwitcher.tsx new file mode 100644 index 0000000..bf0b7e9 --- /dev/null +++ b/frontend/src/components/landing/RepoSwitcher.tsx @@ -0,0 +1,37 @@ +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import type { DemoRepo } from '@/hooks/useDemoSearch' + +interface Props { + repos: DemoRepo[] + selected: DemoRepo + onSelect: (repo: DemoRepo) => void + disabled?: boolean +} + +export function RepoSwitcher({ repos, selected, onSelect, disabled }: Props) { + return ( +
+ Try: + {repos.map((repo) => ( + !disabled && onSelect(repo)} + disabled={disabled} + whileHover={disabled ? {} : { scale: 1.05 }} + whileTap={disabled ? {} : { scale: 0.95 }} + className={cn( + 'px-3 py-1.5 rounded-full text-sm font-medium border transition-colors', + repo.id === selected.id + ? 'bg-[var(--python-blue)] text-white border-[var(--python-blue)]' + : 'bg-zinc-900/50 border-zinc-800 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300', + disabled && 'opacity-50 cursor-not-allowed' + )} + > + {repo.icon} + {repo.name} + + ))} +
+ ) +} diff --git a/frontend/src/components/landing/ResultCard.tsx b/frontend/src/components/landing/ResultCard.tsx new file mode 100644 index 0000000..ea2609d --- /dev/null +++ b/frontend/src/components/landing/ResultCard.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react' +import { motion } from 'framer-motion' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import type { SearchResult } from '@/types' + +const PREVIEW_LINES = 8 + +// stagger cards so they don't all pop in at once +const entrance = { + hidden: { opacity: 0, y: 40, scale: 0.95 }, + visible: (i: number) => ({ + opacity: 1, + y: 0, + scale: 1, + transition: { type: 'spring', damping: 25, stiffness: 200, delay: i * 0.1 } + }), +} + +function scoreColor(score: number) { + if (score >= 0.7) return { text: 'from-emerald-400 to-green-500', bg: 'bg-emerald-500/10 border-emerald-500/20' } + if (score >= 0.5) return { text: 'from-blue-400 to-indigo-500', bg: 'bg-blue-500/10 border-blue-500/20' } + return { text: 'from-amber-400 to-orange-500', bg: 'bg-amber-500/10 border-amber-500/20' } +} + +interface Props { + result: SearchResult + index: number +} + +export function ResultCard({ result, index }: Props) { + const [expanded, setExpanded] = useState(false) + const lines = result.code.split('\n') + const truncated = lines.length > PREVIEW_LINES + const code = expanded ? result.code : lines.slice(0, PREVIEW_LINES).join('\n') + const colors = scoreColor(result.score) + + return ( + + +
+
+
+

+ {result.name} +

+ + {result.type.replace('_', ' ')} + +
+

{result.file_path}

+
+ +
+
+ {(result.score * 100).toFixed(0)}% +
+
match
+
+
+ +
+
+ + {code} + +
+ + {truncated && ( + setExpanded(!expanded)} + /> + )} +
+
+
+ ) +} + +function ExpandButton({ expanded, remaining, onClick }: { expanded: boolean; remaining: number; onClick: () => void }) { + if (expanded) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/landing/ResultsView.tsx b/frontend/src/components/landing/ResultsView.tsx new file mode 100644 index 0000000..137322e --- /dev/null +++ b/frontend/src/components/landing/ResultsView.tsx @@ -0,0 +1,137 @@ +import { motion, AnimatePresence } from 'framer-motion' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { CompactSearchBar } from './CompactSearchBar' +import { SkeletonCard } from './SkeletonCard' +import { ResultCard } from './ResultCard' +import type { SearchResult } from '@/types' + +interface Props { + results: SearchResult[] + loading: boolean + searchTime: number | null + inputQuery: string + searchedQuery: string + isStale: boolean + remaining: number + limit: number + rateLimitError: string | null + onQueryChange: (q: string) => void + onSearch: () => void + onBack: () => void + onSignUp: () => void +} + +export function ResultsView({ + results, loading, searchTime, inputQuery, searchedQuery, isStale, + remaining, limit, rateLimitError, onQueryChange, onSearch, onBack, onSignUp +}: Props) { + return ( +
+ + +
+
+ + + {loading && ( +
+ {[0, 1, 2, 3].map(i => )} +
+ )} + + {!loading && ( + <> + {(remaining <= 0 || rateLimitError) && ( + +

You've hit the limit

+

{rateLimitError || 'Sign up for unlimited searches.'}

+ +
+ )} + + + + {results.map((result, i) => ( + + ))} + + + + {results.length === 0 && ( +
+

No results found for "{searchedQuery}"

+ +
+ )} + + )} +
+
+
+ ) +} + +function ResultsHeader({ loading, isStale, inputQuery, searchedQuery, resultCount, searchTime, remaining, limit }: { + loading: boolean + isStale: boolean + inputQuery: string + searchedQuery: string + resultCount: number + searchTime: number | null + remaining: number + limit: number +}) { + return ( +
+ {loading ? ( + + Searching for "{inputQuery}"... + + ) : isStale ? ( + + + + Press Enter for "{inputQuery}" + + (showing "{searchedQuery}") + + ) : ( +
+ + {resultCount} results for "{searchedQuery}" + + {searchTime && ( + + {searchTime > 1000 ? `${(searchTime / 1000).toFixed(1)}s` : `${searchTime}ms`} + + )} +
+ )} + {!loading && remaining > 0 && remaining < limit && ( + {remaining} left + )} +
+ ) +} diff --git a/frontend/src/components/landing/SkeletonCard.tsx b/frontend/src/components/landing/SkeletonCard.tsx new file mode 100644 index 0000000..c53b3bf --- /dev/null +++ b/frontend/src/components/landing/SkeletonCard.tsx @@ -0,0 +1,38 @@ +import { motion } from 'framer-motion' +import { Card } from '@/components/ui/card' + +interface Props { + index: number +} + +export function SkeletonCard({ index }: Props) { + return ( + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + ) +} diff --git a/frontend/src/components/landing/index.ts b/frontend/src/components/landing/index.ts new file mode 100644 index 0000000..534ca7b --- /dev/null +++ b/frontend/src/components/landing/index.ts @@ -0,0 +1,8 @@ +export { Navbar } from './Navbar' +export { Hero } from './Hero' +export { HeroSearch, type HeroSearchHandle } from './HeroSearch' +export { RepoSwitcher } from './RepoSwitcher' +export { ResultCard } from './ResultCard' +export { SkeletonCard } from './SkeletonCard' +export { CompactSearchBar } from './CompactSearchBar' +export { ResultsView } from './ResultsView' diff --git a/frontend/src/components/ui/AnimatedSection.tsx b/frontend/src/components/ui/AnimatedSection.tsx new file mode 100644 index 0000000..cebf3c8 --- /dev/null +++ b/frontend/src/components/ui/AnimatedSection.tsx @@ -0,0 +1,24 @@ +import { useScrollReveal } from '@/hooks/useScrollReveal' +import { cn } from '@/lib/utils' + +interface Props { + children: React.ReactNode + className?: string +} + +export function AnimatedSection({ children, className }: Props) { + const { ref, visible } = useScrollReveal() + + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/config/demo-repos.ts b/frontend/src/config/demo-repos.ts new file mode 100644 index 0000000..f685495 --- /dev/null +++ b/frontend/src/config/demo-repos.ts @@ -0,0 +1,16 @@ +// demo repos shown on landing page - these must be pre-indexed and hot in cache +// order matters: first one is the default + +export interface DemoRepo { + id: string + name: string + icon: string +} + +export const DEMO_REPOS: DemoRepo[] = [ + { id: 'flask', name: 'Flask', icon: '🐍' }, + { id: 'fastapi', name: 'FastAPI', icon: '⚡' }, + { id: 'express', name: 'Express', icon: '🟢' }, +] + +export const DEFAULT_DEMO_QUERY = 'authentication middleware patterns' diff --git a/frontend/src/hooks/useDemoSearch.ts b/frontend/src/hooks/useDemoSearch.ts new file mode 100644 index 0000000..d59af9c --- /dev/null +++ b/frontend/src/hooks/useDemoSearch.ts @@ -0,0 +1,92 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { API_URL } from '@/config/api' +import { DEMO_REPOS, type DemoRepo } from '@/config/demo-repos' +import type { SearchResult } from '@/types' + +interface SearchState { + query: string + repo: DemoRepo + results: SearchResult[] + loading: boolean + searchTime: number | null + error: string | null +} + +const initialState: SearchState = { + query: '', + repo: DEMO_REPOS[0], + results: [], + loading: false, + searchTime: null, + error: null, +} + +export function useDemoSearch(autoStart = true) { + const [state, setState] = useState(initialState) + const abortRef = useRef(null) + const didAutoStart = useRef(false) + + const search = useCallback(async (q?: string, repoId?: string) => { + const query = q ?? state.query + const repo = repoId ?? state.repo.id + if (!query.trim()) return + + // kill any pending request - user might be typing fast + abortRef.current?.abort() + abortRef.current = new AbortController() + + setState(s => ({ ...s, loading: true, error: null })) + const t0 = Date.now() + + try { + const res = await fetch(`${API_URL}/playground/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + signal: abortRef.current.signal, + body: JSON.stringify({ query, demo_repo: repo, max_results: 10 }), + }) + + const data = await res.json() + + if (data.results) { + setState(s => ({ + ...s, + results: data.results, + searchTime: data.search_time_ms || Date.now() - t0, + loading: false, + })) + } else if (data.status === 429) { + setState(s => ({ ...s, error: 'Rate limited', loading: false })) + } + } catch (err) { + // user navigated away or started new search - don't show error + if (err instanceof Error && err.name === 'AbortError') return + setState(s => ({ ...s, error: 'Search failed', loading: false })) + } + }, [state.query, state.repo.id]) + + const setQuery = useCallback((query: string) => { + setState(s => ({ ...s, query })) + }, []) + + const setRepo = useCallback((repo: DemoRepo) => { + setState(s => ({ ...s, repo })) + }, []) + + const reset = useCallback(() => setState(initialState), []) + + // fire search on mount with slight delay so user sees it "start" + // without this, results appear instantly which feels less impressive + useEffect(() => { + if (!autoStart || didAutoStart.current) return + didAutoStart.current = true + + const delay = setTimeout(() => search(), 600) + return () => clearTimeout(delay) + }, [autoStart, search]) + + return { ...state, setQuery, setRepo, search, reset } +} + +export { DEMO_REPOS, type DemoRepo } diff --git a/frontend/src/hooks/useScrollReveal.ts b/frontend/src/hooks/useScrollReveal.ts new file mode 100644 index 0000000..9e6465a --- /dev/null +++ b/frontend/src/hooks/useScrollReveal.ts @@ -0,0 +1,21 @@ +import { useState, useEffect, useRef } from 'react' + +export function useScrollReveal() { + const ref = useRef(null) + const [visible, setVisible] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) setVisible(true) }, + { threshold: 0.1, rootMargin: '0px 0px -50px 0px' } + ) + + observer.observe(el) + return () => observer.disconnect() + }, []) + + return { ref, visible } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0f5ddb4..eb5b9c4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import './styles/view-transitions.css' import { App } from './App.tsx' +import { injectPythonTheme } from './lib/python-theme' + +// inject CSS variables for Python theme colors +injectPythonTheme() // Create a client with default options const queryClient = new QueryClient({ diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index c587e4c..8cf2999 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,405 +1,27 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { motion, AnimatePresence } from 'framer-motion' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Card } from '@/components/ui/card' -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' -import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' -import { API_URL } from '../config/api' -import { HeroPlayground } from '@/components/playground' +import { Navbar, Hero, ResultsView } from '@/components/landing' +import { API_URL } from '@/config/api' import { playgroundAPI } from '@/services/playground-api' -import type { SearchResult } from '../types' -import { cn } from '@/lib/utils' -import { useViewTransition } from '@/hooks/useViewTransition' - -// Icons -const GitHubIcon = () => ( - - - -) - -const SparklesIcon = () => ( - - - -) - -const SearchIcon = () => ( - - - -) - -const ArrowLeftIcon = () => ( - - - -) - -// Scroll animation hook -function useScrollAnimation() { - const ref = useRef(null) - const [isVisible, setIsVisible] = useState(false) - - useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setIsVisible(true) - } - }, - { threshold: 0.1, rootMargin: '0px 0px -50px 0px' } - ) - - if (ref.current) { - observer.observe(ref.current) - } - - return () => observer.disconnect() - }, []) - - return { ref, isVisible } -} - -// Animated section wrapper -function AnimatedSection({ children, className = '' }: { children: React.ReactNode; className?: string }) { - const { ref, isVisible } = useScrollAnimation() - - return ( -
- {children} -
- ) -} - -// ============ COMPACT SEARCH BAR (for results view) ============ -function CompactSearchBar({ - query, - onQueryChange, - onSearch, - onNewSearch, - loading, - remaining -}: { - query: string - onQueryChange: (q: string) => void - onSearch: () => void - onNewSearch: () => void - loading: boolean - remaining: number -}) { - return ( - -
-
- {/* Back button */} - - - {/* Search input */} -
{ e.preventDefault(); onSearch(); }} className="flex-1 flex items-center gap-3"> - -
- {loading ? ( - - - - ) : ( - - )} -
- onQueryChange(e.target.value)} - placeholder="Search again..." - className={cn( - "w-full bg-zinc-900/80 border rounded-xl pl-12 pr-4 py-3 text-white placeholder:text-zinc-500 focus:outline-none transition-all duration-300", - loading - ? "border-indigo-500/50 shadow-lg shadow-indigo-500/20" - : "border-zinc-800 focus:border-zinc-700" - )} - /> -
- -
-
-
-
- ) -} - -// ============ SKELETON LOADING CARD ============ -function SkeletonCard({ index }: { index: number }) { - return ( - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - ) -} - -// ============ RESULT CARD with DRAMATIC staggered animation ============ -const resultCardVariants = { - hidden: { - opacity: 0, - y: 60, - scale: 0.9, - filter: 'blur(20px)', - rotateX: 15, - }, - visible: (index: number) => ({ - opacity: 1, - y: 0, - scale: 1, - filter: 'blur(0px)', - rotateX: 0, - transition: { - type: 'spring', - damping: 25, - stiffness: 200, - delay: index * 0.12, - } - }), - exit: { - opacity: 0, - y: -30, - scale: 0.95, - filter: 'blur(10px)', - transition: { duration: 0.25 } - } -} - -// Hover animation for cards -const cardHoverVariants = { - rest: { - scale: 1, - y: 0, - boxShadow: '0 0 0 rgba(59, 130, 246, 0)', - }, - hover: { - scale: 1.02, - y: -4, - boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4), 0 0 30px rgba(59, 130, 246, 0.1)', - transition: { - type: 'spring', - damping: 20, - stiffness: 300, - } - } -} - -function ResultCard({ result, index }: { result: SearchResult; index: number }) { - const [expanded, setExpanded] = useState(false) - const codeLines = result.code.split('\n') - const isLongCode = codeLines.length > 8 - const displayCode = expanded ? result.code : codeLines.slice(0, 8).join('\n') - - // Color based on match score - const scoreColor = result.score >= 0.7 - ? 'from-emerald-400 to-green-500' - : result.score >= 0.5 - ? 'from-blue-400 to-indigo-500' - : 'from-amber-400 to-orange-500' - - const scoreBg = result.score >= 0.7 - ? 'bg-emerald-500/10 border-emerald-500/20' - : result.score >= 0.5 - ? 'bg-blue-500/10 border-blue-500/20' - : 'bg-amber-500/10 border-amber-500/20' - - return ( - - - - {/* Header - Clean and Spacious */} -
- {/* Left: Function info */} -
-
-

- {result.name} -

- - {result.type.replace('_', ' ')} - -
-

- {result.file_path} -

-
- - {/* Right: Match Score - THE HERO */} -
-
- {(result.score * 100).toFixed(0)}% -
-
match
-
-
- - {/* Code Block - Contained with gradient fade */} -
-
- - {displayCode} - -
- - {/* Gradient fade + expand button */} - {isLongCode && !expanded && ( -
- -
- )} - - {isLongCode && expanded && ( -
- -
- )} -
-
-
-
- ) -} +import type { SearchResult } from '@/types' export function LandingPage() { const navigate = useNavigate() + const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) const [searchTime, setSearchTime] = useState(null) - const [remaining, setRemaining] = useState(50) - const [limit, setLimit] = useState(50) const [hasSearched, setHasSearched] = useState(false) - const [availableRepos, setAvailableRepos] = useState([]) - const [rateLimitError, setRateLimitError] = useState(null) - const [searchedQuery, setSearchedQuery] = useState('') // What was actually searched - const [inputQuery, setInputQuery] = useState('') // What user is typing + const [searchedQuery, setSearchedQuery] = useState('') + const [inputQuery, setInputQuery] = useState('') const [currentRepoId, setCurrentRepoId] = useState('') const [isCustomRepo, setIsCustomRepo] = useState(false) + const [remaining, setRemaining] = useState(50) + const [limit, setLimit] = useState(50) + const [rateLimitError, setRateLimitError] = useState(null) - // Check if results are stale (user typed something different) const isStale = hasSearched && !loading && inputQuery.trim() !== searchedQuery.trim() && inputQuery.trim() !== '' - // Reset to hero state - const handleNewSearch = () => { - setHasSearched(false) - setResults([]) - setSearchTime(null) - setSearchedQuery('') - setInputQuery('') - window.scrollTo({ top: 0, behavior: 'smooth' }) - } - - // Fetch rate limit status on mount useEffect(() => { fetch(`${API_URL}/playground/limits`, { credentials: 'include' }) .then(res => res.json()) @@ -410,474 +32,89 @@ export function LandingPage() { .catch(console.error) }, []) - useEffect(() => { - fetch(`${API_URL}/playground/repos`) - .then(res => res.json()) - .then(data => { - const available = data.repos?.filter((r: any) => r.available).map((r: any) => r.id) || [] - setAvailableRepos(available) - }) - .catch(console.error) - }, []) - - // Unified search handler for both demo and custom repos const handleSearch = async (query: string, repoId: string, isCustom: boolean) => { if (!query.trim() || loading || remaining <= 0) return setLoading(true) setHasSearched(true) - setSearchedQuery(query) // Track what was actually searched - setInputQuery(query) // Sync input with searched + setSearchedQuery(query) + setInputQuery(query) setCurrentRepoId(repoId) setIsCustomRepo(isCustom) setRateLimitError(null) - const startTime = Date.now() + const t0 = Date.now() try { - const response = isCustom + const data = isCustom ? await playgroundAPI.search(query) : await fetch(`${API_URL}/playground/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ query, demo_repo: repoId, max_results: 10 }) - }).then(res => res.json()) + }).then(r => r.json()) - const data = isCustom ? response : response - if (data.results) { - setResults(data.results || []) - setSearchTime(data.search_time_ms || (Date.now() - startTime)) - if (typeof data.remaining_searches === 'number') { - setRemaining(data.remaining_searches) - } + setResults(data.results) + setSearchTime(data.search_time_ms || Date.now() - t0) + if (typeof data.remaining_searches === 'number') setRemaining(data.remaining_searches) } else if (data.status === 429) { - setRateLimitError('Daily limit reached. Sign up for unlimited searches!') + setRateLimitError('Daily limit reached') setRemaining(0) } - } catch (error) { - console.error('Search error:', error) + } catch (err) { + console.error('Search failed:', err) } finally { setLoading(false) } } - // Re-search with updated query (from compact search bar) + const handleHeroResults = (heroResults: SearchResult[], query: string, repoId: string, time: number | null) => { + setResults(heroResults) + setSearchTime(time) + setSearchedQuery(query) + setInputQuery(query) + setCurrentRepoId(repoId) + setIsCustomRepo(false) + setHasSearched(true) + } + const handleReSearch = () => { - if (inputQuery.trim() && currentRepoId) { - handleSearch(inputQuery, currentRepoId, isCustomRepo) - } + if (inputQuery.trim() && currentRepoId) handleSearch(inputQuery, currentRepoId, isCustomRepo) + } + + const handleNewSearch = () => { + setHasSearched(false) + setResults([]) + setSearchTime(null) + setSearchedQuery('') + setInputQuery('') + window.scrollTo({ top: 0, behavior: 'smooth' }) } return (
- {/* Navigation */} - + - {/* ============ RESULTS VIEW (compact header + results in viewport) ============ */} {hasSearched ? ( -
- {/* Compact Search Bar - sticky below nav */} - - - {/* Results Content - immediately visible */} -
-
- {/* Results Header - contextual based on loading state */} -
- {loading ? ( - - Searching for "{inputQuery}"... - - ) : isStale ? ( - - - - Press Enter to search for "{inputQuery}" - - - (showing results for "{searchedQuery}") - - - ) : ( -
- - {results.length} results for "{searchedQuery}" - - {searchTime && ( - - {searchTime > 1000 ? `${(searchTime/1000).toFixed(1)}s` : `${searchTime}ms`} - - )} -
- )} - {!loading && remaining > 0 && remaining < limit && ( -
{remaining} remaining
- )} -
- - {/* Loading State - Skeleton Cards */} - {loading && ( -
- {[0, 1, 2, 3].map((i) => ( - - ))} -
- )} - - {/* Results List */} - {!loading && ( - <> - {(remaining <= 0 || rateLimitError) && ( - -

You've reached today's limit

-

- {rateLimitError || 'Sign up to get unlimited searches and index your own repos.'} -

- -
- )} - - - - {results.map((result, idx) => ( - - ))} - - - - {results.length === 0 && ( -
-
🔍
-

No results found

-

Try a different query

- -
- )} - - )} -
-
-
+ navigate('/signup')} + /> ) : ( - /* ============ HERO VIEW (full landing page experience) ============ */ - <> - {/* Hero Section */} -
-
-
- - AI-powered code search -
- -

- grep returned - 847 results. -
- - Find the one that matters. - -

- -

- Search any codebase by meaning, not keywords. Index your own repo in seconds. -

- - {/* HeroPlayground - handles demo repos + custom repo indexing */} - -
-
- - {/* THE PROBLEM */} -
- -
-
- The Problem -

You've been here before

-

- New codebase. 50,000 lines. You need to find where authentication happens. -

-
- - {/* Terminal visualization */} -
-
-
-
-
- terminal -
-
-
$ grep -r "auth" ./src
-
-
src/components/AuthButton.tsx: // auth button component
-
src/utils/auth.ts: export const authConfig = ...
-
src/pages/auth/login.tsx: function AuthLogin() ...
-
src/middleware/auth.ts: // TODO: add auth
-
src/api/auth/callback.ts: const authCallback = ...
-
... 842 more results
-
-
- 847 results. Which one handles the actual authentication logic? -
-
-
-
- -
- - {/* THE SOLUTION */} -
- -
-
- The Solution -

Search by meaning, not keywords

-

- Ask for "authentication logic" and get the function that actually handles it. -

-
- - {/* CodeIntel visualization */} -
-
-
-
- CI -
- CodeIntel -
- 1 result • 89ms -
-
-
-
-
- authenticate_user - function -
- src/auth/handlers.py -
-
-
94%
-
match
-
-
-
{`def authenticate_user(credentials: dict) -> User:
-    """Main authentication logic - validates credentials
-    and returns authenticated user or raises AuthError."""
-    user = db.get_user(credentials['email'])
-    if not verify_password(credentials['password'], user.hash):
-        raise AuthError("Invalid credentials")
-    return create_session(user)`}
-
-
-
-
-
- - {/* HOW IT WORKS */} -
- -
-
- How It Works -

Three steps to code clarity

-
- -
-
-
- 1 -
-

Index your repo

-

Connect your GitHub repo. We analyze and embed every function, class, and module.

-
- -
-
- 2 -
-

Search by meaning

-

Ask natural questions. "Where is payment handled?" "Show me error boundaries."

-
- -
-
- 3 -
-

Get precise results

-

Not 847 matches. The exact functions you need, ranked by relevance.

-
-
-
-
-
- - {/* FEATURES */} -
- -
-
-

Built for developers

-
- -
-
-
-
- 🔌 -
-
-

MCP Integration

-

Works with Claude, Cursor, and any MCP-compatible AI. Search code directly from your assistant.

-
-
-
- -
-
-
- -
-
-

Lightning Fast

-

Sub-100ms responses with Redis caching. Semantic search shouldn't slow you down.

-
-
-
- -
-
-
- 📊 -
-
-

Code Intelligence

-

Understand dependencies, analyze coding patterns, and see impact before you change.

-
-
-
- -
-
-
- 🔓 -
-
-

Open Source

-

Self-host for private repos. Inspect the code. Contribute improvements. No vendor lock-in.

-
-
-
-
-
-
-
- - {/* FINAL CTA */} -
- -
-

Ready to understand your codebase?

-

Start searching for free. No credit card required.

-
- - - - Star on GitHub - -
-
-
-
- + )} - - {/* FOOTER */} -
) }