this repo has no description
at main 366 lines 12 kB view raw
1import { useState, useEffect } from 'react'; 2import { getBalance, getUtxos, getTransaction, getRecentTransactions, formatToshis } from '../lib/api'; 3import type { Balance, UTXO, Transaction, RecentTransaction } from '../lib/api'; 4import { Stack, Divider } from './ui/Layout'; 5import { 6 Terminal, 7 Section, 8 LabelValue, 9 DataTable, 10 DataRow, 11 ActionBar, 12 Message, 13} from './ui/Terminal'; 14 15type SearchResult = 16 | { type: 'account'; did: string; handle?: string; balance: Balance; utxos: UTXO[] } 17 | { type: 'transaction'; txId: string; transaction: Transaction } 18 | null; 19 20const handleCache = new Map<string, string>(); 21 22async function resolveHandle(input: string): Promise<{ did: string; handle?: string } | null> { 23 // If it's already a DID, return it 24 if (input.startsWith('did:')) { 25 // Try to get handle for display 26 try { 27 const res = await fetch(`https://plc.directory/${input}`); 28 if (res.ok) { 29 const doc = await res.json(); 30 const handle = doc.alsoKnownAs?.[0]?.replace('at://', ''); 31 if (handle) handleCache.set(input, handle); 32 return { did: input, handle }; 33 } 34 } catch {} 35 return { did: input }; 36 } 37 38 // Remove @ prefix if present 39 const handle = input.startsWith('@') ? input.slice(1) : input; 40 41 // Check cache 42 if (handleCache.has(handle)) { 43 return { did: handleCache.get(handle)!, handle }; 44 } 45 46 // Resolve handle to DID 47 try { 48 const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 49 if (res.ok) { 50 const data = await res.json(); 51 handleCache.set(handle, data.did); 52 return { did: data.did, handle }; 53 } 54 } catch {} 55 56 return null; 57} 58 59async function resolveDid(did: string): Promise<string> { 60 if (handleCache.has(did)) return handleCache.get(did)!; 61 62 try { 63 // For did:web, extract the domain directly 64 if (did.startsWith('did:web:')) { 65 const domain = did.replace('did:web:', ''); 66 handleCache.set(did, domain); 67 return domain; 68 } 69 70 // For did:plc, use PLC directory 71 if (did.startsWith('did:plc:')) { 72 const res = await fetch(`https://plc.directory/${did}`); 73 if (res.ok) { 74 const doc = await res.json(); 75 const handle = doc.alsoKnownAs?.[0]?.replace('at://', '') || did; 76 handleCache.set(did, handle); 77 return handle; 78 } 79 } 80 } catch {} 81 82 return did; 83} 84 85export default function Explorer() { 86 const [query, setQuery] = useState(''); 87 const [loading, setLoading] = useState(false); 88 const [error, setError] = useState<string | null>(null); 89 const [result, setResult] = useState<SearchResult>(null); 90 const [resolvedHandles, setResolvedHandles] = useState<Map<string, string>>(new Map()); 91 const [recentTxs, setRecentTxs] = useState<RecentTransaction[]>([]); 92 const [loadingRecent, setLoadingRecent] = useState(true); 93 94 // Load recent transactions on mount 95 useEffect(() => { 96 const loadRecent = async () => { 97 try { 98 const data = await getRecentTransactions(20); 99 setRecentTxs(data.transactions); 100 } catch (e) { 101 console.error('Failed to load recent transactions:', e); 102 } finally { 103 setLoadingRecent(false); 104 } 105 }; 106 loadRecent(); 107 }, []); 108 109 const search = async () => { 110 if (!query.trim()) return; 111 112 setLoading(true); 113 setError(null); 114 setResult(null); 115 116 const input = query.trim(); 117 118 try { 119 // Check if it looks like a transaction ID (TID format: 13 chars, alphanumeric) 120 const isTxId = /^[a-z0-9]{13}$/i.test(input); 121 122 if (isTxId) { 123 // Search for transaction 124 const txResult = await getTransaction(input); 125 126 // Resolve handles for display 127 const dids = new Set<string>(); 128 txResult.transaction.outputs.forEach(o => dids.add(o.owner)); 129 const trigger = txResult.transaction.trigger; 130 if (trigger) { 131 if ('followerDid' in trigger) { 132 dids.add(trigger.followerDid); 133 } else if ('senderDid' in trigger) { 134 dids.add(trigger.senderDid); 135 } 136 } 137 138 const handles = new Map<string, string>(); 139 await Promise.all( 140 Array.from(dids).map(async did => { 141 const handle = await resolveDid(did); 142 handles.set(did, handle); 143 }) 144 ); 145 setResolvedHandles(handles); 146 147 setResult({ 148 type: 'transaction', 149 txId: input, 150 transaction: txResult.transaction, 151 }); 152 } else { 153 // Search for account 154 const resolved = await resolveHandle(input); 155 if (!resolved) { 156 throw new Error('Could not resolve handle or DID'); 157 } 158 159 const [balanceResult, utxosResult] = await Promise.all([ 160 getBalance(resolved.did), 161 getUtxos(resolved.did), 162 ]); 163 164 setResult({ 165 type: 'account', 166 did: resolved.did, 167 handle: resolved.handle, 168 balance: balanceResult, 169 utxos: utxosResult.utxos, 170 }); 171 } 172 } catch (e) { 173 setError(e instanceof Error ? e.message : 'Search failed'); 174 } finally { 175 setLoading(false); 176 } 177 }; 178 179 const formatDid = (did: string) => { 180 const handle = resolvedHandles.get(did); 181 if (handle && handle !== did) { 182 return `@${handle}`; 183 } 184 return did.length > 24 ? `${did.slice(0, 24)}...` : did; 185 }; 186 187 const handleKeyDown = (e: React.KeyboardEvent) => { 188 if (e.key === 'Enter') { 189 search(); 190 } 191 }; 192 193 return ( 194 <Terminal subtitle="EXPLORER" showBack> 195 <Section title="SEARCH"> 196 <Stack gap="sm"> 197 <div className="input-field" onKeyDown={handleKeyDown}> 198 <label className="input-label">QUERY:</label>{' '} 199 <input 200 type="text" 201 value={query} 202 onChange={(e) => setQuery(e.target.value)} 203 placeholder="@handle, did:plc:..., or txid" 204 style={{ width: '300px' }} 205 /> 206 </div> 207 <button className="btn" onClick={search} disabled={loading || !query.trim()}> 208 {loading ? '[SEARCHING...]' : '[SEARCH]'} 209 </button> 210 </Stack> 211 </Section> 212 213 {error && ( 214 <> 215 <Divider /> 216 <Section> 217 <Message type="error">{error}</Message> 218 </Section> 219 </> 220 )} 221 222 {/* Show recent transactions when no search result */} 223 {!result && !error && ( 224 <> 225 <Divider /> 226 <Section title="RECENT TRANSACTIONS"> 227 {loadingRecent ? ( 228 <p className="text-muted">Loading...</p> 229 ) : recentTxs.length === 0 ? ( 230 <p className="text-muted">No transactions yet</p> 231 ) : ( 232 <DataTable 233 headers={['TYPE', 'AMOUNT', 'TIME', 'TX']} 234 widths={['5rem', '10rem', '8rem', '1fr']} 235 > 236 {recentTxs.map((tx) => ( 237 <DataRow 238 key={tx.txId} 239 cells={[ 240 tx.type.toUpperCase(), 241 `${formatToshis(tx.totalAmount)} @toshis`, 242 new Date(tx.createdAt).toLocaleTimeString(), 243 <a href={`/tx/${tx.txId}`} className="link">{tx.txId}</a>, 244 ]} 245 widths={['5rem', '10rem', '8rem', '1fr']} 246 /> 247 ))} 248 </DataTable> 249 )} 250 </Section> 251 </> 252 )} 253 254 {result?.type === 'account' && ( 255 <> 256 <Divider /> 257 <Section title="ACCOUNT"> 258 <Stack gap="xs"> 259 {result.handle && ( 260 <LabelValue label="HANDLE" value={`@${result.handle}`} /> 261 )} 262 <LabelValue label="DID" value={result.did.length > 40 ? `${result.did.slice(0, 40)}...` : result.did} /> 263 <LabelValue label="BALANCE" value={`${formatToshis(result.balance.balance)} @toshis`} /> 264 <LabelValue label="UTXOS" value={result.balance.utxoCount} /> 265 </Stack> 266 </Section> 267 268 {result.utxos.length > 0 && ( 269 <> 270 <Divider /> 271 <Section title="UNSPENT OUTPUTS"> 272 <DataTable 273 headers={['#', 'AMOUNT', 'TX']} 274 widths={['2rem', '10rem', '1fr']} 275 > 276 {result.utxos.slice(0, 10).map((utxo, i) => ( 277 <DataRow 278 key={`${utxo.txId}:${utxo.index}`} 279 cells={[ 280 String(i + 1), 281 `${formatToshis(utxo.amount)} @toshis`, 282 <a href={`/tx/${utxo.txId}`} className="link">{utxo.txId}</a>, 283 ]} 284 widths={['2rem', '10rem', '1fr']} 285 /> 286 ))} 287 </DataTable> 288 {result.utxos.length > 10 && ( 289 <p className="text-muted">...and {result.utxos.length - 10} more</p> 290 )} 291 </Section> 292 </> 293 )} 294 </> 295 )} 296 297 {result?.type === 'transaction' && ( 298 <> 299 <Divider /> 300 <Section title="TRANSACTION"> 301 <Stack gap="xs"> 302 <LabelValue label="TXID" value={result.txId} /> 303 <LabelValue label="TYPE" value={result.transaction.type.toUpperCase()} /> 304 <LabelValue label="DATE" value={new Date(result.transaction.createdAt).toLocaleString()} /> 305 {result.transaction.trigger && 'followerDid' in result.transaction.trigger && ( 306 <LabelValue label="TRIGGERED BY" value={formatDid((result.transaction.trigger as { followerDid: string }).followerDid)} /> 307 )} 308 {result.transaction.trigger && 'senderDid' in result.transaction.trigger && ( 309 <LabelValue label="SENT BY" value={formatDid((result.transaction.trigger as { senderDid: string }).senderDid)} /> 310 )} 311 </Stack> 312 </Section> 313 314 <Divider /> 315 <Section title="RECIPIENTS"> 316 <DataTable 317 headers={['#', 'AMOUNT', 'TO']} 318 widths={['2rem', '10rem', '1fr']} 319 > 320 {result.transaction.outputs.map((output, i) => ( 321 <DataRow 322 key={i} 323 cells={[ 324 String(i + 1), 325 `${formatToshis(output.amount)} @toshis`, 326 formatDid(output.owner), 327 ]} 328 widths={['2rem', '10rem', '1fr']} 329 /> 330 ))} 331 </DataTable> 332 </Section> 333 334 {result.transaction.inputs.length > 0 && ( 335 <> 336 <Divider /> 337 <Section title="SPENT COINS"> 338 <DataTable 339 headers={['#', 'SOURCE TX']} 340 widths={['2rem', '1fr']} 341 > 342 {result.transaction.inputs.map((input, i) => ( 343 <DataRow 344 key={i} 345 cells={[ 346 String(i + 1), 347 <a href={`/tx/${input.txId}`} className="link">{input.txId}:{input.index}</a>, 348 ]} 349 widths={['2rem', '1fr']} 350 /> 351 ))} 352 </DataTable> 353 </Section> 354 </> 355 )} 356 </> 357 )} 358 359 <Divider /> 360 <ActionBar> 361 <a href="/wallet" className="btn">[WALLET]</a> 362 <a href="/whitepaper" className="btn secondary">[WHITEPAPER]</a> 363 </ActionBar> 364 </Terminal> 365 ); 366}