Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions frontend/src/components/ui/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
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<string, string> = {
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 (
<div
className={cn('rounded-lg overflow-hidden border border-white/[0.08]', className)}
style={{ backgroundColor: codeBg.elevated }}
{...props}
>
{(filename || githubUrl) && (
<div
className="px-4 py-2 flex items-center justify-between border-b border-white/[0.08]"
style={{ backgroundColor: codeBg.primary }}
>
{filename && (
<span className="text-sm text-text-secondary font-mono">{filename}</span>
)}
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="p-1.5 rounded hover:bg-white/[0.05] transition-colors text-text-muted hover:text-text-primary"
title="Copy code"
>
<AnimatePresence mode="wait">
<motion.div
key={copied ? 'check' : 'copy'}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
>
{copied ? (
<Check size={16} className="text-green-500" />
) : (
<Copy size={16} />
)}
</motion.div>
</AnimatePresence>
</button>
{githubUrl && (
<a
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded hover:bg-white/[0.05] transition-colors text-text-muted hover:text-text-primary"
title="View on GitHub"
>
<ExternalLink size={16} />
</a>
)}
</div>
</div>
)}

<div className="overflow-auto scrollbar-thin" style={{ maxHeight }}>
<pre className="p-4 text-sm font-mono leading-relaxed">
<code>
{lines.map((line, index) => {
const lineNumber = lineStart + index;
const isHighlighted = highlightLines.includes(lineNumber);
const tokens = language === 'python'
? tokenizePython(line)
: [{ type: 'text', value: line }];

return (
<motion.div
key={index}
className={cn('flex', isHighlighted && 'rounded')}
variants={lineHighlightVariants}
initial="idle"
whileHover="hover"
style={{
backgroundColor: isHighlighted ? syntax.matchHighlight : undefined,
}}
>
{showLineNumbers && (
<span
className="select-none pr-4 text-right min-w-[3rem]"
style={{
color: isHighlighted ? syntax.lineNumberActive : syntax.lineNumber,
}}
>
{lineNumber}
</span>
)}
<span className="flex-1">
{tokens.map((token, i) => (
<span key={i} style={{ color: tokenColors[token.type] || syntax.variable }}>
{token.value}
</span>
))}
{line === '' && '\u200B'}
</span>
</motion.div>
);
})}
</code>
</pre>
</div>
</div>
);
}

export default CodeBlock;
102 changes: 102 additions & 0 deletions frontend/src/components/ui/GlassCard.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLMotionProps<'div'>, 'children'> {
children: ReactNode;
glow?: GlowColor;
hover?: boolean;
className?: string;
as?: 'div' | 'article' | 'section';
}

const glowStyles: Record<GlowColor, string> = {
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<HTMLDivElement, GlassCardProps>(
({ children, glow = 'none', hover = true, className, as = 'div', ...props }, ref) => {
const Component = motion[as] as typeof motion.div;

return (
<Component
ref={ref}
className={cn(
'rounded-xl bg-white/[0.03] backdrop-blur-xl border border-white/[0.08]',
'transition-all duration-normal ease-out-expo',
hover && 'hover:bg-white/[0.05] hover:border-white/[0.12]',
glow !== 'none' && glowStyles[glow],
className
)}
variants={hover ? glassCardVariants : undefined}
initial={hover ? 'idle' : undefined}
whileHover={hover ? 'hover' : undefined}
{...props}
>
{children}
</Component>
);
}
);

GlassCard.displayName = 'GlassCard';

// Compound components

interface GlassCardContentProps extends HTMLAttributes<HTMLDivElement> {
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) => (
<div className={cn(paddingSizes[padding], className)} {...props}>
{children}
</div>
);

export const GlassCardHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'px-4 py-3 border-b border-white/[0.08] flex items-center justify-between',
className
)}
{...props}
>
{children}
</div>
);

export const GlassCardFooter = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'px-4 py-3 border-t border-white/[0.08] flex items-center gap-2',
className
)}
{...props}
>
{children}
</div>
);

export default GlassCard;
31 changes: 31 additions & 0 deletions frontend/src/hooks/useIsMobile.ts
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 24 additions & 0 deletions frontend/src/hooks/usePrefersReducedMotion.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading