responsive ui + wallet redesign + 401 fix + round cards
This commit is contained in:
@@ -123,14 +123,14 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
@apply font-sans h-full overflow-hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "ss01", "cv01";
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground h-full overflow-hidden;
|
||||
}
|
||||
::selection {
|
||||
background: oklch(0.5 0 0 / 25%);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function createMoneroWalletState(): MoneroWalletState {
|
||||
process: null,
|
||||
rpcPort: 28084,
|
||||
rpcUser: 'koins',
|
||||
rpcPassword: crypto.randomUUID(),
|
||||
rpcPassword: 'koins-local-rpc',
|
||||
daemonAddress: DEFAULT_DAEMON,
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,20 @@ export async function start(
|
||||
)
|
||||
state.daemonAddress = daemonAddress
|
||||
|
||||
// Kill any existing monero-wallet-rpc on the port (stale process from dev reloads)
|
||||
try {
|
||||
const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], {
|
||||
stdout: 'pipe',
|
||||
})
|
||||
const pids = proc.stdout.toString().trim().split('\n').filter(Boolean)
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(Number(pid), 'SIGKILL')
|
||||
console.log(`[monero] killed stale process ${pid} on port ${state.rpcPort}`)
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log(`[monero] starting wallet-rpc process...`)
|
||||
console.log(`[monero] daemon address: ${state.daemonAddress}`)
|
||||
console.log(`[monero] wallet dir: ${getWalletDir()}`)
|
||||
@@ -356,7 +370,9 @@ export async function getTransferDetails(
|
||||
let t: any = null
|
||||
let dests: any[] = []
|
||||
try {
|
||||
console.log('[monero] trying get_transfer_by_txid for', txid)
|
||||
const result = await rawRpc(state, 'get_transfer_by_txid', { txid })
|
||||
console.log('[monero] get_transfer_by_txid result:', result ? 'has result' : 'null')
|
||||
if (result?.transfer) {
|
||||
t = result.transfer
|
||||
dests =
|
||||
@@ -365,10 +381,11 @@ export async function getTransferDetails(
|
||||
amount: d.amount?.toString() ?? '0',
|
||||
})) ?? []
|
||||
}
|
||||
} catch {
|
||||
// get_transfer_by_txid can fail for various reasons; fall back
|
||||
} catch (e: any) {
|
||||
console.log('[monero] get_transfer_by_txid failed:', e?.message?.slice(0, 100))
|
||||
}
|
||||
if (!t) {
|
||||
console.log('[monero] falling back to get_transfers scan')
|
||||
const params: Record<string, any> = {
|
||||
in: true,
|
||||
out: true,
|
||||
@@ -376,9 +393,11 @@ export async function getTransferDetails(
|
||||
pool: true,
|
||||
}
|
||||
const result = await rawRpc(state, 'get_transfers', params)
|
||||
console.log('[monero] get_transfers result keys:', Object.keys(result ?? {}))
|
||||
let found: any = null
|
||||
for (const dir of ['in', 'out', 'pending', 'pool'] as const) {
|
||||
const transfers = result[dir] ?? []
|
||||
console.log(`[monero] ${dir}: ${transfers.length} transfers`)
|
||||
for (const tr of transfers) {
|
||||
if (tr.txid === txid) {
|
||||
found = { ...tr, type: dir === 'in' ? 'in' : 'out' }
|
||||
@@ -387,7 +406,12 @@ export async function getTransferDetails(
|
||||
}
|
||||
if (found) break
|
||||
}
|
||||
if (!found) return null
|
||||
if (!found) {
|
||||
console.log('[monero] txid not found in get_transfers. Available txids:',
|
||||
['in', 'out', 'pending', 'pool'].flatMap(d => (result[d] ?? []).map((t: any) => t.txid?.slice(0, 12))))
|
||||
return null
|
||||
}
|
||||
console.log('[monero] found transfer in get_transfers')
|
||||
t = found
|
||||
}
|
||||
const isIn = t.type === 'in'
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
class={cn("rounded-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
"gap-1 rounded-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col", className)}
|
||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-xl *:[img:last-child]:rounded-xl group/card flex flex-col", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<button
|
||||
onclick={() => navigate('/xrp')}
|
||||
class="text-left transition-transform active:scale-[0.98]">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
if (!moneroWallet.ready) await moneroWallet.init()
|
||||
await Promise.allSettled([
|
||||
xrpWallet.autoUnlock(),
|
||||
moneroWallet.autoUnlock(),
|
||||
moneroWallet.login(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -144,8 +144,8 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-2xl px-8 py-8">
|
||||
<main class="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-5xl px-6 pt-6 pb-6 lg:px-10">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !w.ready}
|
||||
<p class="text-center text-muted-foreground text-sm mt-8">
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send XMR</CardTitle>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction Details</CardTitle>
|
||||
|
||||
@@ -16,15 +16,14 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import Copy from '@lucide/svelte/icons/copy'
|
||||
import Send from '@lucide/svelte/icons/send'
|
||||
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
|
||||
import Plus from '@lucide/svelte/icons/plus'
|
||||
import Loader from '$lib/components/loader.svelte'
|
||||
import QrCode from '$lib/components/qr.svelte'
|
||||
import { navigate, route } from 'sv-router/generated'
|
||||
@@ -104,8 +103,7 @@
|
||||
|
||||
const walletName = $derived(route.params.name)
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
|
||||
$state()
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
if (w.walletOpen && !autoRefreshTimer) {
|
||||
@@ -146,555 +144,306 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col items-center py-2">
|
||||
{#if w.loading}
|
||||
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">Balance</p>
|
||||
<p class="mt-1 font-mono text-4xl font-semibold tabular-nums tracking-tight">
|
||||
{w.balance} <span class="text-muted-foreground text-2xl">XMR</span>
|
||||
</p>
|
||||
{#if moneroPrice}
|
||||
<p class="mt-1 text-sm text-muted-foreground tabular-nums">
|
||||
≈ ${(parseFloat(w.balance) * parseFloat(moneroPrice.usd)).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if w.unlockedAtomic !== w.balAtomic}
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Unlocked: {w.unlocked} XMR
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<div class="size-2 rounded-full {w.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{w.connected ? 'Connected' : 'Disconnected'}
|
||||
{#if w.height < w.daemonHeight}
|
||||
· Scanning {w.height.toLocaleString()} / {w.daemonHeight.toLocaleString()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<Button onclick={() => navigate('/monero/send')} class="gap-2">
|
||||
<Send size={16} />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">Address</p>
|
||||
<p class="font-mono text-xs break-all">
|
||||
<span>{chunkAddress(w.address).start}</span>
|
||||
<span class="text-muted-foreground">{chunkAddress(w.address).mid}</span>
|
||||
<span class="text-muted-foreground/40">...</span>
|
||||
<span>{chunkAddress(w.address).end}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1 ml-2">
|
||||
<Dialog.Root bind:open={qrDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost" size="icon-sm" class="text-muted-foreground" aria-label="QR code">
|
||||
<QrCodeIcon size={14} />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={w.address} size={256} />
|
||||
<p class="font-mono text-xs break-all text-center max-w-64">{w.address}</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton text={w.address} variant="ghost" size="icon-sm" class="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
{#if feeEstimate}
|
||||
<p class="mt-2 text-center text-xs text-muted-foreground">
|
||||
Fee: ~{atomicToXmr(((BigInt(feeEstimate.fees[2]) * 2500n) / 1000n).toString())} XMR (Normal)
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if balanceHistory.length > 1}
|
||||
<Card>
|
||||
{@const a = chunkAddress(w.address)}
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>Monero Wallet</CardTitle>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="size-2 rounded-full {w.connected
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'}">
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{w.connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<CardTitle class="text-sm">Balance History</CardTitle>
|
||||
<div class="inline-flex rounded-lg bg-muted p-0.5">
|
||||
{#each ['1D', '1W', '1M', '1Y', 'ALL'] as const as range}
|
||||
<button
|
||||
onclick={() => (selectedRange = range)}
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all {selectedRange === range ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}">
|
||||
{range}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{#if w.height < w.daemonHeight}
|
||||
Scanning: {w.height.toLocaleString()} / {w.daemonHeight.toLocaleString()}
|
||||
{:else}
|
||||
Height: {w.height.toLocaleString()}
|
||||
{/if}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-4 space-y-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<p class="font-mono text-xs break-all">
|
||||
<span class="text-primary-foreground">
|
||||
{a.start}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{a.mid}</span>
|
||||
<span class="text-muted-foreground/40">...</span>
|
||||
<span class="text-primary-foreground">
|
||||
{a.end}
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<Dialog.Root bind:open={qrDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label="Show QR code">
|
||||
<QrCodeIcon size={16} />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={w.address} size={256} />
|
||||
<p
|
||||
class="font-mono text-xs break-all text-center max-w-64">
|
||||
{w.address}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton
|
||||
text={w.address}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground">
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 font-medium text-xs">Balance</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="font-mono text-lg">{w.balance} XMR</p>
|
||||
{#if moneroPrice}
|
||||
<p class="font-mono text-sm text-muted-foreground">
|
||||
≈ ${(
|
||||
parseFloat(w.balance) * parseFloat(moneroPrice.usd)
|
||||
).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if w.unlockedAtomic !== w.balAtomic}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Unlocked: {w.unlocked} XMR
|
||||
</p>
|
||||
{/if}
|
||||
{#if feeEstimate}
|
||||
{@const labels = [
|
||||
'Unimportant',
|
||||
'Normal',
|
||||
'Elevated',
|
||||
'Priority',
|
||||
]}
|
||||
<div class="mt-1 space-y-0.5">
|
||||
{#each feeEstimate.fees as f, i}
|
||||
{@const feeAtomic = (BigInt(f) * 2500n) / 1000n}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{labels[i]}: ~{atomicToXmr(feeAtomic.toString())} XMR
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if moneroMnemonic}
|
||||
<div class="mb-4 rounded-md bg-muted p-3">
|
||||
<p class="text-xs font-medium mb-1">
|
||||
Seed phrase (save this!)
|
||||
</p>
|
||||
<p class="font-mono text-xs break-all">
|
||||
{moneroMnemonic}
|
||||
</p>
|
||||
</div>
|
||||
{#if chartData.length > 1}
|
||||
<Chart.Container config={{ balance: { label: 'XMR', color: 'var(--chart-1)' } }}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
x="date"
|
||||
xScale={scaleUtc()}
|
||||
axis="x"
|
||||
points
|
||||
series={[{ key: 'balance', label: 'XMR', color: 'var(--chart-1)' }]}
|
||||
yDomain={[
|
||||
chartData.reduce((a, b) => (a.balance < b.balance ? a : b)).balance,
|
||||
chartData.reduce((a, b) => (a.balance > b.balance ? a : b)).balance,
|
||||
]}
|
||||
props={{
|
||||
spline: { curve: curveLinear, motion: 'tween', strokeWidth: 2 },
|
||||
yAxis: {},
|
||||
xAxis: { format: (v: Date) => v.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) },
|
||||
highlight: { points: { r: 4 } },
|
||||
}}>
|
||||
{#snippet tooltip()}
|
||||
<Chart.Tooltip
|
||||
labelFormatter={(value: Date) => value.toLocaleDateString()}
|
||||
valueFormatter={(value: Number) => value.toString().slice(0, 8)} />
|
||||
{/snippet}
|
||||
</LineChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-muted-foreground py-4">No data in this range</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/monero/send')}>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/monero/settings')}>
|
||||
<SettingsIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={async () => {
|
||||
await w.logout()
|
||||
navigate('/monero')
|
||||
}}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Monero transactions -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-sm">Transactions</CardTitle>
|
||||
{#if w.accounts.length > 0}
|
||||
<CardDescription>
|
||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||
{#each w.accounts as acct}
|
||||
<button
|
||||
onclick={() => {
|
||||
w.selectedAccountIndex = acct.index
|
||||
w.refresh()
|
||||
}}
|
||||
class="rounded-md px-2 py-0.5 text-xs transition-colors {w.selectedAccountIndex ===
|
||||
acct.index
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}">
|
||||
Account {acct.index}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</CardDescription>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.loading}
|
||||
<div class="flex justify-center py-4">
|
||||
<Loader />
|
||||
</div>
|
||||
{:else if w.txs.length === 0}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
No transactions found
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-96 space-y-1 overflow-y-auto">
|
||||
{#each w.txs as tx}
|
||||
<div class="inline-flex rounded-lg bg-muted p-0.5">
|
||||
{#each w.accounts as acct}
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate(`/monero/tx/:hash`, {
|
||||
params: {
|
||||
hash: tx.hash,
|
||||
},
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<div
|
||||
class="shrink-0 mt-0.5 {tx.direction === 'in'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'}">
|
||||
onclick={() => { w.selectedAccountIndex = acct.index; w.refresh() }}
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all {w.selectedAccountIndex === acct.index ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}">
|
||||
{acct.index}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.loading}
|
||||
<div class="flex justify-center py-8"><Loader /></div>
|
||||
{:else if w.txs.length === 0}
|
||||
<p class="text-center text-sm text-muted-foreground py-8">No transactions yet</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each w.txs as tx}
|
||||
<button
|
||||
onclick={() => navigate(`/monero/tx/:hash`, { params: { hash: tx.hash } })}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-muted transition-colors">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<div class={tx.direction === 'in' ? 'text-green-500' : 'text-muted-foreground'}>
|
||||
{#if tx.direction === 'in'}
|
||||
<ArrowDown size={16} />
|
||||
{:else}
|
||||
<ArrowUp size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="font-medium">
|
||||
{tx.direction === 'in' ? 'Received' : 'Sent'}
|
||||
</p>
|
||||
<p class="font-mono text-muted-foreground">
|
||||
{atomicToXmr(tx.amount)} XMR
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{tx.direction === 'in' ? 'Received' : 'Sent'}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if tx.timestamp && tx.timestamp !== '0'}
|
||||
<p class="text-muted-foreground">
|
||||
{new Date(
|
||||
Number(tx.timestamp) * 1000,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
{new Date(Number(tx.timestamp) * 1000).toLocaleString()}
|
||||
{/if}
|
||||
<p
|
||||
class="text-muted-foreground/50 truncate font-mono">
|
||||
{tx.hash.slice(0, 12)}...
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</p>
|
||||
</div>
|
||||
<p class="shrink-0 font-mono text-sm tabular-nums {tx.direction === 'in' ? 'text-green-500' : ''}">
|
||||
{tx.direction === 'in' ? '+' : '-'}{atomicToXmr(tx.amount)} XMR
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if balanceHistory.length > 1}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>Balance</CardTitle>
|
||||
<div class="flex gap-1">
|
||||
{#each ['1D', '1W', '1M', '1Y', 'ALL'] as const as range}
|
||||
<button
|
||||
onclick={() => (selectedRange = range)}
|
||||
class="rounded px-1.5 py-0.5 text-xs font-medium transition-colors {selectedRange ===
|
||||
range
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'}">
|
||||
{range}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if chartData.length > 1}
|
||||
<Chart.Container
|
||||
config={{
|
||||
balance: { label: 'XMR', color: 'var(--chart-1)' },
|
||||
}}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
x="date"
|
||||
xScale={scaleUtc()}
|
||||
axis="x"
|
||||
points
|
||||
series={[
|
||||
{
|
||||
key: 'balance',
|
||||
label: 'XMR',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
]}
|
||||
yDomain={[
|
||||
chartData.reduce((a, b) =>
|
||||
a.balance < b.balance ? a : b,
|
||||
).balance,
|
||||
chartData.reduce((a, b) =>
|
||||
a.balance > b.balance ? a : b,
|
||||
).balance,
|
||||
]}
|
||||
props={{
|
||||
spline: {
|
||||
curve: curveLinear,
|
||||
motion: 'tween',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
yAxis: {},
|
||||
xAxis: {
|
||||
format: (v: Date) =>
|
||||
v.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
},
|
||||
highlight: { points: { r: 4 } },
|
||||
}}>
|
||||
{#snippet tooltip()}
|
||||
<Chart.Tooltip
|
||||
labelFormatter={(value: Date) => {
|
||||
return value.toLocaleDateString()
|
||||
}}
|
||||
valueFormatter={(value: Number) => {
|
||||
return value.toString().slice(0, 8)
|
||||
}} />
|
||||
{/snippet}
|
||||
</LineChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
No transactions in this time range.
|
||||
</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Monero accounts & subaddresses -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accounts & Addresses</CardTitle>
|
||||
<CardDescription>
|
||||
{w.accounts.length} account{w.accounts.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-sm">Accounts & Addresses</CardTitle>
|
||||
<div class="flex gap-1.5">
|
||||
<Dialog.Root bind:open={accountDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button size="sm" variant="outline">+ Account</Button>
|
||||
<Button size="sm" variant="ghost" class="gap-1 text-xs">
|
||||
<Plus size={12} /> Account
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create Account</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Optional label for the new account
|
||||
</Dialog.Description>
|
||||
<Dialog.Description>Optional label for the new account</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="px-6 pb-4 space-y-3">
|
||||
<Input
|
||||
placeholder="Account label (optional)"
|
||||
bind:value={accountLabel} />
|
||||
<Input placeholder="Account label (optional)" bind:value={accountLabel} />
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
accountDialogOpen = false
|
||||
accountLabel = ''
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={async () => {
|
||||
await w.createAccount(accountLabel || undefined)
|
||||
accountDialogOpen = false
|
||||
accountLabel = ''
|
||||
}}>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => { accountDialogOpen = false; accountLabel = '' }}>Cancel</Button>
|
||||
<Button size="sm" onclick={async () => { await w.createAccount(accountLabel || undefined); accountDialogOpen = false; accountLabel = '' }}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<Dialog.Root bind:open={subaddressDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button size="sm" variant="outline">
|
||||
+ Subaddress
|
||||
<Button size="sm" variant="ghost" class="gap-1 text-xs">
|
||||
<Plus size={12} /> Subaddress
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create Subaddress</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Account index and optional label
|
||||
</Dialog.Description>
|
||||
<Dialog.Description>Account index and optional label</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="px-6 pb-4 space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Account</label>
|
||||
<Select
|
||||
type="single"
|
||||
bind:value={subaddressAccountIndex}>
|
||||
<Select type="single" bind:value={subaddressAccountIndex}>
|
||||
<SelectTrigger>
|
||||
{subaddressAccountIndex
|
||||
? `Account ${subaddressAccountIndex}`
|
||||
: 'Select account'}
|
||||
{subaddressAccountIndex ? `Account ${subaddressAccountIndex}` : 'Select account'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each w.accounts as acct}
|
||||
<SelectItem value={String(acct.index)}>
|
||||
Account {acct.index}{acct.label
|
||||
? ` - ${acct.label}`
|
||||
: ''}
|
||||
Account {acct.index}{acct.label ? ` - ${acct.label}` : ''}
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Subaddress label (optional)"
|
||||
bind:value={subaddressLabel} />
|
||||
<Input placeholder="Subaddress label (optional)" bind:value={subaddressLabel} />
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
subaddressDialogOpen = false
|
||||
subaddressAccountIndex = ''
|
||||
subaddressLabel = ''
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!subaddressAccountIndex}
|
||||
onclick={async () => {
|
||||
await w.createSubaddress(
|
||||
parseInt(subaddressAccountIndex),
|
||||
subaddressLabel || undefined,
|
||||
)
|
||||
subaddressDialogOpen = false
|
||||
subaddressAccountIndex = ''
|
||||
subaddressLabel = ''
|
||||
}}>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => { subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Cancel</Button>
|
||||
<Button size="sm" disabled={!subaddressAccountIndex} onclick={async () => { await w.createSubaddress(parseInt(subaddressAccountIndex), subaddressLabel || undefined); subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2">
|
||||
{#each w.accounts as account}
|
||||
{@const a = chunkAddress(account.primaryAddress)}
|
||||
<div class="mb-3 rounded-md bg-muted p-3">
|
||||
<div class="rounded-lg border border-border p-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<p class="text-xs font-medium">
|
||||
Account {account.index}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{atomicToXmr(account.balance)} XMR
|
||||
</p>
|
||||
<p class="text-xs font-medium">Account {account.index}{account.label ? ` — ${account.label}` : ''}</p>
|
||||
<p class="text-xs text-muted-foreground tabular-nums">{atomicToXmr(account.balance)} XMR</p>
|
||||
</div>
|
||||
<p
|
||||
class="mb-2 font-mono text-xs text-muted-foreground break-all">
|
||||
<span class="text-primary-foreground">
|
||||
{a.start}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{a.mid}</span>
|
||||
<span class="text-muted-foreground/40">...</span>
|
||||
<span class="text-primary-foreground">{a.end}</span>
|
||||
</p>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<Dialog.Root
|
||||
open={subQrDialogOpen === account.primaryAddress}
|
||||
onopenchange={(v) => {
|
||||
subQrDialogOpen = v ? account.primaryAddress : null
|
||||
}}>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground"
|
||||
aria-label="Show QR code">
|
||||
<QrCodeIcon />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode
|
||||
text={account.primaryAddress}
|
||||
size={256} />
|
||||
<p
|
||||
class="font-mono text-xs break-all text-center max-w-64">
|
||||
{account.primaryAddress}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<CopyButton
|
||||
text={account.primaryAddress}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground">
|
||||
</CopyButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="min-w-0 flex-1 font-mono text-xs text-muted-foreground break-all">
|
||||
<span>{a.start}</span><span>{a.mid}</span><span class="text-muted-foreground/40">...</span><span>{a.end}</span>
|
||||
</p>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Dialog.Root open={subQrDialogOpen === account.primaryAddress} onopenchange={(v) => { subQrDialogOpen = v ? account.primaryAddress : null }}>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost" size="icon-xs" class="text-muted-foreground" aria-label="QR"><QrCodeIcon size={12} /></Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={account.primaryAddress} size={256} />
|
||||
<p class="font-mono text-xs break-all text-center max-w-64">{account.primaryAddress}</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton text={account.primaryAddress} variant="ghost" size="icon-xs" class="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
{#if account.subaddresses.length > 1}
|
||||
<div class="space-y-1 border-t border-border pt-2">
|
||||
<p class="text-xs text-muted-foreground mb-1">
|
||||
Subaddresses
|
||||
</p>
|
||||
<div class="mt-2 space-y-1 border-t border-border pt-2">
|
||||
{#each account.subaddresses as sub}
|
||||
{@const a = chunkAddress(sub.address)}
|
||||
<div
|
||||
class="rounded-sm bg-background px-2 py-1.5 text-xs">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="flex items-center justify-between">
|
||||
<p class="font-medium">
|
||||
#{sub.index}{sub.label
|
||||
? ` - ${sub.label}`
|
||||
: ''}
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
{atomicToXmr(sub.balance)} XMR
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-mono text-xs mt-0.5">
|
||||
<span class="text-primary-foreground">
|
||||
{a.start}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
{a.mid}
|
||||
</span>
|
||||
<span class="text-muted-foreground/40">
|
||||
...
|
||||
</span>
|
||||
<span class="text-primary-foreground">
|
||||
{a.end}
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<Dialog.Root
|
||||
open={subQrDialogOpen === sub.address}
|
||||
onopenchange={(v) => {
|
||||
subQrDialogOpen = v ? sub.address : null
|
||||
}}>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground"
|
||||
aria-label="Show QR code">
|
||||
<QrCodeIcon />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode
|
||||
text={sub.address}
|
||||
size={256} />
|
||||
<p
|
||||
class="font-mono text-xs break-all text-center max-w-64">
|
||||
{sub.address}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton
|
||||
text={sub.address}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground">
|
||||
</CopyButton>
|
||||
</div>
|
||||
{@const sa = chunkAddress(sub.address)}
|
||||
<div class="rounded-md px-2 py-1.5 text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">#{sub.index}{sub.label ? ` - ${sub.label}` : ''}</p>
|
||||
<p class="text-muted-foreground tabular-nums">{atomicToXmr(sub.balance)} XMR</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<p class="min-w-0 flex-1 font-mono text-muted-foreground truncate">
|
||||
<span>{sa.start}</span><span>{sa.mid}</span><span class="text-muted-foreground/40">...</span><span>{sa.end}</span>
|
||||
</p>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Dialog.Root open={subQrDialogOpen === sub.address} onopenchange={(v) => { subQrDialogOpen = v ? sub.address : null }}>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost" size="icon-xs" class="text-muted-foreground" aria-label="QR"><QrCodeIcon size={12} /></Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={sub.address} size={256} />
|
||||
<p class="font-mono text-xs break-all text-center max-w-64">{sub.address}</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton text={sub.address} variant="ghost" size="icon-xs" class="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -703,7 +452,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !w.ready}
|
||||
<p class="text-center text-muted-foreground text-sm mt-8">
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send XRP</CardTitle>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction Details</CardTitle>
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link'
|
||||
import Send from '@lucide/svelte/icons/send'
|
||||
import Loader from '$lib/components/loader.svelte'
|
||||
import QrCode from '$lib/components/qr.svelte'
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js'
|
||||
@@ -26,14 +25,11 @@
|
||||
const walletId = $derived(route.params.id)
|
||||
|
||||
let qrDialogOpen = $state(false)
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
|
||||
$state()
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
if (w.seed && !autoRefreshTimer) {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
w.refresh()
|
||||
}, 30000)
|
||||
autoRefreshTimer = setInterval(() => w.refresh(), 30000)
|
||||
} else if (!w.seed && autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = undefined
|
||||
@@ -55,181 +51,122 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>
|
||||
{w.currentWallet?.name ?? 'XRP Wallet'}
|
||||
</CardTitle>
|
||||
<img src="/icons/xrp.png" alt="" class="size-6 rounded-full" />
|
||||
</div>
|
||||
<CardDescription>XRP Ledger</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-4 space-y-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<p class="font-mono text-xs break-all">{w.address}</p>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<Dialog.Root bind:open={qrDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label="Show QR code">
|
||||
<QrCodeIcon size={16} />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={w.address} size={256} />
|
||||
<p
|
||||
class="font-mono text-xs break-all text-center max-w-64">
|
||||
{w.address}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton
|
||||
text={w.address}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground">
|
||||
</CopyButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label="View on explorer"
|
||||
onclick={() =>
|
||||
electrobun.rpc?.request.openExternal({
|
||||
url: w.explorerAddressUrl,
|
||||
})}>
|
||||
<ExternalLink size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 font-medium text-xs">Balance</p>
|
||||
{#if w.loadingBalance}
|
||||
<div class="py-1"><Loader /></div>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="font-mono text-lg">{w.balance} XRP</p>
|
||||
{#if w.price}
|
||||
<p class="font-mono text-sm text-muted-foreground">
|
||||
≈ ${(
|
||||
parseFloat(w.balance) * parseFloat(w.price)
|
||||
).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !w.funded}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Account not activated — it needs at least 10 XRP to
|
||||
meet the base reserve.
|
||||
</p>
|
||||
{/if}
|
||||
{#if w.fee}
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Network fee: ~{w.fee} XRP
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col items-center py-2">
|
||||
{#if w.loadingBalance}
|
||||
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">Balance</p>
|
||||
<p class="mt-1 font-mono text-4xl font-semibold tabular-nums tracking-tight">
|
||||
{w.balance} <span class="text-muted-foreground text-2xl">XRP</span>
|
||||
</p>
|
||||
{#if w.price}
|
||||
<p class="mt-1 text-sm text-muted-foreground tabular-nums">
|
||||
≈ ${(parseFloat(w.balance) * parseFloat(w.price)).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if !w.funded}
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Account not activated — needs at least 10 XRP
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/xrp/send')}>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/settings')}>
|
||||
<SettingsIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={async () => {
|
||||
await w.logout()
|
||||
navigate('/')
|
||||
}}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="flex items-center justify-center">
|
||||
<Button onclick={() => navigate('/xrp/send')} class="gap-2">
|
||||
<Send size={16} />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.loadingTransactions}
|
||||
<div class="flex justify-center py-4">
|
||||
<Loader />
|
||||
</div>
|
||||
{:else if w.transactions.length === 0}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
No transactions found
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-96 space-y-1 overflow-y-auto">
|
||||
{#each w.transactions as tx}
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate('/xrp/tx/:hash', {
|
||||
params: { hash: tx.hash },
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<div
|
||||
class="shrink-0 mt-0.5 {tx.direction === 'in'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'}">
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">Address</p>
|
||||
<p class="font-mono text-xs break-all">{w.address}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1 ml-2">
|
||||
<Dialog.Root bind:open={qrDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost" size="icon-sm" class="text-muted-foreground" aria-label="QR code">
|
||||
<QrCodeIcon size={14} />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={w.address} size={256} />
|
||||
<p class="font-mono text-xs break-all text-center max-w-64">{w.address}</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton text={w.address} variant="ghost" size="icon-sm" class="text-muted-foreground" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label="View on explorer"
|
||||
onclick={() => electrobun.rpc?.request.openExternal({ url: w.explorerAddressUrl })}>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if w.fee}
|
||||
<p class="mt-2 text-center text-xs text-muted-foreground">
|
||||
Network fee: ~{w.fee} XRP
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.loadingTransactions}
|
||||
<div class="flex justify-center py-8"><Loader /></div>
|
||||
{:else if w.transactions.length === 0}
|
||||
<p class="text-center text-sm text-muted-foreground py-8">No transactions yet</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each w.transactions as tx}
|
||||
<button
|
||||
onclick={() => navigate('/xrp/tx/:hash', { params: { hash: tx.hash } })}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-muted transition-colors">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-full {tx.direction === 'in' ? 'bg-muted' : 'bg-muted'}">
|
||||
<div class={tx.direction === 'in' ? 'text-green-500' : 'text-muted-foreground'}>
|
||||
{#if tx.direction === 'in'}
|
||||
<ArrowDown size={16} />
|
||||
{:else}
|
||||
<ArrowUp size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="font-medium">
|
||||
{tx.direction === 'in' ? 'Received' : 'Sent'}
|
||||
{#if !tx.confirmed}
|
||||
<span class="text-muted-foreground">
|
||||
(unconfirmed)
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="font-mono text-muted-foreground">
|
||||
{tx.amount} XRP
|
||||
</p>
|
||||
{#if tx.timestamp && tx.timestamp !== '0'}
|
||||
<p class="text-muted-foreground">
|
||||
{new Date(
|
||||
Number(tx.timestamp) * 1000,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{tx.direction === 'in' ? 'Received' : 'Sent'}
|
||||
{#if !tx.confirmed}
|
||||
<span class="text-muted-foreground text-xs ml-1">(unconfirmed)</span>
|
||||
{/if}
|
||||
<p
|
||||
class="text-muted-foreground/50 truncate font-mono">
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if tx.timestamp && tx.timestamp !== '0'}
|
||||
{new Date(Number(tx.timestamp) * 1000).toLocaleString()}
|
||||
{:else}
|
||||
{tx.hash.slice(0, 12)}...
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<p class="shrink-0 font-mono text-sm tabular-nums {tx.direction === 'in' ? 'text-green-500' : ''}">
|
||||
{tx.direction === 'in' ? '+' : '-'}{tx.amount} XRP
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
Reference in New Issue
Block a user