|
| 1 | +import { useState, useRef, useEffect } from 'react'; |
| 2 | +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; |
| 3 | +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; |
| 4 | +import { toast } from 'sonner'; |
| 5 | +import type { SearchResult } from '../../types'; |
| 6 | + |
| 7 | +interface ResultCardProps { |
| 8 | + result: SearchResult; |
| 9 | + rank: number; |
| 10 | + isExpanded?: boolean; |
| 11 | + aiSummary?: string; |
| 12 | + repoUrl?: string; |
| 13 | +} |
| 14 | + |
| 15 | +export function ResultCard({ |
| 16 | + result, |
| 17 | + rank, |
| 18 | + isExpanded: initialExpanded = false, |
| 19 | + aiSummary, |
| 20 | + repoUrl |
| 21 | +}: ResultCardProps) { |
| 22 | + const [expanded, setExpanded] = useState(initialExpanded); |
| 23 | + const contentRef = useRef<HTMLDivElement>(null); |
| 24 | + const [contentHeight, setContentHeight] = useState<number | undefined>( |
| 25 | + initialExpanded ? undefined : 0 |
| 26 | + ); |
| 27 | + |
| 28 | + const matchPercent = Math.round(result.score * 100); |
| 29 | + const isTopResult = rank === 1; |
| 30 | + |
| 31 | + // Extract clean file path (remove repos/{uuid}/ prefix if present) |
| 32 | + const cleanFilePath = result.file_path.replace(/^repos\/[a-f0-9-]+\//, ''); |
| 33 | + const displayPath = cleanFilePath.split('/').slice(-3).join('/'); |
| 34 | + |
| 35 | + // Build GitHub URL with clean path |
| 36 | + const githubUrl = repoUrl |
| 37 | + ? `${repoUrl}/blob/main/${cleanFilePath}#L${result.line_start}-L${result.line_end}` |
| 38 | + : null; |
| 39 | + |
| 40 | + // Animate height on expand/collapse |
| 41 | + useEffect(() => { |
| 42 | + if (expanded) { |
| 43 | + const height = contentRef.current?.scrollHeight; |
| 44 | + setContentHeight(height); |
| 45 | + // After animation, set to auto for dynamic content |
| 46 | + const timer = setTimeout(() => setContentHeight(undefined), 200); |
| 47 | + return () => clearTimeout(timer); |
| 48 | + } else { |
| 49 | + // First set explicit height, then animate to 0 |
| 50 | + const height = contentRef.current?.scrollHeight; |
| 51 | + setContentHeight(height); |
| 52 | + requestAnimationFrame(() => setContentHeight(0)); |
| 53 | + } |
| 54 | + }, [expanded]); |
| 55 | + |
| 56 | + const copyCode = () => { |
| 57 | + navigator.clipboard.writeText(result.code); |
| 58 | + toast.success('Copied to clipboard'); |
| 59 | + }; |
| 60 | + |
| 61 | + return ( |
| 62 | + <div |
| 63 | + className={` |
| 64 | + card overflow-hidden transition-all duration-200 |
| 65 | + ${expanded ? 'ring-1 ring-accent/20' : 'hover:border-border-accent'} |
| 66 | + ${isTopResult ? 'border-accent/30' : ''} |
| 67 | + `} |
| 68 | + > |
| 69 | + {/* Header */} |
| 70 | + <button |
| 71 | + onClick={() => setExpanded(!expanded)} |
| 72 | + className="w-full p-4 flex items-start justify-between text-left hover:bg-white/[0.02] transition-colors" |
| 73 | + > |
| 74 | + <div className="flex-1 min-w-0"> |
| 75 | + <div className="flex items-center gap-2 mb-1"> |
| 76 | + {isTopResult && ( |
| 77 | + <span className="badge-accent text-[10px]">TOP MATCH</span> |
| 78 | + )} |
| 79 | + <h3 className="font-mono font-semibold text-sm text-text-primary truncate"> |
| 80 | + {result.name || 'anonymous'} |
| 81 | + </h3> |
| 82 | + <span className="badge-neutral text-[10px] uppercase shrink-0"> |
| 83 | + {result.type.replace('_', ' ')} |
| 84 | + </span> |
| 85 | + </div> |
| 86 | + <p className="text-xs text-text-muted font-mono truncate">{displayPath}</p> |
| 87 | + </div> |
| 88 | + |
| 89 | + <div className="flex items-center gap-3 ml-4 shrink-0"> |
| 90 | + <div className="flex items-center gap-2"> |
| 91 | + <div className="w-16 h-1.5 bg-bg-tertiary rounded-full overflow-hidden"> |
| 92 | + <div |
| 93 | + className="h-full bg-gradient-to-r from-accent to-accent-light rounded-full transition-all" |
| 94 | + style={{ width: `${matchPercent}%` }} |
| 95 | + /> |
| 96 | + </div> |
| 97 | + <span className="text-sm font-mono font-semibold text-accent w-10 text-right"> |
| 98 | + {matchPercent}% |
| 99 | + </span> |
| 100 | + </div> |
| 101 | + |
| 102 | + <svg |
| 103 | + className={`w-4 h-4 text-text-muted transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`} |
| 104 | + fill="none" |
| 105 | + viewBox="0 0 24 24" |
| 106 | + stroke="currentColor" |
| 107 | + > |
| 108 | + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> |
| 109 | + </svg> |
| 110 | + </div> |
| 111 | + </button> |
| 112 | + |
| 113 | + {/* Expandable content with animation */} |
| 114 | + <div |
| 115 | + ref={contentRef} |
| 116 | + className="overflow-hidden transition-all duration-200 ease-out" |
| 117 | + style={{ height: contentHeight !== undefined ? contentHeight : 'auto' }} |
| 118 | + > |
| 119 | + <div className="border-t border-border"> |
| 120 | + {/* AI Summary */} |
| 121 | + {aiSummary && isTopResult && ( |
| 122 | + <div className="px-4 py-3 bg-accent/5 border-b border-border"> |
| 123 | + <div className="flex items-start gap-2"> |
| 124 | + <span className="text-accent text-sm">✨</span> |
| 125 | + <div> |
| 126 | + <p className="text-xs font-medium text-accent mb-1">AI Summary</p> |
| 127 | + <p className="text-sm text-text-secondary leading-relaxed">{aiSummary}</p> |
| 128 | + </div> |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + )} |
| 132 | + |
| 133 | + {/* Code block */} |
| 134 | + <div className="relative"> |
| 135 | + <SyntaxHighlighter |
| 136 | + language={result.language || 'text'} |
| 137 | + style={oneDark} |
| 138 | + customStyle={{ |
| 139 | + margin: 0, |
| 140 | + borderRadius: 0, |
| 141 | + fontSize: '0.75rem', |
| 142 | + lineHeight: '1.6', |
| 143 | + background: 'var(--color-bg-secondary)', |
| 144 | + padding: '1rem', |
| 145 | + }} |
| 146 | + showLineNumbers |
| 147 | + startingLineNumber={result.line_start} |
| 148 | + wrapLines |
| 149 | + > |
| 150 | + {result.code} |
| 151 | + </SyntaxHighlighter> |
| 152 | + |
| 153 | + <span className="absolute top-3 right-3 px-2 py-0.5 text-[10px] font-mono uppercase bg-bg-tertiary text-text-muted rounded"> |
| 154 | + {result.language} |
| 155 | + </span> |
| 156 | + </div> |
| 157 | + |
| 158 | + {/* Footer */} |
| 159 | + <div className="px-4 py-3 bg-bg-secondary/50 flex items-center justify-between"> |
| 160 | + <span className="text-xs text-text-muted font-mono"> |
| 161 | + Lines {result.line_start}–{result.line_end} |
| 162 | + </span> |
| 163 | + |
| 164 | + <div className="flex items-center gap-2"> |
| 165 | + <button |
| 166 | + onClick={copyCode} |
| 167 | + className="btn-ghost px-3 py-1.5 text-xs flex items-center gap-1.5" |
| 168 | + > |
| 169 | + <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 170 | + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> |
| 171 | + </svg> |
| 172 | + Copy |
| 173 | + </button> |
| 174 | + |
| 175 | + {githubUrl && ( |
| 176 | + <a |
| 177 | + href={githubUrl} |
| 178 | + target="_blank" |
| 179 | + rel="noopener noreferrer" |
| 180 | + className="btn-ghost px-3 py-1.5 text-xs flex items-center gap-1.5" |
| 181 | + > |
| 182 | + <svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"> |
| 183 | + <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /> |
| 184 | + </svg> |
| 185 | + View |
| 186 | + </a> |
| 187 | + )} |
| 188 | + </div> |
| 189 | + </div> |
| 190 | + </div> |
| 191 | + </div> |
| 192 | + </div> |
| 193 | + ); |
| 194 | +} |
0 commit comments