'use client'; import { useState, useMemo, useRef } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { HugeiconsIcon } from "@hugeicons/react"; import type { IconSvgElement } from "@hugeicons/react"; import { Copy01Icon, Tick01Icon, ArrowDown01Icon, ArrowUp01Icon, SourceCodeIcon, CommandLineIcon, CodeIcon, File01Icon, HashtagIcon, } from "@hugeicons/core-free-icons"; import { cn } from '@/lib/utils'; const COLLAPSE_THRESHOLD = 20; const VISIBLE_LINES = 10; const TERMINAL_LANGUAGES = new Set(['bash', 'sh', 'shell', 'terminal', 'zsh', 'console']); function getLanguageIcon(language: string): IconSvgElement { const lower = language.toLowerCase(); if (TERMINAL_LANGUAGES.has(lower)) return CommandLineIcon; if (['typescript', 'tsx', 'javascript', 'jsx'].includes(lower)) return CodeIcon; if (['json', 'yaml', 'yml', 'toml', 'xml'].includes(lower)) return CodeIcon; if (['python', 'ruby', 'go', 'rust', 'java', 'c', 'cpp'].includes(lower)) return HashtagIcon; if (['css', 'scss', 'html'].includes(lower)) return File01Icon; return SourceCodeIcon; } interface CodeBlockProps { code: string; language?: string; filename?: string; showLineNumbers?: boolean; maxCollapsedLines?: number; } export function CodeBlock({ code, language = 'text', filename, showLineNumbers = true, maxCollapsedLines = VISIBLE_LINES, }: CodeBlockProps) { const [copied, setCopied] = useState(false); const [copiedMarkdown, setCopiedMarkdown] = useState(false); const [expanded, setExpanded] = useState(false); const codeContainerRef = useRef(null); const [animatingHeight, setAnimatingHeight] = useState(undefined); const lines = useMemo(() => code.split('\n'), [code]); const totalLines = lines.length; const isCollapsible = totalLines > COLLAPSE_THRESHOLD; const isTerminal = TERMINAL_LANGUAGES.has(language.toLowerCase()); const displayCode = useMemo(() => { if (!isCollapsible || expanded) return code; return lines.slice(0, maxCollapsedLines).join('\n'); }, [code, lines, isCollapsible, expanded, maxCollapsedLines]); const handleCopy = async () => { try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // clipboard not available } }; const handleCopyMarkdown = async () => { try { const markdown = `\`\`\`${language}\n${code}\n\`\`\``; await navigator.clipboard.writeText(markdown); setCopiedMarkdown(true); setTimeout(() => setCopiedMarkdown(false), 2000); } catch { // clipboard not available } }; const handleToggleExpand = () => { const container = codeContainerRef.current; if (!container) { setExpanded(!expanded); return; } const currentHeight = container.scrollHeight; if (!expanded) { // Expanding: set current height, then switch to auto after transition setAnimatingHeight(`${currentHeight}px`); setExpanded(true); requestAnimationFrame(() => { // Measure the full height after content change const fullHeight = container.scrollHeight; setAnimatingHeight(`${fullHeight}px`); setTimeout(() => setAnimatingHeight(undefined), 300); }); } else { // Collapsing: set current height, then reduce setAnimatingHeight(`${currentHeight}px`); requestAnimationFrame(() => { const collapsedH = maxCollapsedLines * 1.5 + 1.5; setAnimatingHeight(`${collapsedH}rem`); setTimeout(() => { setExpanded(false); setAnimatingHeight(undefined); }, 300); }); } }; const languageIconData = getLanguageIcon(language); const theme = isTerminal ? vscDarkPlus : oneDark; return (
{/* Header bar */}
{filename && ( {filename} )} {filename && |} {language.toUpperCase()}
{/* Code area */}
{expanded ? code : displayCode} {/* Gradient overlay for collapsed state */} {isCollapsible && !expanded && (
)}
{/* Expand/Collapse button */} {isCollapsible && ( )}
); } // Inline code component for reuse export function InlineCode({ children }: { children: React.ReactNode }) { return ( {children} ); }