this repo has no description
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}