extract monero wallet dashboard into dedicated /monero/wallet/[name] route

This commit is contained in:
2026-05-27 13:19:23 +08:00
parent a30038f9e7
commit 2cbf3bb33b
4 changed files with 588 additions and 567 deletions
+4
View File
@@ -0,0 +1,4 @@
# Instructions for AI assistants
- Never commit changes unless explicitly told to do so. Always ask first or wait for the user's command.
- Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location.
@@ -25,10 +25,8 @@ export const atomicToXmr = (atomic: string): string => {
return frac ? `${whole}.${frac}` : `${whole}` return frac ? `${whole}.${frac}` : `${whole}`
} }
export type AccountType = 'multi' | 'monero'
export const MoneroWallet = () => { export const MoneroWallet = () => {
let accountType = $state<AccountType | null>(null) let accountType = $state<'monero' | null>(null)
let ready = $state(false) let ready = $state(false)
let biometricAvailable = $state(false) let biometricAvailable = $state(false)
let running = $state(false) let running = $state(false)
@@ -310,14 +308,20 @@ export const MoneroWallet = () => {
const createAccount = async (label?: string) => { const createAccount = async (label?: string) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateAccount({ label }) const result = await electrobun.rpc.request.moneroCreateAccount({
label,
})
await refresh() await refresh()
return result return result
} }
const createSubaddress = async (accountIndex: number, label?: string) => { const createSubaddress = async (
accountIndex: number,
label?: string,
) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateSubaddress({ const result =
await electrobun.rpc.request.moneroCreateSubaddress({
accountIndex, accountIndex,
label, label,
}) })
+11 -557
View File
@@ -1,19 +1,8 @@
<script lang="ts"> <script lang="ts">
import { import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
moneroWallet,
atomicToXmr,
} from '$lib/states/monero-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { Button } from '$lib/components/ui/button/index.js' import { Button } from '$lib/components/ui/button/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js' import { Textarea } from '$lib/components/ui/textarea/index.js'
import { Input } from '$lib/components/ui/input/index.js' import { Input } from '$lib/components/ui/input/index.js'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import * as Dialog from '$lib/components/ui/dialog/index.js'
import { import {
Card, Card,
CardContent, CardContent,
@@ -21,36 +10,12 @@
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '$lib/components/ui/card/index.js' } from '$lib/components/ui/card/index.js'
import ArrowDown from '@lucide/svelte/icons/arrow-down'
import ArrowUp from '@lucide/svelte/icons/arrow-up'
import Fingerprint from '@lucide/svelte/icons/fingerprint' import Fingerprint from '@lucide/svelte/icons/fingerprint'
import SettingsIcon from '@lucide/svelte/icons/settings'
import Copy from '@lucide/svelte/icons/copy'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import Loader from '$lib/components/loader.svelte' import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte'
import { navigate } from 'sv-router/generated' import { navigate } from 'sv-router/generated'
import { CopyButton } from '$lib/components/ui/copy-button'
import { onMount } from 'svelte'
const w = moneroWallet const w = moneroWallet
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch {}
}
const chunkAddress = (addr: string) => {
const start = addr.slice(0, 6)
const mid = addr.slice(6, 12)
const end = addr.slice(-6)
return { start, mid, end }
}
let qrDialogOpen = $state(false)
let subQrDialogOpen = $state<string | null>(null)
let moneroWalletName = $state('') let moneroWalletName = $state('')
let moneroWalletPass = $state('') let moneroWalletPass = $state('')
let moneroMnemonic = $state('') let moneroMnemonic = $state('')
@@ -60,40 +25,14 @@
let moneroUseBiometric = $state(false) let moneroUseBiometric = $state(false)
let initStarted = false let initStarted = false
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
$state()
let feeEstimate = $state<{
fee: string
fees: string[]
estimatedFee: string
} | null>(null)
let accountDialogOpen = $state(false)
let accountLabel = $state('')
let subaddressDialogOpen = $state(false)
let subaddressAccountIndex = $state('')
let subaddressLabel = $state('')
$effect(() => { $effect(() => {
if (w.walletOpen && !autoRefreshTimer) { if (w.walletOpen && w.walletName) {
autoRefreshTimer = setInterval(() => w.refresh(), 30000) navigate('/monero/wallet/:name', {
} else if (!w.walletOpen && autoRefreshTimer) { params: {
clearInterval(autoRefreshTimer) name: w.walletName,
autoRefreshTimer = undefined },
}
return () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined
}
}
}) })
$effect(() => {
if (w.walletOpen) {
electrobun.rpc?.request
.moneroGetFeeEstimate({})
.then((fee) => (feeEstimate = fee))
} }
}) })
@@ -106,10 +45,6 @@
}) })
} }
}) })
onMount(() => {
w.refresh()
})
</script> </script>
<div class="mx-auto mt-16 max-w-md"> <div class="mx-auto mt-16 max-w-md">
@@ -204,9 +139,6 @@
await w.openExistingWallet( await w.openExistingWallet(
moneroSelectedWallet, moneroSelectedWallet,
) )
if (!w.walletOpen)
w.error =
'No password saved for this wallet'
} }
}} }}
disabled={w.loading}> disabled={w.loading}>
@@ -261,12 +193,11 @@
const pw = moneroUseBiometric const pw = moneroUseBiometric
? crypto.randomUUID() ? crypto.randomUUID()
: moneroWalletPass : moneroWalletPass
const result = await w.createWallet( await w.createWallet(
moneroWalletName, moneroWalletName,
pw, pw,
moneroUseBiometric, moneroUseBiometric,
) )
moneroMnemonic = result.mnemonic
}} }}
disabled={w.loading || disabled={w.loading ||
!moneroWalletName || !moneroWalletName ||
@@ -292,8 +223,8 @@
bind:value={moneroWalletPass} /> bind:value={moneroWalletPass} />
{/if} {/if}
<Button <Button
onclick={() => onclick={async () => {
w.restoreWallet( await w.restoreWallet(
moneroWalletName, moneroWalletName,
moneroUseBiometric moneroUseBiometric
? crypto.randomUUID() ? crypto.randomUUID()
@@ -301,7 +232,8 @@
moneroMnemonic, moneroMnemonic,
moneroRestoreHeight, moneroRestoreHeight,
moneroUseBiometric, moneroUseBiometric,
)} )
}}
disabled={w.loading || disabled={w.loading ||
!moneroWalletName || !moneroWalletName ||
(!moneroUseBiometric && !moneroWalletPass) || (!moneroUseBiometric && !moneroWalletPass) ||
@@ -311,484 +243,6 @@
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{:else}
<!-- Monero wallet unlocked -->
{@const a = chunkAddress(w.address)}
<Card>
<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>
</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>
<p class="font-mono text-lg">{w.balance} XMR</p>
{#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}
<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()
window.location.href = '/'
}}>
Logout
</Button>
</div>
</CardContent>
</Card>
<!-- Monero transactions -->
<Card>
<CardHeader>
<CardTitle>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}
<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'}">
{#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>
{#if tx.timestamp && tx.timestamp !== '0'}
<p class="text-muted-foreground">
{new Date(
Number(tx.timestamp) * 1000,
).toLocaleString()}
</p>
{/if}
<p
class="text-muted-foreground/50 truncate font-mono">
{tx.hash.slice(0, 12)}...
</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
<!-- 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">
<Dialog.Root bind:open={accountDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">
+ 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.Header>
<div class="px-6 pb-4 space-y-3">
<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>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={subaddressDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">
+ Subaddress
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Subaddress</Dialog.Title>
<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}>
<SelectTrigger>
{subaddressAccountIndex
? `Account ${subaddressAccountIndex}`
: 'Select account'}
</SelectTrigger>
<SelectContent>
{#each w.accounts as acct}
<SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label
? ` - ${acct.label}`
: ''}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<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>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
</div>
{#each w.accounts as account}
{@const a = chunkAddress(account.primaryAddress)}
<div class="mb-3 rounded-md bg-muted 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>
</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>
{#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>
{#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>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</CardContent>
</Card>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -0,0 +1,559 @@
<script lang="ts">
import {
moneroWallet,
atomicToXmr,
} from '$lib/states/monero-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import * as Dialog from '$lib/components/ui/dialog/index.js'
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 QrCodeIcon from '@lucide/svelte/icons/qr-code'
import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte'
import { navigate, route } from 'sv-router/generated'
import { CopyButton } from '$lib/components/ui/copy-button'
import { onMount } from 'svelte'
const w = moneroWallet
const chunkAddress = (addr: string) => {
const start = addr.slice(0, 6)
const mid = addr.slice(6, 12)
const end = addr.slice(-6)
return { start, mid, end }
}
let qrDialogOpen = $state(false)
let subQrDialogOpen = $state<string | null>(null)
let moneroMnemonic = $state('')
let feeEstimate = $state<{
fee: string
fees: string[]
estimatedFee: string
} | null>(null)
let accountDialogOpen = $state(false)
let accountLabel = $state('')
let subaddressDialogOpen = $state(false)
let subaddressAccountIndex = $state('')
let subaddressLabel = $state('')
const walletName = $derived(route.params.name)
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
$state()
$effect(() => {
if (w.walletOpen && !autoRefreshTimer) {
autoRefreshTimer = setInterval(() => w.refresh(), 30000)
} else if (!w.walletOpen && autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined
}
return () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined
}
}
})
$effect(() => {
if (w.walletOpen) {
electrobun.rpc?.request
.moneroGetFeeEstimate({})
.then((fee) => (feeEstimate = fee))
}
})
onMount(() => {
if (!w.walletOpen || w.walletName !== walletName) {
navigate('/monero')
}
w.refresh()
})
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="flex flex-col gap-4">
<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>
</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>
<p class="font-mono text-lg">{w.balance} XMR</p>
{#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}
<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>
<!-- Monero transactions -->
<Card>
<CardHeader>
<CardTitle>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}
<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'}">
{#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>
{#if tx.timestamp && tx.timestamp !== '0'}
<p class="text-muted-foreground">
{new Date(
Number(tx.timestamp) * 1000,
).toLocaleString()}
</p>
{/if}
<p
class="text-muted-foreground/50 truncate font-mono">
{tx.hash.slice(0, 12)}...
</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
<!-- 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">
<Dialog.Root bind:open={accountDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">+ 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.Header>
<div class="px-6 pb-4 space-y-3">
<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>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={subaddressDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">
+ Subaddress
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Subaddress</Dialog.Title>
<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}>
<SelectTrigger>
{subaddressAccountIndex
? `Account ${subaddressAccountIndex}`
: 'Select account'}
</SelectTrigger>
<SelectContent>
{#each w.accounts as acct}
<SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label
? ` - ${acct.label}`
: ''}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<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>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
</div>
{#each w.accounts as account}
{@const a = chunkAddress(account.primaryAddress)}
<div class="mb-3 rounded-md bg-muted 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>
</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>
{#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>
{#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>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</CardContent>
</Card>
</div>
</div>