Skip to content

Commit a078e42

Browse files
committed
feat(design-system): Add Python-native theme and core landing page components
Part of #167 Added: - lib/python-theme.ts: Python brand colors, syntax highlighting tokens, featured repos config - lib/animations.ts: Comprehensive Framer Motion variants for search, cards, results, buttons - components/ui/GlassCard.tsx: Premium glassmorphism card with hover effects and glow options - components/ui/CodeBlock.tsx: Python syntax highlighting with line numbers and copy functionality - hooks/usePrefersReducedMotion.ts: Accessibility hook for reduced motion preference - hooks/useIsMobile.ts: Responsive design hook for mobile detection - Updated tailwind.config.js with Python colors and syntax tokens The Python theme uses the official Python blue (#4B8BBE) and yellow (#FFD43B) colors that developers recognize from the Python logo. Syntax colors match PyCharm/VS Code for zero cognitive load when reading code.
1 parent 35d57a4 commit a078e42

7 files changed

Lines changed: 1014 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { HTMLAttributes, useState } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { cn } from '@/lib/utils';
4+
import { syntax, codeBg } from '@/lib/python-theme';
5+
import { lineHighlightVariants } from '@/lib/animations';
6+
import { Check, Copy, ExternalLink } from 'lucide-react';
7+
8+
interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
9+
code: string;
10+
language?: 'python' | 'text';
11+
filename?: string;
12+
lineStart?: number;
13+
highlightLines?: number[];
14+
showLineNumbers?: boolean;
15+
maxHeight?: string;
16+
onCopy?: () => void;
17+
githubUrl?: string;
18+
}
19+
20+
// Simple Python tokenizer for syntax highlighting
21+
function tokenizePython(code: string): Array<{ type: string; value: string }> {
22+
const tokens: Array<{ type: string; value: string }> = [];
23+
24+
const patterns: Array<[string, RegExp]> = [
25+
['comment', /^#.*/],
26+
['docstring', /^("""[\s\S]*?"""|'''[\s\S]*?''')/],
27+
['fstring', /^f(['"])((?:\\.|(?!\1)[^\\])*)\1/],
28+
['string', /^(['"])((?:\\.|(?!\1)[^\\])*)\1/],
29+
['decorator', /^@[\w.]+/],
30+
['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/],
31+
['builtin', /^\b(print|len|range|str|int|float|list|dict|set|tuple|bool|type|isinstance|hasattr|getattr|setattr|open|input|super|self|cls)\b/],
32+
['number', /^\b\d+(\.\d+)?\b/],
33+
['function', /^\b([a-zA-Z_][a-zA-Z0-9_]*)(?=\s*\()/],
34+
['className', /^\b([A-Z][a-zA-Z0-9_]*)\b/],
35+
['parameter', /^\b([a-zA-Z_][a-zA-Z0-9_]*)(?=\s*[=:])/],
36+
['operator', /^(==|!=|<=|>=|<|>|\+|-|\*|\/|%|\*\*|=|\+=|-=|\|\||&&)/],
37+
['punctuation', /^[()[\]{}:,.;]/],
38+
['variable', /^[a-zA-Z_][a-zA-Z0-9_]*/],
39+
['whitespace', /^\s+/],
40+
];
41+
42+
let remaining = code;
43+
44+
while (remaining.length > 0) {
45+
let matched = false;
46+
47+
for (const [type, pattern] of patterns) {
48+
const match = remaining.match(pattern);
49+
if (match) {
50+
tokens.push({ type, value: match[0] });
51+
remaining = remaining.slice(match[0].length);
52+
matched = true;
53+
break;
54+
}
55+
}
56+
57+
if (!matched) {
58+
tokens.push({ type: 'text', value: remaining[0] });
59+
remaining = remaining.slice(1);
60+
}
61+
}
62+
63+
return tokens;
64+
}
65+
66+
function getTokenColor(type: string): string {
67+
const colorMap: Record<string, string> = {
68+
keyword: syntax.keyword,
69+
builtin: syntax.builtin,
70+
function: syntax.function,
71+
className: syntax.className,
72+
decorator: syntax.decorator,
73+
string: syntax.string,
74+
fstring: syntax.string,
75+
docstring: syntax.docstring,
76+
comment: syntax.comment,
77+
number: syntax.number,
78+
parameter: syntax.parameter,
79+
operator: syntax.operator,
80+
punctuation: syntax.punctuation,
81+
variable: syntax.variable,
82+
};
83+
84+
return colorMap[type] || syntax.variable;
85+
}
86+
87+
/**
88+
* CodeBlock - Python code display with syntax highlighting
89+
*
90+
* Features:
91+
* - Python syntax highlighting
92+
* - Line numbers
93+
* - Line highlighting for matches
94+
* - Copy to clipboard
95+
* - GitHub link
96+
* - Hover effects on lines
97+
*/
98+
export function CodeBlock({
99+
code,
100+
language = 'python',
101+
filename,
102+
lineStart = 1,
103+
highlightLines = [],
104+
showLineNumbers = true,
105+
maxHeight = '400px',
106+
onCopy,
107+
githubUrl,
108+
className,
109+
...props
110+
}: CodeBlockProps) {
111+
const [copied, setCopied] = useState(false);
112+
const lines = code.split('\n');
113+
114+
const handleCopy = async () => {
115+
await navigator.clipboard.writeText(code);
116+
setCopied(true);
117+
onCopy?.();
118+
setTimeout(() => setCopied(false), 2000);
119+
};
120+
121+
return (
122+
<div
123+
className={cn(
124+
'rounded-lg overflow-hidden',
125+
'border border-white/[0.08]',
126+
className
127+
)}
128+
style={{ backgroundColor: codeBg.elevated }}
129+
{...props}
130+
>
131+
{/* Header */}
132+
{(filename || githubUrl) && (
133+
<div
134+
className="px-4 py-2 flex items-center justify-between border-b border-white/[0.08]"
135+
style={{ backgroundColor: codeBg.primary }}
136+
>
137+
{filename && (
138+
<span className="text-sm text-text-secondary font-mono">
139+
{filename}
140+
</span>
141+
)}
142+
<div className="flex items-center gap-2">
143+
<button
144+
onClick={handleCopy}
145+
className="p-1.5 rounded hover:bg-white/[0.05] transition-colors text-text-muted hover:text-text-primary"
146+
title="Copy code"
147+
>
148+
<AnimatePresence mode="wait">
149+
{copied ? (
150+
<motion.div
151+
key="check"
152+
initial={{ scale: 0.5, opacity: 0 }}
153+
animate={{ scale: 1, opacity: 1 }}
154+
exit={{ scale: 0.5, opacity: 0 }}
155+
>
156+
<Check size={16} className="text-green-500" />
157+
</motion.div>
158+
) : (
159+
<motion.div
160+
key="copy"
161+
initial={{ scale: 0.5, opacity: 0 }}
162+
animate={{ scale: 1, opacity: 1 }}
163+
exit={{ scale: 0.5, opacity: 0 }}
164+
>
165+
<Copy size={16} />
166+
</motion.div>
167+
)}
168+
</AnimatePresence>
169+
</button>
170+
{githubUrl && (
171+
<a
172+
href={githubUrl}
173+
target="_blank"
174+
rel="noopener noreferrer"
175+
className="p-1.5 rounded hover:bg-white/[0.05] transition-colors text-text-muted hover:text-text-primary"
176+
title="View on GitHub"
177+
>
178+
<ExternalLink size={16} />
179+
</a>
180+
)}
181+
</div>
182+
</div>
183+
)}
184+
185+
{/* Code */}
186+
<div
187+
className="overflow-auto scrollbar-thin"
188+
style={{ maxHeight }}
189+
>
190+
<pre className="p-4 text-sm font-mono leading-relaxed">
191+
<code>
192+
{lines.map((line, index) => {
193+
const lineNumber = lineStart + index;
194+
const isHighlighted = highlightLines.includes(lineNumber);
195+
const tokens = language === 'python' ? tokenizePython(line) : [{ type: 'text', value: line }];
196+
197+
return (
198+
<motion.div
199+
key={index}
200+
className={cn(
201+
'flex',
202+
isHighlighted && 'rounded'
203+
)}
204+
variants={lineHighlightVariants}
205+
initial="idle"
206+
whileHover="hover"
207+
style={{
208+
backgroundColor: isHighlighted
209+
? syntax.matchHighlight
210+
: undefined,
211+
}}
212+
>
213+
{showLineNumbers && (
214+
<span
215+
className="select-none pr-4 text-right min-w-[3rem]"
216+
style={{
217+
color: isHighlighted
218+
? syntax.lineNumberActive
219+
: syntax.lineNumber,
220+
}}
221+
>
222+
{lineNumber}
223+
</span>
224+
)}
225+
<span className="flex-1">
226+
{tokens.map((token, tokenIndex) => (
227+
<span
228+
key={tokenIndex}
229+
style={{ color: getTokenColor(token.type) }}
230+
>
231+
{token.value}
232+
</span>
233+
))}
234+
{line === '' && '\u200B'}
235+
</span>
236+
</motion.div>
237+
);
238+
})}
239+
</code>
240+
</pre>
241+
</div>
242+
</div>
243+
);
244+
}
245+
246+
export default CodeBlock;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { forwardRef, HTMLAttributes, ReactNode } from 'react';
2+
import { motion, HTMLMotionProps } from 'framer-motion';
3+
import { cn } from '@/lib/utils';
4+
import { glassCardVariants } from '@/lib/animations';
5+
6+
export type GlowColor = 'blue' | 'yellow' | 'none';
7+
8+
interface GlassCardProps extends Omit<HTMLMotionProps<'div'>, 'children'> {
9+
children: ReactNode;
10+
glow?: GlowColor;
11+
hover?: boolean;
12+
className?: string;
13+
as?: 'div' | 'article' | 'section';
14+
}
15+
16+
const glowStyles: Record<GlowColor, string> = {
17+
blue: 'hover:shadow-[0_0_30px_rgba(75,139,190,0.15)]',
18+
yellow: 'hover:shadow-[0_0_30px_rgba(255,212,59,0.15)]',
19+
none: '',
20+
};
21+
22+
/**
23+
* GlassCard - Premium glassmorphism card component
24+
*
25+
* Features:
26+
* - Backdrop blur effect
27+
* - Subtle border that brightens on hover
28+
* - Optional glow effect (Python blue or yellow)
29+
* - Smooth hover animations via Framer Motion
30+
*
31+
* @example
32+
* <GlassCard glow="blue">
33+
* <h3>Search Result</h3>
34+
* <p>Code snippet here...</p>
35+
* </GlassCard>
36+
*/
37+
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
38+
(
39+
{
40+
children,
41+
glow = 'none',
42+
hover = true,
43+
className,
44+
as = 'div',
45+
...props
46+
},
47+
ref
48+
) => {
49+
const Component = motion[as] as typeof motion.div;
50+
51+
return (
52+
<Component
53+
ref={ref}
54+
className={cn(
55+
// Base glass styles
56+
'rounded-xl',
57+
'bg-white/[0.03]',
58+
'backdrop-blur-xl',
59+
'border border-white/[0.08]',
60+
// Transitions
61+
'transition-all duration-normal ease-out-expo',
62+
// Hover effects
63+
hover && [
64+
'hover:bg-white/[0.05]',
65+
'hover:border-white/[0.12]',
66+
],
67+
// Glow effect
68+
glow !== 'none' && glowStyles[glow],
69+
className
70+
)}
71+
variants={hover ? glassCardVariants : undefined}
72+
initial={hover ? 'idle' : undefined}
73+
whileHover={hover ? 'hover' : undefined}
74+
{...props}
75+
>
76+
{children}
77+
</Component>
78+
);
79+
}
80+
);
81+
82+
GlassCard.displayName = 'GlassCard';
83+
84+
// Convenience wrapper for content padding
85+
interface GlassCardContentProps extends HTMLAttributes<HTMLDivElement> {
86+
children: ReactNode;
87+
padding?: 'sm' | 'md' | 'lg';
88+
}
89+
90+
const paddingSizes = {
91+
sm: 'p-3',
92+
md: 'p-4',
93+
lg: 'p-6',
94+
};
95+
96+
export const GlassCardContent = ({
97+
children,
98+
padding = 'md',
99+
className,
100+
...props
101+
}: GlassCardContentProps) => (
102+
<div className={cn(paddingSizes[padding], className)} {...props}>
103+
{children}
104+
</div>
105+
);
106+
107+
// Header with border
108+
export const GlassCardHeader = ({
109+
children,
110+
className,
111+
...props
112+
}: HTMLAttributes<HTMLDivElement>) => (
113+
<div
114+
className={cn(
115+
'px-4 py-3',
116+
'border-b border-white/[0.08]',
117+
'flex items-center justify-between',
118+
className
119+
)}
120+
{...props}
121+
>
122+
{children}
123+
</div>
124+
);
125+
126+
// Footer with border
127+
export const GlassCardFooter = ({
128+
children,
129+
className,
130+
...props
131+
}: HTMLAttributes<HTMLDivElement>) => (
132+
<div
133+
className={cn(
134+
'px-4 py-3',
135+
'border-t border-white/[0.08]',
136+
'flex items-center gap-2',
137+
className
138+
)}
139+
{...props}
140+
>
141+
{children}
142+
</div>
143+
);
144+
145+
export default GlassCard;

0 commit comments

Comments
 (0)