details.tsx
1 "use client" 2 import { useState, useMemo } from "react" 3 import { motion } from "framer-motion" 4 import { ExternalLink, Loader2 } from "lucide-react" 5 import { formatBlockSize, formatBlockTime, getBlockTransactionsBatch } from '@/services/api/main' 6 import { getRollupName, getAllRollups } from '@/services/api/resolver' 7 interface Transaction { 8 hash: string; 9 index: number; 10 namespace: number; 11 } 12 13 interface SearchResult { 14 type: 'block' | 'transaction' | 'rollup' | 'namespace' | 'block_hash' | 'error' | 'loading' 15 data: unknown 16 query: string 17 displayText?: string 18 } 19 20 interface SearchDetailsProps { 21 selectedResults: SearchResult[] 22 } 23 24 // Foundation component interfaces 25 interface DetailHeaderProps { 26 children: React.ReactNode 27 variant?: 'default' | 'error' 28 } 29 30 interface FieldRowProps { 31 label: string 32 value: string | React.ReactNode 33 copyable?: string 34 className?: string 35 } 36 37 interface HashDisplayProps { 38 label: string 39 hash: string 40 className?: string 41 } 42 43 interface InfoGridProps { 44 children: React.ReactNode 45 className?: string 46 } 47 48 interface ExternalLinkProps { 49 href: string 50 children: React.ReactNode 51 className?: string 52 } 53 54 const CopyButton = ({ text, label = "Copy" }: { text: string; label?: string }) => { 55 const [copied, setCopied] = useState(false); 56 57 const handleCopy = async () => { 58 try { 59 // Try modern clipboard API first 60 if (navigator.clipboard && navigator.clipboard.writeText) { 61 await navigator.clipboard.writeText(text); 62 setCopied(true); 63 setTimeout(() => setCopied(false), 2000); 64 } else { 65 // Fallback for HTTP or older browsers 66 const textArea = document.createElement('textarea'); 67 textArea.value = text; 68 textArea.style.position = 'fixed'; 69 textArea.style.left = '-999999px'; 70 textArea.style.top = '-999999px'; 71 document.body.appendChild(textArea); 72 textArea.focus(); 73 textArea.select(); 74 75 try { 76 const successful = document.execCommand('copy'); 77 if (successful) { 78 setCopied(true); 79 setTimeout(() => setCopied(false), 2000); 80 } 81 } finally { 82 document.body.removeChild(textArea); 83 } 84 } 85 } catch (err) { 86 console.error('Failed to copy:', err); 87 // Still try the fallback method 88 try { 89 const textArea = document.createElement('textarea'); 90 textArea.value = text; 91 textArea.style.position = 'fixed'; 92 textArea.style.left = '-999999px'; 93 document.body.appendChild(textArea); 94 textArea.select(); 95 document.execCommand('copy'); 96 document.body.removeChild(textArea); 97 setCopied(true); 98 setTimeout(() => setCopied(false), 2000); 99 } catch (fallbackErr) { 100 console.error('Fallback copy also failed:', fallbackErr); 101 } 102 } 103 }; 104 105 return ( 106 <button 107 onClick={handleCopy} 108 className="relative text-gray-400 hover:text-gray-600 transition-colors" 109 title={copied ? "Copied!" : `Copy ${label}`} 110 > 111 {copied ? ( 112 <svg className="h-3 w-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 113 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> 114 </svg> 115 ) : ( 116 <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 117 <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" /> 118 </svg> 119 )} 120 {copied && ( 121 <span className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-50"> 122 Copied! 123 </span> 124 )} 125 </button> 126 ); 127 } 128 129 // Foundation Components 130 const DetailHeader = ({ children, variant = 'default' }: DetailHeaderProps) => ( 131 <h3 className={`text-lg font-medium mb-4 ${ 132 variant === 'error' ? 'text-red-600' : 'text-gray-900' 133 }`}>{children}</h3> 134 ) 135 136 const FieldRow = ({ label, value, copyable, className = "" }: FieldRowProps) => ( 137 <div className={`flex items-center gap-2 ${className}`}> 138 <span className="text-gray-500">{label}:</span> 139 <span className="ml-3 font-mono text-sm text-gray-900">{value}</span> 140 {copyable && <CopyButton text={copyable} label={label.toLowerCase()} />} 141 </div> 142 ) 143 144 const HashDisplay = ({ label, hash, className = "" }: HashDisplayProps) => ( 145 <div className={`flex items-center gap-2 ${className}`}> 146 <span className="text-gray-500">{label}:</span> 147 <span className="ml-3 font-mono text-sm text-gray-900 break-all">{hash}</span> 148 <CopyButton text={hash} label={label.toLowerCase()} /> 149 </div> 150 ) 151 152 const InfoGrid = ({ children, className = "" }: InfoGridProps) => ( 153 <div className={`grid grid-cols-1 md:grid-cols-2 gap-3 text-sm ${className}`}> 154 {children} 155 </div> 156 ) 157 158 const ExternalLinkComponent = ({ href, children, className = "" }: ExternalLinkProps) => ( 159 <a 160 href={href} 161 target="_blank" 162 rel="noopener noreferrer" 163 className={`text-blue-600 hover:text-blue-800 underline flex items-center gap-1 ${className}`} 164 > 165 {children} 166 <ExternalLink className="w-3 h-3" /> 167 </a> 168 ) 169 170 export default function SearchDetails({ selectedResults }: SearchDetailsProps) { 171 if (selectedResults.length === 0) return null 172 173 return ( 174 <motion.div 175 initial={{ opacity: 0, y: 20 }} 176 animate={{ opacity: 1, y: 0 }} 177 className="space-y-4" 178 > 179 {selectedResults.map((result, index) => ( 180 <div key={index} className="bg-white border border-gray-200 shadow-sm p-6"> 181 {(result.type === 'block' || result.type === 'block_hash') && ( 182 <BlockDetails result={result} /> 183 )} 184 185 {result.type === 'transaction' && ( 186 <TransactionDetails result={result} /> 187 )} 188 189 {result.type === 'namespace' && ( 190 <NamespaceDetails result={result} /> 191 )} 192 193 {result.type === 'rollup' && ( 194 <RollupDetails result={result} /> 195 )} 196 197 {result.type === 'error' && ( 198 <ErrorDetails result={result} /> 199 )} 200 </div> 201 ))} 202 </motion.div> 203 ) 204 } 205 206 function BlockDetails({ result }: { result: SearchResult }) { 207 const [showTransactions, setShowTransactions] = useState(false) 208 const [batchState, setBatchState] = useState({ 209 loading: false, 210 progress: 0, 211 total: 0, 212 transactions: [] as Transaction[], 213 namespaces: new Set<number>(), 214 error: null as string | null 215 }) 216 const [activeFilter, setActiveFilter] = useState<number | null>(null) 217 218 const blockHeight = (result.data as any)?.height || 0 219 const numTransactions = (result.data as any)?.transactions || (result.data as any)?.num_transactions || 0 220 221 const handleToggleTransactions = async () => { 222 if (!showTransactions && batchState.transactions.length === 0 && numTransactions > 0) { 223 setBatchState(prev => ({ ...prev, loading: true, error: null, progress: 0, total: 0 })) 224 225 const blockTransactions = await getBlockTransactionsBatch(blockHeight) 226 const namespaces = new Set(blockTransactions.map(tx => tx.namespace)) 227 228 setBatchState(prev => ({ 229 ...prev, 230 loading: false, 231 transactions: blockTransactions, 232 namespaces, 233 progress: 1, 234 total: 1 235 })) 236 } 237 setShowTransactions(!showTransactions) 238 } 239 240 const sortedTransactions = useMemo(() => { 241 return batchState.transactions.sort((a, b) => a.index - b.index) 242 }, [batchState.transactions]) 243 244 const filteredTransactions = activeFilter !== null 245 ? sortedTransactions.filter(tx => tx.namespace === activeFilter) 246 : sortedTransactions 247 248 const namespaceGroups = batchState.transactions.reduce((acc, tx) => { 249 acc[tx.namespace] = (acc[tx.namespace] || 0) + 1 250 return acc 251 }, {} as Record<number, number>) 252 253 const uniqueNamespaces = batchState.namespaces.size 254 255 return ( 256 <div className="space-y-6"> 257 <DetailHeader> 258 Block #{String(blockHeight || 'Unknown')} 259 </DetailHeader> 260 261 <HashDisplay 262 label="Hash" 263 hash={(result.data as any)?.hash || 'N/A'} 264 /> 265 266 <InfoGrid> 267 <FieldRow 268 label="Height" 269 value={blockHeight.toString()} 270 /> 271 <FieldRow 272 label="Timestamp" 273 value={new Date(((result.data as any)?.timestamp || 0) * 1000).toLocaleString()} 274 /> 275 <FieldRow 276 label="Size" 277 value={(result.data as any)?.human_readable_size || 278 ((result.data as any)?.size ? `${(result.data as any).size} bytes` : 'N/A')} 279 /> 280 <FieldRow 281 label="Fee" 282 value={`${(result.data as any)?.fee_info?.amount 283 ? (parseInt((result.data as any).fee_info.amount, 10) / 1e18).toFixed(6) 284 : '0.000000' 285 } ETH`} 286 /> 287 </InfoGrid> 288 289 {numTransactions > 0 && ( 290 <div> 291 <button 292 onClick={handleToggleTransactions} 293 disabled={batchState.loading} 294 className="flex items-center justify-between w-full text-sm text-gray-900 hover:text-gray-700 transition-colors disabled:opacity-50" 295 aria-expanded={showTransactions} 296 aria-controls="transaction-list" 297 > 298 <span>Transactions ({numTransactions}) {Array.from(' ').map((_, i) => ( 299 <span key={i} className="inline-block w-6 border-b border-gray-300 mx-1"></span> 300 ))} {showTransactions ? '▼' : '▶'} Details</span> 301 </button> 302 303 {batchState.loading && ( 304 <div className="text-sm text-gray-500 mt-2 flex items-center gap-2"> 305 <Loader2 className="h-3 w-3 animate-spin" /> 306 Loading transactions... 307 </div> 308 )} 309 310 {showTransactions && ( 311 <motion.div 312 id="transaction-list" 313 initial={{ opacity: 0, height: 0 }} 314 animate={{ opacity: 1, height: 'auto' }} 315 exit={{ opacity: 0, height: 0 }} 316 className="mt-4 space-y-3" 317 > 318 {batchState.error ? ( 319 <div className="text-red-600 text-sm"> 320 Error loading transactions: {batchState.error} 321 </div> 322 ) : batchState.transactions.length > 0 ? ( 323 <div className="space-y-3"> 324 {batchState.namespaces.size > 1 && ( 325 <div className="flex flex-wrap gap-2 pb-3 border-b border-gray-200"> 326 <button 327 onClick={() => setActiveFilter(null)} 328 className={`text-xs px-2 py-1 rounded transition-colors ${ 329 activeFilter === null 330 ? 'bg-blue-100 text-blue-800' 331 : 'bg-gray-100 hover:bg-gray-200 text-gray-700' 332 }`} 333 > 334 All 335 </button> 336 {Array.from(batchState.namespaces).sort((a, b) => a - b).map(ns => ( 337 <button 338 key={ns} 339 onClick={() => setActiveFilter(ns)} 340 className={`text-xs px-2 py-1 rounded transition-colors ${ 341 activeFilter === ns 342 ? 'bg-blue-100 text-blue-800' 343 : 'bg-gray-100 hover:bg-gray-200 text-gray-700' 344 }`} 345 > 346 {(() => { 347 const rollupName = getRollupName(ns) 348 return rollupName ? `${rollupName} (${namespaceGroups[ns]})` : `NS: ${ns} (${namespaceGroups[ns]})` 349 })()} 350 </button> 351 ))} 352 </div> 353 )} 354 355 <div className="space-y-2 max-h-64 overflow-y-auto"> 356 {filteredTransactions.length === 0 && !batchState.loading ? ( 357 <div className="text-center py-4 text-gray-500 text-sm"> 358 No transactions found in this block 359 </div> 360 ) : ( 361 filteredTransactions.map((tx, index) => ( 362 <div key={index} className="flex items-center gap-3 py-2 text-sm border-b border-gray-100 last:border-b-0"> 363 <span className="text-gray-500 font-mono">#{tx.index}:</span> 364 <span className="font-mono text-xs text-gray-900 flex-1 min-w-0 truncate"> 365 {tx.hash.startsWith('TX~') ? tx.hash : `TX~${tx.hash}`} 366 </span> 367 <CopyButton text={tx.hash} label="transaction hash" /> 368 <span className="text-gray-400">|</span> 369 <span className="text-gray-900"> 370 {(() => { 371 const rollupName = getRollupName(tx.namespace) 372 return rollupName ? `${rollupName} (${tx.namespace})` : tx.namespace 373 })()} 374 </span> 375 </div> 376 )) 377 )} 378 </div> 379 380 <div className="text-sm text-gray-600 pt-2"> 381 {activeFilter !== null ? ( 382 <>Showing {filteredTransactions.length} transactions from {(() => { 383 const rollupName = getRollupName(activeFilter) 384 return rollupName ? `${rollupName} (${activeFilter})` : `namespace ${activeFilter}` 385 })()}</> 386 ) : ( 387 <>{uniqueNamespaces} {uniqueNamespaces === 1 ? 'namespace' : 'namespaces'}, {batchState.transactions.length} total transactions</> 388 )} 389 </div> 390 </div> 391 ) : ( 392 <div className="text-gray-500 text-sm">No transaction data available</div> 393 )} 394 </motion.div> 395 )} 396 </div> 397 )} 398 </div> 399 ) 400 } 401 402 function TransactionDetails({ result }: { result: SearchResult }) { 403 // Type guards and data extraction 404 const hasTransactionData = result.data && typeof result.data === 'object' && 'transaction' in result.data 405 const transactionData = hasTransactionData ? (result.data as any) : null 406 const transaction = transactionData?.transaction 407 408 // Extract values with proper fallbacks 409 const getTransactionIndex = () => { 410 if (transactionData?.index !== undefined) return transactionData.index 411 if (transaction?.index !== undefined) return transaction.index 412 return '?' 413 } 414 415 const getNamespaceDisplay = () => { 416 if (transaction?.namespace === undefined) return null 417 const namespace = transaction.namespace 418 const rollupName = getRollupName(namespace) 419 return rollupName ? `${namespace} (${rollupName})` : namespace 420 } 421 422 return ( 423 <div className="space-y-4"> 424 <DetailHeader>Transaction Details</DetailHeader> 425 426 <div className="space-y-4"> 427 <HashDisplay 428 label="Transaction Hash" 429 hash={result.query} 430 /> 431 432 {hasTransactionData ? ( 433 <> 434 <InfoGrid> 435 <FieldRow 436 label="Size" 437 value={transaction?.tx_size_bytes ? formatBlockSize(transaction.tx_size_bytes) : 'Unknown'} 438 /> 439 <FieldRow 440 label="Block Height" 441 value={transactionData.block_height || 'Unknown'} 442 /> 443 <FieldRow 444 label="Index" 445 value={`#${getTransactionIndex()}`} 446 /> 447 {transaction?.namespace !== undefined && ( 448 <FieldRow 449 label="Namespace" 450 value={getNamespaceDisplay() || 'Unknown'} 451 /> 452 )} 453 </InfoGrid> 454 455 {transaction?.block_hash && ( 456 <HashDisplay 457 label="Block Hash" 458 hash={transaction.block_hash} 459 /> 460 )} 461 462 {transaction?.timestamp && ( 463 <FieldRow 464 label="Timestamp" 465 value={formatBlockTime(transaction.timestamp) || 'Unknown'} 466 /> 467 )} 468 469 {transaction?.human_readable_time && ( 470 <FieldRow 471 label="Time Ago" 472 value={transaction.human_readable_time} 473 /> 474 )} 475 </> 476 ) : null} 477 </div> 478 </div> 479 ) 480 } 481 482 function NamespaceDetails({ result }: { result: SearchResult }) { 483 const namespaceId = parseInt(result.query) 484 const rollupData = getAllRollups().find(rollup => rollup.namespace === namespaceId) 485 486 if (rollupData) { 487 return ( 488 <div className="space-y-4"> 489 <DetailHeader>{rollupData.name}</DetailHeader> 490 491 <InfoGrid> 492 <FieldRow 493 label="Namespace" 494 value={rollupData.namespace.toString()} 495 /> 496 <FieldRow 497 label="Website" 498 value={<ExternalLinkComponent href={rollupData.website} className="break-all">{rollupData.website}</ExternalLinkComponent>} 499 /> 500 <FieldRow 501 label="Scan" 502 value={<ExternalLinkComponent href={rollupData.scan} className="break-all">{rollupData.scan}</ExternalLinkComponent>} 503 /> 504 </InfoGrid> 505 </div> 506 ) 507 } else { 508 return ( 509 <div className="space-y-4"> 510 <DetailHeader>Namespace #{result.query}</DetailHeader> 511 512 <InfoGrid> 513 <FieldRow 514 label="Namespace ID" 515 value={result.query} 516 /> 517 <FieldRow 518 label="Status" 519 value="No associated rollup found" 520 /> 521 </InfoGrid> 522 </div> 523 ) 524 } 525 } 526 527 function RollupDetails({ result }: { result: SearchResult }) { 528 return ( 529 <div className="space-y-4"> 530 <DetailHeader>Rollup Search Results</DetailHeader> 531 532 {Array.isArray(result.data) && (result.data as any[]).length > 0 ? ( 533 <div className="space-y-4"> 534 {(result.data as any[]).map((rollup, rollupIndex) => ( 535 <div key={rollupIndex} className="border-l-4 border-blue-400 pl-4"> 536 <InfoGrid> 537 <FieldRow 538 label="Name" 539 value={rollup.name} 540 /> 541 <FieldRow 542 label="Namespace" 543 value={rollup.namespace} 544 /> 545 <FieldRow 546 label="Website" 547 value={<ExternalLinkComponent href={rollup.website} className="break-all">{rollup.website}</ExternalLinkComponent>} 548 /> 549 <FieldRow 550 label="Scan" 551 value={<ExternalLinkComponent href={rollup.scan} className="break-all">{rollup.scan}</ExternalLinkComponent>} 552 /> 553 </InfoGrid> 554 </div> 555 ))} 556 </div> 557 ) : ( 558 <div className="text-gray-500">No rollups found for "{result.query}"</div> 559 )} 560 </div> 561 ) 562 } 563 564 function ErrorDetails({ result }: { result: SearchResult }) { 565 return ( 566 <div className="space-y-4"> 567 <DetailHeader variant="error">Search Error</DetailHeader> 568 <div className="text-sm text-red-500"> 569 {(result.data as any)?.error} 570 </div> 571 </div> 572 ) 573 }