diff --git a/frontend/src/components/ui/CodeBlock.tsx b/frontend/src/components/ui/CodeBlock.tsx new file mode 100644 index 0000000..8c3ca65 --- /dev/null +++ b/frontend/src/components/ui/CodeBlock.tsx @@ -0,0 +1,206 @@ +import { HTMLAttributes, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { syntax, codeBg } from '@/lib/python-theme'; +import { lineHighlightVariants } from '@/lib/animations'; +import { Check, Copy, ExternalLink } from 'lucide-react'; + +interface CodeBlockProps extends HTMLAttributes { + code: string; + language?: 'python' | 'text'; + filename?: string; + lineStart?: number; + highlightLines?: number[]; + showLineNumbers?: boolean; + maxHeight?: string; + onCopy?: () => void; + githubUrl?: string; +} + +type Token = { type: string; value: string }; + +// Dead simple tokenizer - not trying to be a full parser, just good enough for display +function tokenizePython(code: string): Token[] { + const tokens: Token[] = []; + + // Order matters here - more specific patterns first + const patterns: [string, RegExp][] = [ + ['comment', /^#.*/], + ['docstring', /^("""[\s\S]*?"""|'''[\s\S]*?''')/], + ['fstring', /^f(['"])((?:\\.|(?!\1)[^\\])*)\1/], + ['string', /^(['"])((?:\\.|(?!\1)[^\\])*)\1/], + ['decorator', /^@[\w.]+/], + ['keyword', /^\b(def|class|import|from|return|if|elif|else|for|while|try|except|finally|with|as|yield|lambda|pass|break|continue|raise|assert|global|nonlocal|del|in|not|and|or|is|True|False|None|async|await)\b/], + ['builtin', /^\b(print|len|range|str|int|float|list|dict|set|tuple|bool|type|isinstance|hasattr|getattr|setattr|open|input|super|self|cls)\b/], + ['number', /^\b\d+(\.\d+)?\b/], + ['function', /^\b([a-zA-Z_][a-zA-Z0-9_]*)(?=\s*\()/], + ['className', /^\b([A-Z][a-zA-Z0-9_]*)\b/], + ['parameter', /^\b([a-zA-Z_][a-zA-Z0-9_]*)(?=\s*[=:])/], + ['operator', /^(==|!=|<=|>=|<|>|\+|-|\*|\/|%|\*\*|=|\+=|-=|\|\||&&)/], + ['punctuation', /^[()[\]{}:,.;]/], + ['variable', /^[a-zA-Z_][a-zA-Z0-9_]*/], + ['whitespace', /^\s+/], + ]; + + let remaining = code; + while (remaining.length > 0) { + let matched = false; + for (const [type, pattern] of patterns) { + const match = remaining.match(pattern); + if (match) { + tokens.push({ type, value: match[0] }); + remaining = remaining.slice(match[0].length); + matched = true; + break; + } + } + if (!matched) { + tokens.push({ type: 'text', value: remaining[0] }); + remaining = remaining.slice(1); + } + } + + return tokens; +} + +const tokenColors: Record = { + keyword: syntax.keyword, + builtin: syntax.builtin, + function: syntax.function, + className: syntax.className, + decorator: syntax.decorator, + string: syntax.string, + fstring: syntax.string, + docstring: syntax.docstring, + comment: syntax.comment, + number: syntax.number, + parameter: syntax.parameter, + operator: syntax.operator, + punctuation: syntax.punctuation, + variable: syntax.variable, +}; + +// Python code block with syntax highlighting and copy button +export function CodeBlock({ + code, + language = 'python', + filename, + lineStart = 1, + highlightLines = [], + showLineNumbers = true, + maxHeight = '400px', + onCopy, + githubUrl, + className, + ...props +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + const lines = code.split('\n'); + + const handleCopy = async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + onCopy?.(); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {(filename || githubUrl) && ( +
+ {filename && ( + {filename} + )} +
+ + {githubUrl && ( + + + + )} +
+
+ )} + +
+
+          
+            {lines.map((line, index) => {
+              const lineNumber = lineStart + index;
+              const isHighlighted = highlightLines.includes(lineNumber);
+              const tokens = language === 'python' 
+                ? tokenizePython(line) 
+                : [{ type: 'text', value: line }];
+              
+              return (
+                
+                  {showLineNumbers && (
+                    
+                      {lineNumber}
+                    
+                  )}
+                  
+                    {tokens.map((token, i) => (
+                      
+                        {token.value}
+                      
+                    ))}
+                    {line === '' && '\u200B'}
+                  
+                
+              );
+            })}
+          
+        
+
+
+ ); +} + +export default CodeBlock; diff --git a/frontend/src/components/ui/GlassCard.tsx b/frontend/src/components/ui/GlassCard.tsx new file mode 100644 index 0000000..1523114 --- /dev/null +++ b/frontend/src/components/ui/GlassCard.tsx @@ -0,0 +1,102 @@ +import { forwardRef, HTMLAttributes, ReactNode } from 'react'; +import { motion, HTMLMotionProps } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { glassCardVariants } from '@/lib/animations'; + +export type GlowColor = 'blue' | 'yellow' | 'none'; + +interface GlassCardProps extends Omit, 'children'> { + children: ReactNode; + glow?: GlowColor; + hover?: boolean; + className?: string; + as?: 'div' | 'article' | 'section'; +} + +const glowStyles: Record = { + blue: 'hover:shadow-[0_0_30px_rgba(75,139,190,0.15)]', + yellow: 'hover:shadow-[0_0_30px_rgba(255,212,59,0.15)]', + none: '', +}; + +// Glassmorphism card with optional Python-colored glow +export const GlassCard = forwardRef( + ({ children, glow = 'none', hover = true, className, as = 'div', ...props }, ref) => { + const Component = motion[as] as typeof motion.div; + + return ( + + {children} + + ); + } +); + +GlassCard.displayName = 'GlassCard'; + +// Compound components + +interface GlassCardContentProps extends HTMLAttributes { + children: ReactNode; + padding?: 'sm' | 'md' | 'lg'; +} + +const paddingSizes = { sm: 'p-3', md: 'p-4', lg: 'p-6' }; + +export const GlassCardContent = ({ + children, + padding = 'md', + className, + ...props +}: GlassCardContentProps) => ( +
+ {children} +
+); + +export const GlassCardHeader = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const GlassCardFooter = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export default GlassCard; diff --git a/frontend/src/hooks/useIsMobile.ts b/frontend/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..14b9c41 --- /dev/null +++ b/frontend/src/hooks/useIsMobile.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react'; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + check(); + + // Debounce resize to avoid thrashing + let timeout: NodeJS.Timeout; + const onResize = () => { + clearTimeout(timeout); + timeout = setTimeout(check, 100); + }; + + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + clearTimeout(timeout); + }; + }, []); + + return isMobile; +} + +export default useIsMobile; diff --git a/frontend/src/hooks/usePrefersReducedMotion.ts b/frontend/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 0000000..375c424 --- /dev/null +++ b/frontend/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; + +// Respects user's OS-level motion preferences +export function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReducedMotion(mq.matches); + + const onChange = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); + + mq.addEventListener?.('change', onChange) ?? mq.addListener?.(onChange); + return () => { + mq.removeEventListener?.('change', onChange) ?? mq.removeListener?.(onChange); + }; + }, []); + + return prefersReducedMotion; +} + +export default usePrefersReducedMotion; diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 0000000..089b7a6 --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,177 @@ +// Framer Motion variants for landing page animations + +import { Variants, Transition } from 'framer-motion'; +import { animation } from './design-tokens'; + +// Spring configs +export const springTransition: Transition = { type: 'spring', stiffness: 260, damping: 20 }; +export const gentleSpring: Transition = { type: 'spring', stiffness: 120, damping: 14 }; +export const snappySpring: Transition = { type: 'spring', stiffness: 400, damping: 25 }; + +// Search bar - changes glow color based on state (blue → yellow → green) +export const searchBarVariants: Variants = { + idle: { + boxShadow: '0 0 0 1px rgba(75, 139, 190, 0.2)', + scale: 1, + }, + focused: { + boxShadow: '0 0 0 2px #4B8BBE, 0 0 20px rgba(75, 139, 190, 0.3)', + scale: 1.01, + transition: snappySpring, + }, + searching: { + boxShadow: '0 0 0 2px #FFD43B, 0 0 30px rgba(255, 212, 59, 0.25)', + transition: { duration: 0.3 }, + }, + complete: { + boxShadow: '0 0 0 2px #22c55e, 0 0 20px rgba(34, 197, 94, 0.25)', + transition: { duration: 0.2 }, + }, +}; + +// Results list - staggered fade in from below +export const resultListVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.08, delayChildren: 0.1 }, + }, + exit: { + opacity: 0, + transition: { staggerChildren: 0.03, staggerDirection: -1 }, + }, +}; + +export const resultItemVariants: Variants = { + hidden: { opacity: 0, y: 20, filter: 'blur(4px)' }, + visible: { opacity: 1, y: 0, filter: 'blur(0px)', transition: springTransition }, + exit: { opacity: 0, y: -10, filter: 'blur(2px)', transition: { duration: 0.15 } }, +}; + +// Cards +export const cardVariants: Variants = { + idle: { y: 0, boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.05)' }, + hover: { + y: -2, + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.1)', + transition: snappySpring, + }, + tap: { y: 0, scale: 0.99 }, +}; + +export const glassCardVariants: Variants = { + idle: { backgroundColor: 'rgba(255, 255, 255, 0.03)', borderColor: 'rgba(255, 255, 255, 0.08)' }, + hover: { + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderColor: 'rgba(255, 255, 255, 0.15)', + transition: { duration: 0.2 }, + }, +}; + +// Buttons +export const buttonVariants: Variants = { + idle: { scale: 1 }, + hover: { scale: 1.02 }, + tap: { scale: 0.98 }, +}; + +export const primaryButtonVariants: Variants = { + idle: { scale: 1, boxShadow: '0 0 0 0 rgba(75, 139, 190, 0)' }, + hover: { scale: 1.02, boxShadow: '0 0 25px rgba(75, 139, 190, 0.4)', transition: snappySpring }, + tap: { scale: 0.98 }, +}; + +// Pills/tags (repo switcher) +export const pillVariants: Variants = { + idle: { scale: 1, backgroundColor: 'rgba(39, 39, 42, 0.5)' }, + hover: { scale: 1.05, backgroundColor: 'rgba(39, 39, 42, 0.8)', transition: { duration: 0.15 } }, + selected: { scale: 1, backgroundColor: 'rgba(75, 139, 190, 0.2)', borderColor: '#4B8BBE' }, + tap: { scale: 0.95 }, +}; + +// Score bar fill animation +export const scoreVariants: Variants = { + hidden: { width: 0, opacity: 0 }, + visible: (score: number) => ({ + width: `${score}%`, + opacity: 1, + transition: { + width: { delay: 0.2, duration: 0.6, ease: 'easeOut' }, + opacity: { delay: 0.1, duration: 0.2 }, + }, + }), +}; + +// Generic fade variants +export const fadeInVariants: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: animation.duration.normal / 1000 } }, +}; + +export const fadeInUpVariants: Variants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0, transition: springTransition }, +}; + +export const fadeInDownVariants: Variants = { + hidden: { opacity: 0, y: -20 }, + visible: { opacity: 1, y: 0, transition: springTransition }, +}; + +export const scaleInVariants: Variants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { opacity: 1, scale: 1, transition: springTransition }, +}; + +export const blurInVariants: Variants = { + hidden: { opacity: 0, filter: 'blur(10px)' }, + visible: { opacity: 1, filter: 'blur(0px)', transition: { duration: animation.duration.slow / 1000 } }, +}; + +// Stagger containers +export const staggerContainer: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.05, delayChildren: 0.1 } }, +}; + +export const fastStaggerContainer: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.03 } }, +}; + +// Code highlighting +export const highlightFlashVariants: Variants = { + initial: { backgroundColor: 'rgba(255, 212, 59, 0.5)' }, + animate: { backgroundColor: 'rgba(255, 212, 59, 0.15)', transition: { duration: 0.8, delay: 0.3 } }, +}; + +export const lineHighlightVariants: Variants = { + idle: { backgroundColor: 'transparent' }, + hover: { backgroundColor: 'rgba(255, 255, 255, 0.03)', transition: { duration: 0.1 } }, +}; + +// Expand/collapse (for AI explain panel) +export const expandVariants: Variants = { + collapsed: { height: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }, + expanded: { height: 'auto', opacity: 1, transition: { duration: 0.4, ease: 'easeOut' } }, +}; + +// Loading states +export const spinnerVariants: Variants = { + animate: { rotate: 360, transition: { duration: 1, repeat: Infinity, ease: 'linear' } }, +}; + +export const pulseVariants: Variants = { + animate: { + scale: [1, 1.05, 1], + opacity: [0.7, 1, 0.7], + transition: { duration: 1.5, repeat: Infinity, ease: 'easeInOut' }, + }, +}; + +// Page transitions +export const pageVariants: Variants = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } }, + exit: { opacity: 0, y: -10, transition: { duration: 0.2 } }, +}; diff --git a/frontend/src/lib/python-theme.ts b/frontend/src/lib/python-theme.ts new file mode 100644 index 0000000..df40b40 --- /dev/null +++ b/frontend/src/lib/python-theme.ts @@ -0,0 +1,141 @@ +// Python brand colors + syntax highlighting for the landing page + +// Pulled from python.org branding guidelines +export const python = { + blue: { + dark: '#306998', + DEFAULT: '#4B8BBE', + light: '#6BA3D6', + glow: 'rgba(75, 139, 190, 0.25)', + glowStrong: 'rgba(75, 139, 190, 0.4)', + }, + yellow: { + dark: '#E5C33B', + DEFAULT: '#FFD43B', + light: '#FFE873', + glow: 'rgba(255, 212, 59, 0.2)', + glowStrong: 'rgba(255, 212, 59, 0.35)', + }, +} as const; + +// Dracula-ish syntax colors (what devs expect from VS Code / PyCharm) +export const syntax = { + keyword: '#FF79C6', // def, class, import, from, return, if, else + function: '#50FA7B', // Function names, method calls + string: '#F1FA8C', // Single, double, triple quoted strings + comment: '#6272A4', // Comments and docstrings prefix + variable: '#F8F8F2', // Variable names + number: '#BD93F9', // Integers, floats + decorator: '#8BE9FD', // @decorators + builtin: '#FF79C6', // Built-in functions like print, len, range + className: '#8BE9FD', // Class names + parameter: '#FFB86C', // Function parameters + operator: '#FF79C6', // Operators like =, ==, +, - + punctuation: '#F8F8F2', // Brackets, colons, commas + docstring: '#6272A4', // Triple-quoted docstrings + fstring: '#F1FA8C', // f-strings + fstringBrace: '#FF79C6', // Braces inside f-strings + lineNumber: 'rgba(248, 248, 242, 0.3)', + lineNumberActive: 'rgba(248, 248, 242, 0.6)', + lineHighlight: 'rgba(255, 255, 255, 0.05)', + matchHighlight: 'rgba(255, 212, 59, 0.2)', + matchBorder: '#FFD43B', +} as const; + +// Background layers (GitHub dark palette) +export const codeBg = { + deep: '#0D1117', // Deepest layer (GitHub dark) + primary: '#161B22', // Main code background + elevated: '#21262D', // Code blocks, cards + hover: '#30363D', // Hover states + selection: 'rgba(75, 139, 190, 0.3)', +} as const; + +// Repos we'll show in the demo - popular ones people recognize +export const featuredRepos = [ + { + id: 'flask', + owner: 'pallets', + name: 'flask', + displayName: 'Flask', + description: 'Lightweight WSGI web framework', + stars: '67k', + color: python.blue.DEFAULT, + defaultQuery: 'authentication middleware', + }, + { + id: 'django', + owner: 'django', + name: 'django', + displayName: 'Django', + description: 'High-level Python web framework', + stars: '79k', + color: '#092E20', + defaultQuery: 'user authentication views', + }, + { + id: 'fastapi', + owner: 'tiangolo', + name: 'fastapi', + displayName: 'FastAPI', + description: 'Modern, fast web framework', + stars: '76k', + color: '#009688', + defaultQuery: 'dependency injection', + }, + { + id: 'requests', + owner: 'psf', + name: 'requests', + displayName: 'Requests', + description: 'HTTP for Humans', + stars: '52k', + color: python.yellow.DEFAULT, + defaultQuery: 'session handling cookies', + }, + { + id: 'sqlalchemy', + owner: 'sqlalchemy', + name: 'sqlalchemy', + displayName: 'SQLAlchemy', + description: 'Database toolkit', + stars: '9k', + color: '#D71F00', + defaultQuery: 'connection pooling', + }, +] as const; + +export type FeaturedRepo = typeof featuredRepos[number]; + +// CSS vars export (for use in vanilla CSS if needed) +export const pythonCSSVars = { + '--python-blue': python.blue.DEFAULT, + '--python-blue-dark': python.blue.dark, + '--python-blue-light': python.blue.light, + '--python-blue-glow': python.blue.glow, + '--python-yellow': python.yellow.DEFAULT, + '--python-yellow-dark': python.yellow.dark, + '--python-yellow-light': python.yellow.light, + '--python-yellow-glow': python.yellow.glow, + '--syntax-keyword': syntax.keyword, + '--syntax-function': syntax.function, + '--syntax-string': syntax.string, + '--syntax-comment': syntax.comment, + '--syntax-variable': syntax.variable, + '--syntax-number': syntax.number, + '--syntax-decorator': syntax.decorator, + '--syntax-class': syntax.className, + '--syntax-parameter': syntax.parameter, + '--code-bg-deep': codeBg.deep, + '--code-bg-primary': codeBg.primary, + '--code-bg-elevated': codeBg.elevated, +} as const; + +// Inject vars into :root (call once on app init) +export function injectPythonTheme(): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + Object.entries(pythonCSSVars).forEach(([key, value]) => { + root.style.setProperty(key, value); + }); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 712fa80..bee4e98 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -40,6 +40,40 @@ export default { 'border-hover': 'var(--glass-border-hover)', }, + // Python brand colors + python: { + blue: { + dark: '#306998', + DEFAULT: '#4B8BBE', + light: '#6BA3D6', + }, + yellow: { + dark: '#E5C33B', + DEFAULT: '#FFD43B', + light: '#FFE873', + }, + }, + + // Code background layers + code: { + deep: '#0D1117', + primary: '#161B22', + elevated: '#21262D', + hover: '#30363D', + }, + + // Syntax highlighting + syntax: { + keyword: '#FF79C6', + function: '#50FA7B', + string: '#F1FA8C', + comment: '#6272A4', + variable: '#F8F8F2', + number: '#BD93F9', + decorator: '#8BE9FD', + parameter: '#FFB86C', + }, + // Shadcn compatibility background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))',