@1sat/sweep-ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/SweepApp.d.ts +6 -0
- package/dist/components/SweepApp.d.ts.map +1 -0
- package/dist/components/asset-preview.d.ts +32 -0
- package/dist/components/asset-preview.d.ts.map +1 -0
- package/dist/components/connect-wallet.d.ts +8 -0
- package/dist/components/connect-wallet.d.ts.map +1 -0
- package/dist/components/opns-section.d.ts +13 -0
- package/dist/components/opns-section.d.ts.map +1 -0
- package/dist/components/sweep-progress.d.ts +9 -0
- package/dist/components/sweep-progress.d.ts.map +1 -0
- package/dist/components/tx-history.d.ts +14 -0
- package/dist/components/tx-history.d.ts.map +1 -0
- package/dist/components/ui/badge.d.ts +8 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/button.d.ts +9 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/card.d.ts +10 -0
- package/dist/components/ui/card.d.ts.map +1 -0
- package/dist/components/ui/input.d.ts +4 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/tabs.d.ts +14 -0
- package/dist/components/ui/tabs.d.ts.map +1 -0
- package/dist/components/wif-input.d.ts +9 -0
- package/dist/components/wif-input.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2556 -0
- package/dist/lib/legacy-send.d.ts +24 -0
- package/dist/lib/legacy-send.d.ts.map +1 -0
- package/dist/lib/scanner.d.ts +33 -0
- package/dist/lib/scanner.d.ts.map +1 -0
- package/dist/lib/services.d.ts +4 -0
- package/dist/lib/services.d.ts.map +1 -0
- package/dist/lib/sweeper.d.ts +18 -0
- package/dist/lib/sweeper.d.ts.map +1 -0
- package/dist/lib/utils.d.ts +6 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/wallet.d.ts +9 -0
- package/dist/lib/wallet.d.ts.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +44 -0
- package/src/components/SweepApp.tsx +269 -0
- package/src/components/asset-preview.tsx +224 -0
- package/src/components/connect-wallet.tsx +59 -0
- package/src/components/opns-section.tsx +108 -0
- package/src/components/sweep-progress.tsx +69 -0
- package/src/components/tx-history.tsx +63 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/button.tsx +52 -0
- package/src/components/ui/card.tsx +32 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/tabs.tsx +51 -0
- package/src/components/wif-input.tsx +332 -0
- package/src/index.ts +28 -0
- package/src/lib/legacy-send.ts +234 -0
- package/src/lib/scanner.ts +226 -0
- package/src/lib/services.ts +18 -0
- package/src/lib/sweeper.ts +93 -0
- package/src/lib/utils.ts +23 -0
- package/src/lib/wallet.ts +32 -0
- package/src/types.ts +5 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Badge } from "./ui/badge";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
import { Input } from "./ui/input";
|
|
5
|
+
import { formatSats, formatTokenAmount } from "../lib/utils";
|
|
6
|
+
import type { EnrichedOrdinal, TokenBalance } from "../lib/scanner";
|
|
7
|
+
import type { IndexedOutput } from "@1sat/types";
|
|
8
|
+
|
|
9
|
+
const ORDINALS_PER_PAGE = 20;
|
|
10
|
+
|
|
11
|
+
function isImageType(ct: string): boolean {
|
|
12
|
+
return ct.startsWith("image/") && ct !== "image/svg+xml";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function OrdinalCard({ ordinal, isSelected, onToggle }: { ordinal: EnrichedOrdinal; isSelected: boolean; onToggle: () => void }) {
|
|
16
|
+
const ct = ordinal.contentType ?? "";
|
|
17
|
+
const isImage = isImageType(ct);
|
|
18
|
+
const subtype = ct.includes("/") ? ct.split("/")[1] : ct;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className={`relative p-2 rounded-lg border cursor-pointer transition-all ${isSelected ? "border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30" : "border-border/50 hover:border-border bg-black/20"}`}
|
|
23
|
+
onClick={onToggle}
|
|
24
|
+
>
|
|
25
|
+
<div className="absolute top-1.5 right-1.5 z-10">
|
|
26
|
+
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center text-[10px] ${isSelected ? "bg-blue-500 border-blue-500 text-white" : "border-muted-foreground/40"}`}>
|
|
27
|
+
{isSelected && "\u2713"}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="w-full aspect-square mb-1.5 rounded overflow-hidden bg-black/30 flex items-center justify-center">
|
|
31
|
+
{!ordinal.contentUrl ? (
|
|
32
|
+
<span className="text-muted-foreground text-lg">{"\u25C6"}</span>
|
|
33
|
+
) : isImage ? (
|
|
34
|
+
<img src={ordinal.contentUrl} alt={ordinal.name || "Ordinal"} className="w-full h-full object-cover" loading="lazy" />
|
|
35
|
+
) : (
|
|
36
|
+
<iframe src={ordinal.contentUrl} title={ordinal.name || "Ordinal"} className="w-full h-full border-0 pointer-events-none" sandbox="allow-scripts" loading="lazy" />
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
{subtype && (
|
|
40
|
+
<div className="mb-1">
|
|
41
|
+
<span className="px-1 py-0.5 text-[9px] rounded bg-blue-500/20 text-blue-400 truncate">{subtype}</span>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
{ordinal.name ? (
|
|
45
|
+
<a href={ordinal.contentUrl} target="_blank" rel="noopener noreferrer" className="text-[10px] text-foreground truncate font-medium hover:text-blue-400" title={ordinal.name}>{ordinal.name}</a>
|
|
46
|
+
) : (
|
|
47
|
+
<a href={ordinal.contentUrl} target="_blank" rel="noopener noreferrer" className="text-[9px] text-muted-foreground truncate font-mono hover:text-blue-400">{ordinal.outpoint.substring(0, 8)}...</a>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function FundingSection({ funding, totalBsv, sweepAmount, onSweepAmountChange, onSweep, onSend, walletConnected }: {
|
|
54
|
+
funding: IndexedOutput[]; totalBsv: number; sweepAmount: number | null; onSweepAmountChange: (amount: number | null) => void; onSweep: () => void; onSend: (destination: string) => void; walletConnected: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const [address, setAddress] = useState("");
|
|
57
|
+
if (funding.length === 0) return null;
|
|
58
|
+
const isMax = sweepAmount === null;
|
|
59
|
+
const displayAmount = isMax ? formatSats(totalBsv) : formatSats(sweepAmount!);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="border border-green-500/20 bg-green-500/5 p-4 rounded-lg">
|
|
63
|
+
<div className="flex items-center gap-2 mb-2">
|
|
64
|
+
<span className="h-2 w-2 rounded-full bg-green-500" />
|
|
65
|
+
<span className="text-sm font-semibold text-green-500">BSV Funding</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex items-baseline justify-between mb-3">
|
|
68
|
+
<div>
|
|
69
|
+
<div className="text-2xl font-bold text-green-500">{formatSats(totalBsv)} sats</div>
|
|
70
|
+
<div className="text-xs text-muted-foreground">{(totalBsv / 100_000_000).toFixed(8)} BSV</div>
|
|
71
|
+
</div>
|
|
72
|
+
<Badge variant="secondary">{funding.length} UTXO{funding.length !== 1 ? "s" : ""}</Badge>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<Input type="number" min={0} max={totalBsv} placeholder="Max" value={isMax ? "" : sweepAmount} onChange={(e) => { const val = e.target.value; onSweepAmountChange(val === "" ? null : Math.max(0, Math.min(totalBsv, Number(val)))); }} className="flex-1 font-mono" />
|
|
76
|
+
<span className="text-xs text-muted-foreground">sats</span>
|
|
77
|
+
<Button variant="outline" size="sm" className="h-9 text-xs" onClick={() => onSweepAmountChange(null)} disabled={isMax}>Max</Button>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="mt-3 space-y-2">
|
|
80
|
+
<Input type="text" placeholder="Destination address..." value={address} onChange={(e) => setAddress(e.target.value)} className="font-mono text-xs" />
|
|
81
|
+
<div className="flex gap-2">
|
|
82
|
+
<Button variant="outline" size="sm" className="flex-1" disabled={!address.trim()} onClick={() => onSend(address.trim())}>Send {displayAmount} sats</Button>
|
|
83
|
+
<Button size="sm" className="flex-1" onClick={onSweep} disabled={!walletConnected} title={walletConnected ? undefined : "Connect BRC-100 wallet to sweep"}>Sweep to Wallet</Button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function OrdinalsSection({ ordinals, selectedOrdinals, onToggle, onSelectAll, onDeselectAll, onSweep, onSend, onBurn, walletConnected }: {
|
|
91
|
+
ordinals: EnrichedOrdinal[]; selectedOrdinals: Set<string>; onToggle: (outpoint: string) => void; onSelectAll: () => void; onDeselectAll: () => void; onSweep: () => void; onSend: (destination: string) => void; onBurn: () => void; walletConnected: boolean;
|
|
92
|
+
}) {
|
|
93
|
+
const [page, setPage] = useState(0);
|
|
94
|
+
const [address, setAddress] = useState("");
|
|
95
|
+
if (ordinals.length === 0) return null;
|
|
96
|
+
const totalPages = Math.ceil(ordinals.length / ORDINALS_PER_PAGE);
|
|
97
|
+
const start = page * ORDINALS_PER_PAGE;
|
|
98
|
+
const pageItems = ordinals.slice(start, start + ORDINALS_PER_PAGE);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="border border-blue-500/20 bg-blue-500/5 p-4 rounded-lg">
|
|
102
|
+
<div className="flex items-start justify-between mb-3">
|
|
103
|
+
<div>
|
|
104
|
+
<div className="flex items-center gap-2 mb-1">
|
|
105
|
+
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
|
106
|
+
<span className="text-sm font-semibold text-blue-500">Ordinals</span>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="text-xs text-muted-foreground">
|
|
109
|
+
{ordinals.length} inscription{ordinals.length !== 1 ? "s" : ""}
|
|
110
|
+
{selectedOrdinals.size > 0 && <span className="text-blue-400 ml-1">({selectedOrdinals.size} selected)</span>}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="flex gap-2">
|
|
114
|
+
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={onSelectAll}>Select All</Button>
|
|
115
|
+
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={onDeselectAll} disabled={selectedOrdinals.size === 0}>Deselect</Button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2 mb-3">
|
|
119
|
+
{pageItems.map((ord) => (<OrdinalCard key={ord.outpoint} ordinal={ord} isSelected={selectedOrdinals.has(ord.outpoint)} onToggle={() => onToggle(ord.outpoint)} />))}
|
|
120
|
+
</div>
|
|
121
|
+
{totalPages > 1 && (
|
|
122
|
+
<div className="flex items-center justify-center gap-4">
|
|
123
|
+
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setPage(page - 1)} disabled={page === 0}>Prev</Button>
|
|
124
|
+
<span className="text-xs text-muted-foreground">Page {page + 1} of {totalPages}</span>
|
|
125
|
+
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1}>Next</Button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{selectedOrdinals.size > 0 && (
|
|
129
|
+
<div className="mt-3 space-y-2">
|
|
130
|
+
<Input type="text" placeholder="Destination address..." value={address} onChange={(e) => setAddress(e.target.value)} className="font-mono text-xs" />
|
|
131
|
+
<div className="flex gap-2">
|
|
132
|
+
<Button variant="outline" size="sm" className="flex-1" disabled={!address.trim()} onClick={() => onSend(address.trim())}>Send {selectedOrdinals.size} Ordinal{selectedOrdinals.size !== 1 ? "s" : ""}</Button>
|
|
133
|
+
<Button size="sm" className="flex-1" onClick={onSweep} disabled={!walletConnected} title={walletConnected ? undefined : "Connect BRC-100 wallet to sweep"}>Sweep to Wallet</Button>
|
|
134
|
+
<Button size="sm" className="bg-red-600 hover:bg-red-700 text-white" onClick={() => { if (window.confirm(`Permanently burn ${selectedOrdinals.size} ordinal${selectedOrdinals.size !== 1 ? "s" : ""}? This cannot be undone.`)) onBurn(); }}>Burn</Button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function TokenRow({ tb }: { tb: TokenBalance }) {
|
|
143
|
+
return (
|
|
144
|
+
<div className={`flex items-center justify-between p-3 rounded-lg border ${tb.isActive ? "bg-black/20 border-purple-500/10" : "bg-black/10 border-muted/20 opacity-60"}`}>
|
|
145
|
+
<div className="flex items-center gap-3">
|
|
146
|
+
<img src={tb.icon} alt={tb.symbol || "Token"} className="w-8 h-8 rounded-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
147
|
+
<div>
|
|
148
|
+
<div className="flex items-center gap-2">
|
|
149
|
+
<span className="font-medium text-foreground">{tb.symbol || tb.tokenId.slice(0, 8) + "..."}</span>
|
|
150
|
+
{tb.isActive ? (
|
|
151
|
+
<span className="px-1.5 py-0.5 text-[9px] rounded bg-green-600/20 text-green-700 dark:text-green-400">active</span>
|
|
152
|
+
) : (
|
|
153
|
+
<span className="px-1.5 py-0.5 text-[9px] rounded bg-muted text-muted-foreground">inactive</span>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
<div className="text-xs text-muted-foreground">
|
|
157
|
+
{formatTokenAmount(tb.totalAmount.toString(), tb.decimals)} {tb.symbol || ""}
|
|
158
|
+
<span className="ml-2">({tb.outputs.length} output{tb.outputs.length !== 1 ? "s" : ""})</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function Bsv21Section({ tokens }: { tokens: TokenBalance[] }) {
|
|
167
|
+
if (tokens.length === 0) return null;
|
|
168
|
+
const active = tokens.filter((t) => t.isActive);
|
|
169
|
+
const inactive = tokens.filter((t) => !t.isActive);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className="border border-purple-500/20 bg-purple-500/5 p-4 rounded-lg">
|
|
173
|
+
<div className="flex items-center gap-2 mb-3">
|
|
174
|
+
<span className="h-2 w-2 rounded-full bg-purple-500" />
|
|
175
|
+
<span className="text-sm font-semibold text-purple-500">BSV-21 Tokens</span>
|
|
176
|
+
</div>
|
|
177
|
+
<div className="space-y-3">
|
|
178
|
+
{active.map((tb) => (<TokenRow key={tb.tokenId} tb={tb} />))}
|
|
179
|
+
{inactive.length > 0 && active.length > 0 && (
|
|
180
|
+
<div className="border-t border-purple-500/10 pt-3 mt-3">
|
|
181
|
+
<div className="text-xs text-muted-foreground mb-2">Inactive overlays ({inactive.length}) — cannot be swept</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
{inactive.map((tb) => (<TokenRow key={tb.tokenId} tb={tb} />))}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function Bsv20Section({ tokens }: { tokens: IndexedOutput[] }) {
|
|
191
|
+
if (tokens.length === 0) return null;
|
|
192
|
+
return (
|
|
193
|
+
<div className="border border-muted/30 bg-muted/10 p-4 rounded-lg">
|
|
194
|
+
<div className="flex items-center gap-2 mb-2">
|
|
195
|
+
<span className="h-2 w-2 rounded-full bg-muted-foreground" />
|
|
196
|
+
<span className="text-sm font-semibold text-muted-foreground">BSV-20 Tokens</span>
|
|
197
|
+
</div>
|
|
198
|
+
<p className="text-xs text-muted-foreground mb-2">Cannot be swept automatically.</p>
|
|
199
|
+
<div className="flex flex-wrap gap-2">
|
|
200
|
+
{tokens.slice(0, 10).map((o) => {
|
|
201
|
+
const tickEvent = o.events?.find((e) => e.startsWith("tick:"));
|
|
202
|
+
const tick = tickEvent ? tickEvent.slice(5) : "Token";
|
|
203
|
+
return (<span key={o.outpoint} className="px-2 py-1 text-xs rounded bg-muted/30 text-muted-foreground">{tick}</span>);
|
|
204
|
+
})}
|
|
205
|
+
{tokens.length > 10 && <span className="text-xs text-muted-foreground">+{tokens.length - 10} more</span>}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function LockedSection({ locked }: { locked: IndexedOutput[] }) {
|
|
212
|
+
if (locked.length === 0) return null;
|
|
213
|
+
return (
|
|
214
|
+
<div className="border border-yellow-500/20 bg-yellow-500/5 p-4 rounded-lg">
|
|
215
|
+
<div className="flex items-center gap-2 mb-2">
|
|
216
|
+
<span className="h-2 w-2 rounded-full bg-yellow-500" />
|
|
217
|
+
<span className="text-sm font-semibold text-yellow-500">Locked Outputs</span>
|
|
218
|
+
</div>
|
|
219
|
+
<p className="text-xs text-muted-foreground">
|
|
220
|
+
{locked.length} locked output{locked.length !== 1 ? "s" : ""}. These are in contracts and cannot be swept directly.
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Loader2, Wallet, X } from "lucide-react";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
import { connectWallet, disconnectWallet, getIdentityKey, getProvider } from "../lib/wallet";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
onConnected: () => void;
|
|
8
|
+
onDisconnected: () => void;
|
|
9
|
+
connected: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ConnectWallet({ onConnected, onDisconnected, connected }: Props) {
|
|
13
|
+
const [connecting, setConnecting] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
async function handleConnect() {
|
|
17
|
+
setConnecting(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
try {
|
|
20
|
+
await connectWallet();
|
|
21
|
+
onConnected();
|
|
22
|
+
} catch (e) {
|
|
23
|
+
setError(e instanceof Error ? e.message : "Failed to connect wallet");
|
|
24
|
+
} finally {
|
|
25
|
+
setConnecting(false);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function handleDisconnect() {
|
|
30
|
+
disconnectWallet();
|
|
31
|
+
onDisconnected();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (connected) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex items-center justify-between px-3 py-2 rounded-lg border border-green-500/20 bg-green-500/5">
|
|
37
|
+
<div className="flex items-center gap-2 text-sm">
|
|
38
|
+
<span className="h-2 w-2 rounded-full bg-green-500" />
|
|
39
|
+
<span className="text-muted-foreground">
|
|
40
|
+
{getProvider() === "brc100" ? "BRC-100" : "OneSat"} · {getIdentityKey()?.slice(0, 12)}...
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={handleDisconnect}>
|
|
44
|
+
<X className="h-3 w-3" />
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-1">
|
|
52
|
+
<Button variant="outline" size="sm" className="w-full text-xs gap-2" onClick={handleConnect} disabled={connecting}>
|
|
53
|
+
{connecting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wallet className="h-3 w-3" />}
|
|
54
|
+
{connecting ? "Connecting..." : "Connect BRC-100 Wallet (optional)"}
|
|
55
|
+
</Button>
|
|
56
|
+
{error && <p className="text-xs text-destructive text-center">{error}</p>}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Button } from "./ui/button";
|
|
3
|
+
import { Input } from "./ui/input";
|
|
4
|
+
import type { EnrichedOrdinal } from "../lib/scanner";
|
|
5
|
+
import { getServices } from "../lib/services";
|
|
6
|
+
|
|
7
|
+
export function OpnsSection({ opnsNames, selectedOpns, onToggle, onSelectAll, onDeselectAll, onSweep, onSend, onBurn, walletConnected }: {
|
|
8
|
+
opnsNames: EnrichedOrdinal[]; selectedOpns: Set<string>; onToggle: (outpoint: string) => void; onSelectAll: () => void; onDeselectAll: () => void; onSweep: () => void; onSend: (destination: string) => void; onBurn: () => void; walletConnected: boolean;
|
|
9
|
+
}) {
|
|
10
|
+
const [address, setAddress] = useState("");
|
|
11
|
+
const [resolvedNames, setResolvedNames] = useState<Map<string, string>>(new Map());
|
|
12
|
+
const [overlayValid, setOverlayValid] = useState<Map<string, boolean>>(new Map());
|
|
13
|
+
const [validating, setValidating] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (opnsNames.length === 0) return;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const pending = new Map<string, string>();
|
|
19
|
+
Promise.all(
|
|
20
|
+
opnsNames.map(async (item) => {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(item.contentUrl, { signal: controller.signal });
|
|
23
|
+
if (res.ok) pending.set(item.outpoint, await res.text());
|
|
24
|
+
} catch { /* fetch aborted or failed */ }
|
|
25
|
+
}),
|
|
26
|
+
).then(() => { if (!controller.signal.aborted) setResolvedNames(new Map(pending)); });
|
|
27
|
+
return () => controller.abort();
|
|
28
|
+
}, [opnsNames]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (opnsNames.length === 0) return;
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
setValidating(true);
|
|
34
|
+
const originToOutpoints = new Map<string, string[]>();
|
|
35
|
+
for (const item of opnsNames) {
|
|
36
|
+
const origin = item.origin ?? item.outpoint;
|
|
37
|
+
const list = originToOutpoints.get(origin) ?? [];
|
|
38
|
+
list.push(item.outpoint);
|
|
39
|
+
originToOutpoints.set(origin, list);
|
|
40
|
+
}
|
|
41
|
+
getServices().opns.validateOrigins([...originToOutpoints.keys()])
|
|
42
|
+
.then((result) => {
|
|
43
|
+
if (cancelled) return;
|
|
44
|
+
const map = new Map<string, boolean>();
|
|
45
|
+
for (const [origin, valid] of Object.entries(result)) {
|
|
46
|
+
for (const outpoint of originToOutpoints.get(origin) ?? []) map.set(outpoint, valid);
|
|
47
|
+
}
|
|
48
|
+
setOverlayValid(map);
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {})
|
|
51
|
+
.finally(() => { if (!cancelled) setValidating(false); });
|
|
52
|
+
return () => { cancelled = true; };
|
|
53
|
+
}, [opnsNames]);
|
|
54
|
+
|
|
55
|
+
if (opnsNames.length === 0) return null;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="border border-orange-500/20 bg-orange-500/5 p-4 rounded-lg">
|
|
59
|
+
<div className="flex items-start justify-between mb-3">
|
|
60
|
+
<div>
|
|
61
|
+
<div className="flex items-center gap-2 mb-1">
|
|
62
|
+
<span className="h-2 w-2 rounded-full bg-orange-500" />
|
|
63
|
+
<span className="text-sm font-semibold text-orange-500">OPNS Domains</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="text-xs text-muted-foreground">
|
|
66
|
+
{opnsNames.length} domain{opnsNames.length !== 1 ? "s" : ""}
|
|
67
|
+
{selectedOpns.size > 0 && <span className="text-orange-400 ml-1">({selectedOpns.size} selected)</span>}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="flex gap-2">
|
|
71
|
+
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={onSelectAll}>Select All</Button>
|
|
72
|
+
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={onDeselectAll} disabled={selectedOpns.size === 0}>Deselect</Button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="space-y-1">
|
|
76
|
+
{opnsNames.map((item) => {
|
|
77
|
+
const isSelected = selectedOpns.has(item.outpoint);
|
|
78
|
+
const displayName = resolvedNames.get(item.outpoint) ?? item.outpoint.substring(0, 8) + "...";
|
|
79
|
+
return (
|
|
80
|
+
<div key={item.outpoint} className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all ${isSelected ? "border border-orange-500 bg-orange-500/10 ring-1 ring-orange-500/30" : "border border-border/50 hover:border-border bg-black/20"}`} onClick={() => onToggle(item.outpoint)}>
|
|
81
|
+
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center text-[10px] shrink-0 ${isSelected ? "bg-orange-500 border-orange-500 text-white" : "border-muted-foreground/40"}`}>
|
|
82
|
+
{isSelected && "\u2713"}
|
|
83
|
+
</div>
|
|
84
|
+
<span className="text-sm text-foreground truncate">{displayName}</span>
|
|
85
|
+
{!validating && overlayValid.has(item.outpoint) && (
|
|
86
|
+
overlayValid.get(item.outpoint) ? (
|
|
87
|
+
<span className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">valid</span>
|
|
88
|
+
) : (
|
|
89
|
+
<span className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">invalid</span>
|
|
90
|
+
)
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
{selectedOpns.size > 0 && (
|
|
97
|
+
<div className="mt-3 space-y-2">
|
|
98
|
+
<Input type="text" placeholder="Destination address..." value={address} onChange={(e) => setAddress(e.target.value)} className="font-mono text-xs" />
|
|
99
|
+
<div className="flex gap-2">
|
|
100
|
+
<Button variant="outline" size="sm" className="flex-1" disabled={!address.trim()} onClick={() => onSend(address.trim())}>Send {selectedOpns.size} Domain{selectedOpns.size !== 1 ? "s" : ""}</Button>
|
|
101
|
+
<Button size="sm" className="flex-1" onClick={onSweep} disabled={!walletConnected} title={walletConnected ? undefined : "Connect BRC-100 wallet to sweep"}>Sweep to Wallet</Button>
|
|
102
|
+
<Button size="sm" className="bg-red-600 hover:bg-red-700 text-white" onClick={() => { if (window.confirm(`Permanently burn ${selectedOpns.size} domain${selectedOpns.size !== 1 ? "s" : ""}? This cannot be undone.`)) onBurn(); }}>Burn</Button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CheckCircle2, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
|
|
2
|
+
import type { SweepResult } from "../lib/sweeper";
|
|
3
|
+
|
|
4
|
+
const EXPLORER_BASE = "https://bananablocks.com/tx/";
|
|
5
|
+
|
|
6
|
+
function TxLink({ label, txid }: { label: string; txid: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="border-b border-border/30 pb-2 space-y-1">
|
|
9
|
+
<div className="flex items-center justify-between text-sm">
|
|
10
|
+
<span className="text-muted-foreground">{label}</span>
|
|
11
|
+
<a href={`${EXPLORER_BASE}${txid}`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
|
12
|
+
<ExternalLink className="h-3 w-3" />
|
|
13
|
+
<span className="text-xs">View</span>
|
|
14
|
+
</a>
|
|
15
|
+
</div>
|
|
16
|
+
<code className="text-xs font-mono text-muted-foreground break-all">{txid}</code>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
sweeping: boolean;
|
|
23
|
+
progress: string;
|
|
24
|
+
result: SweepResult | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SweepProgress({ sweeping, progress, result }: Props) {
|
|
28
|
+
if (sweeping) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="text-center space-y-4 py-8">
|
|
31
|
+
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
|
32
|
+
<p className="text-sm text-muted-foreground animate-pulse">{progress}</p>
|
|
33
|
+
<p className="text-xs text-destructive/80">Do not close this page.</p>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!result) return null;
|
|
39
|
+
|
|
40
|
+
const hasErrors = result.errors.length > 0;
|
|
41
|
+
const hasTxids = result.bsvTxid || result.ordinalTxids.length > 0 || result.bsv21Txids.length > 0;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-4 py-4">
|
|
45
|
+
<div className="flex items-center gap-2">
|
|
46
|
+
{hasErrors ? (
|
|
47
|
+
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
|
48
|
+
) : (
|
|
49
|
+
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
|
50
|
+
)}
|
|
51
|
+
<span className="font-semibold">
|
|
52
|
+
{hasErrors && !hasTxids ? "Failed" : hasErrors ? "Completed with Errors" : "Complete"}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{result.bsvTxid && <TxLink label="BSV" txid={result.bsvTxid} />}
|
|
57
|
+
{result.ordinalTxids.map((txid) => (
|
|
58
|
+
<TxLink key={txid} label="Ordinals" txid={txid} />
|
|
59
|
+
))}
|
|
60
|
+
{result.bsv21Txids.map((txid) => (
|
|
61
|
+
<TxLink key={txid} label="Tokens" txid={txid} />
|
|
62
|
+
))}
|
|
63
|
+
|
|
64
|
+
{result.errors.map((err) => (
|
|
65
|
+
<p key={err} className="text-xs text-destructive">{err}</p>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Loader2, ExternalLink } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
const EXPLORER_BASE = "https://bananablocks.com/tx/";
|
|
4
|
+
|
|
5
|
+
export interface TxRecord {
|
|
6
|
+
label: string;
|
|
7
|
+
txid: string;
|
|
8
|
+
timestamp: Date;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
sweeping: boolean;
|
|
14
|
+
progress: string;
|
|
15
|
+
history: TxRecord[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TxHistory({ sweeping, progress, history }: Props) {
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
{sweeping && (
|
|
22
|
+
<div className="text-center space-y-4 py-8">
|
|
23
|
+
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
|
24
|
+
<p className="text-sm text-muted-foreground animate-pulse">{progress}</p>
|
|
25
|
+
<p className="text-xs text-destructive/80">Do not close this page.</p>
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
{history.length > 0 && !sweeping && (
|
|
30
|
+
<div className="border border-border/50 rounded-lg p-3 space-y-2">
|
|
31
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
32
|
+
Transactions ({history.length})
|
|
33
|
+
</div>
|
|
34
|
+
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
35
|
+
{[...history].reverse().map((tx, i) => (
|
|
36
|
+
<div key={`${tx.txid}-${i}`} className="flex items-center justify-between gap-2 text-xs">
|
|
37
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
38
|
+
<span className={tx.error ? "text-red-500" : "text-green-500"}>
|
|
39
|
+
{tx.error ? "\u2717" : "\u2713"}
|
|
40
|
+
</span>
|
|
41
|
+
<span className="text-muted-foreground truncate">{tx.label}</span>
|
|
42
|
+
</div>
|
|
43
|
+
{tx.error ? (
|
|
44
|
+
<span className="text-red-500 text-[10px] truncate max-w-[200px]">{tx.error}</span>
|
|
45
|
+
) : (
|
|
46
|
+
<a
|
|
47
|
+
href={`${EXPLORER_BASE}${tx.txid}`}
|
|
48
|
+
target="_blank"
|
|
49
|
+
rel="noopener noreferrer"
|
|
50
|
+
className="text-blue-400 hover:text-blue-300 flex items-center gap-1 shrink-0"
|
|
51
|
+
>
|
|
52
|
+
<code className="text-[10px] font-mono">{tx.txid.substring(0, 12)}...</code>
|
|
53
|
+
<ExternalLink className="h-3 w-3" />
|
|
54
|
+
</a>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "../../lib/utils"
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
|
11
|
+
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
|
12
|
+
destructive: "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
|
13
|
+
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
14
|
+
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
15
|
+
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
variant: "default",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
function Badge({
|
|
25
|
+
className,
|
|
26
|
+
variant = "default",
|
|
27
|
+
...props
|
|
28
|
+
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
data-slot="badge"
|
|
32
|
+
data-variant={variant}
|
|
33
|
+
className={cn(badgeVariants({ variant }), className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "../../lib/utils"
|
|
4
|
+
|
|
5
|
+
const buttonVariants = cva(
|
|
6
|
+
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
11
|
+
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
|
12
|
+
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
13
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
14
|
+
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
15
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
16
|
+
},
|
|
17
|
+
size: {
|
|
18
|
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
19
|
+
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
20
|
+
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
|
21
|
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
22
|
+
icon: "size-9",
|
|
23
|
+
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
|
24
|
+
"icon-sm": "size-8",
|
|
25
|
+
"icon-lg": "size-10",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
variant: "default",
|
|
30
|
+
size: "default",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
function Button({
|
|
36
|
+
className,
|
|
37
|
+
variant = "default",
|
|
38
|
+
size = "default",
|
|
39
|
+
...props
|
|
40
|
+
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
data-slot="button"
|
|
44
|
+
data-variant={variant}
|
|
45
|
+
data-size={size}
|
|
46
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../lib/utils"
|
|
3
|
+
|
|
4
|
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
5
|
+
return (<div data-slot="card" className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)} {...props} />)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
9
|
+
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props} />)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
13
|
+
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
17
|
+
return (<div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
21
|
+
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props} />)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
25
|
+
return (<div data-slot="card-content" className={cn("px-6", className)} {...props} />)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
29
|
+
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|