2026-04-11 13:38:01 +01:00

436 lines
16 KiB
Vue

<template>
<div class="pb-6">
<!-- Quick Actions + HW Banner -->
<Web5QuickActions
:showStagger="showStagger"
:profitsBreakdown="profitsBreakdown"
:networkingProfitsDisplay="networkingProfitsDisplay"
:userDid="userDid"
:didStatus="didStatus"
:didCopied="didCopied"
:creatingDid="creatingDid"
:dhtDid="dhtDid"
:dhtDidCopied="dhtDidCopied"
:publishingDht="publishingDht"
:walletConnected="walletConnected"
:connectingWallet="connectingWallet"
:nostrRelayStats="nostrRelaysRef?.nostrRelayStats ?? null"
:connectedNodesCount="connectedNodesRef?.peers?.length ?? 0"
:detectedHwWallets="detectedHwWallets"
@copyDid="copyDid"
@showDidDocument="showDidDocument"
@createDid="createDID"
@copyDhtDid="copyDhtDid"
@refreshDhtDid="refreshDhtDid"
@publishDhtDid="publishDhtDid"
@connectWallet="connectWallet"
@manageRelays="nostrRelaysRef?.openRelaysModal()"
/>
<!-- DID Document Modal -->
<Teleport to="body">
<div v-if="showDidDocModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showDidDocModal = false" @keydown.escape="showDidDocModal = false">
<div class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title">
<div class="flex items-center justify-between mb-4">
<h3 id="did-doc-title" class="text-lg font-semibold text-white">{{ t('web5.didDocument') }}</h3>
<div class="flex items-center gap-2">
<span v-if="didDocVerified === true" class="text-xs text-green-400 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
{{ t('web5.verified') }}
</span>
<span v-else-if="didDocVerified === false" class="text-xs text-red-400">{{ t('web5.invalid') }}</span>
</div>
</div>
<div v-if="loadingDidDoc" class="text-white/60 text-sm">{{ t('common.loading') }}</div>
<pre v-else class="text-xs text-white/80 font-mono bg-black/30 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap">{{ didDocumentFormatted }}</pre>
<div class="flex gap-3 mt-4">
<button @click="copyDidDocument" class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
{{ didDocCopied ? t('common.copiedBang') : t('common.copy') }}
</button>
<button @click="showDidDocModal = false" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">
{{ t('common.close') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Connected Nodes + Node Visibility -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<Web5ConnectedNodes ref="connectedNodesRef" @toast="showToast" />
<Web5NodeVisibility :showStagger="showStagger" ref="nodeVisibilityRef" @toast="showToast" />
</div>
<!-- Identities + Nostr Relays -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<Web5Identities ref="identitiesRef" :showStagger="showStagger" @toast="showToast" />
<Web5NostrRelays ref="nostrRelaysRef" :showStagger="showStagger" />
</div>
<!-- Monitoring + Federation -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<Web5Monitoring />
<Web5Federation />
</div>
<!-- Send/Receive Modals hidden wallet card removed -->
<!-- <Web5SendReceiveModals ref="sendReceiveRef" @toast="showToast" @balancesChanged="reloadBalances" /> -->
<!-- Identity Toast -->
<Transition name="content-fade">
<div v-if="identityToastVisible" class="fixed bottom-24 md:bottom-8 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-lg bg-black/80 backdrop-blur-md border border-white/10 text-white text-sm shadow-lg">
{{ identityToastText }}
</div>
</Transition>
</div>
</template>
<script lang="ts">
let web5AnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types'
import Web5QuickActions from './Web5QuickActions.vue'
// import Web5Wallet from './Web5Wallet.vue' // hidden for now
// import Web5Domains from './Web5Domains.vue' // hidden for now
import Web5NostrRelays from './Web5NostrRelays.vue'
import Web5NodeVisibility from './Web5NodeVisibility.vue'
import Web5ConnectedNodes from './Web5ConnectedNodes.vue'
// import Web5SharedContent from './Web5SharedContent.vue' // hidden for now
import Web5Identities from './Web5Identities.vue'
// import Web5DWN from './Web5DWN.vue' // hidden for now
// import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // hidden for now
import Web5Monitoring from './Web5Monitoring.vue'
import Web5Federation from './Web5Federation.vue'
// import Web5SendReceiveModals from './Web5SendReceiveModals.vue' // wallet hidden
const route = useRoute()
const { t } = useI18n()
const showStagger = !web5AnimationDone
// Child refs
// const domainsRef = ref(null) // hidden for now
const nostrRelaysRef = ref<InstanceType<typeof Web5NostrRelays> | null>(null)
const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null)
const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null)
const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null)
// const dwnRef = ref(null) // hidden for now
// const credentialsRef = ref(null) // hidden for now
// const sharedContentRef = ref(null) // hidden for now
// const sendReceiveRef = ref(null) // wallet hidden
// --- Toast ---
const identityToastText = ref('')
const identityToastVisible = ref(false)
let identityToastTimer: ReturnType<typeof setTimeout> | undefined
function showToast(text: string) {
identityToastText.value = text
identityToastVisible.value = true
clearTimeout(identityToastTimer)
identityToastTimer = setTimeout(() => { identityToastVisible.value = false }, 2000)
}
// --- Networking Profits ---
const profitsBreakdown = ref<ProfitsData | null>(null)
const networkingProfitsDisplay = computed(() => {
if (!profitsBreakdown.value) return '...'
const sats = profitsBreakdown.value.total_sats
if (sats === 0) return '0 sats'
if (sats < 100000) return `${sats.toLocaleString()} sats`
const btc = sats / 100_000_000
return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
})
async function loadNetworkingProfits() {
try {
const res = await rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits' })
profitsBreakdown.value = res
} catch {
profitsBreakdown.value = { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 }
}
}
// --- DID State ---
const storedDid = ref<string | null>(null)
try {
storedDid.value = localStorage.getItem('neode_did') || null
} catch { /* noop */ }
const userDid = computed(() => storedDid.value)
const didStatus = computed<'active' | 'inactive' | 'pending'>(() => userDid.value ? 'active' : 'inactive')
const creatingDid = ref(false)
const didCopied = ref(false)
// did:dht
const dhtDid = ref<string | null>(null)
const publishingDht = ref(false)
const dhtDidCopied = ref(false)
try {
dhtDid.value = localStorage.getItem('neode_dht_did') || null
} catch { /* noop */ }
async function createDID() {
creatingDid.value = true
try {
const res = await rpcClient.call<{ did: string }>({ method: 'identity.create-did' })
storedDid.value = res.did
localStorage.setItem('neode_did', res.did)
} catch {
if (!crypto.subtle) {
const randomBytes = new Uint8Array(32)
crypto.getRandomValues(randomBytes)
const hex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('')
const did = `did:key:z${hex}`
storedDid.value = did
localStorage.setItem('neode_did', did)
} else {
const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
const exported = await crypto.subtle.exportKey('raw', keyPair.publicKey)
const bytes = new Uint8Array(exported)
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
const did = `did:key:z${hex}`
storedDid.value = did
localStorage.setItem('neode_did', did)
}
} finally {
creatingDid.value = false
}
}
async function copyDid() {
if (!userDid.value) return
await safeClipboardWrite(userDid.value)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
async function publishDhtDid() {
publishingDht.value = true
try {
const identities = await rpcClient.call<{ identities: Array<{ id: string; is_default: boolean }> }>({ method: 'identity.list' })
const defaultId = identities.identities?.find((i: { is_default: boolean }) => i.is_default)
if (!defaultId) return
const res = await rpcClient.call<{ dht_did: string }>({
method: 'identity.create-dht-did',
params: { identity_id: defaultId.id },
})
dhtDid.value = res.dht_did
localStorage.setItem('neode_dht_did', res.dht_did)
} catch {
const did = storedDid.value || localStorage.getItem('neode_did')
if (did) {
const dhtVersion = did.replace('did:key:', 'did:dht:')
dhtDid.value = dhtVersion
localStorage.setItem('neode_dht_did', dhtVersion)
}
} finally {
publishingDht.value = false
}
}
async function refreshDhtDid() {
publishingDht.value = true
try {
const identities = await rpcClient.call<{ identities: Array<{ id: string; is_default: boolean }> }>({ method: 'identity.list' })
const defaultId = identities.identities?.find((i: { is_default: boolean }) => i.is_default)
if (!defaultId) return
await rpcClient.call({ method: 'identity.refresh-dht-did', params: { identity_id: defaultId.id } })
} catch { /* silently ignore */ }
finally { publishingDht.value = false }
}
async function copyDhtDid() {
if (!dhtDid.value) return
await safeClipboardWrite(dhtDid.value)
dhtDidCopied.value = true
setTimeout(() => { dhtDidCopied.value = false }, 2000)
}
// DID Document modal
const showDidDocModal = ref(false)
const loadingDidDoc = ref(false)
const didDocumentData = ref<Record<string, unknown> | null>(null)
const didDocVerified = ref<boolean | null>(null)
const didDocCopied = ref(false)
const didDocumentFormatted = computed(() =>
didDocumentData.value ? JSON.stringify(didDocumentData.value, null, 2) : ''
)
async function showDidDocument() {
showDidDocModal.value = true
loadingDidDoc.value = true
didDocVerified.value = null
try {
const doc = await rpcClient.resolveDid()
didDocumentData.value = doc
const verification = await rpcClient.call({
method: 'identity.verify-did-document',
params: { document: doc },
}) as { valid: boolean }
didDocVerified.value = verification.valid
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to load DID Document:', err)
didDocumentData.value = null
} finally {
loadingDidDoc.value = false
}
}
async function copyDidDocument() {
if (!didDocumentFormatted.value) return
await safeClipboardWrite(didDocumentFormatted.value)
didDocCopied.value = true
setTimeout(() => { didDocCopied.value = false }, 2000)
}
// --- Wallet / LND Balances ---
const walletConnected = ref(false)
const connectingWallet = ref(false)
const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0)
const walletError = ref('')
const ecashBalance = ref(0)
// Transactions — wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
const walletTransactions = ref<WalletTransaction[]>([])
// Hardware wallets
const detectedHwWallets = ref<HwWalletDevice[]>([])
async function loadLndBalances() {
try {
const res = await rpcClient.call<{
balance_sats: number
channel_balance_sats: number
synced_to_chain: boolean
}>({ method: 'lnd.getinfo' })
lndOnchainBalance.value = res.balance_sats || 0
lndChannelBalance.value = res.channel_balance_sats || 0
walletConnected.value = true
walletError.value = ''
} catch (e) {
walletConnected.value = false
lndOnchainBalance.value = 0
lndChannelBalance.value = 0
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
}
}
async function loadEcashBalance() {
try {
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
ecashBalance.value = res.balance_sats ?? 0
} catch {
ecashBalance.value = 0
}
}
async function loadTransactions() {
try {
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
walletTransactions.value = res.transactions || []
walletError.value = ''
} catch (e) {
walletTransactions.value = []
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
}
}
async function connectWallet() {
if (walletConnected.value) {
walletConnected.value = false
} else {
connectingWallet.value = true
await loadLndBalances()
connectingWallet.value = false
}
}
async function detectHardwareWallets() {
try {
const res = await rpcClient.detectUsbDevices()
detectedHwWallets.value = res.devices || []
} catch {
detectedHwWallets.value = []
}
}
// function reloadBalances() { // wallet hidden
// loadLndBalances()
// loadEcashBalance()
// loadTransactions()
// }
// Auto-refresh wallet data every 30s
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
web5AnimationDone = true
// Load the authoritative node DID from the backend
rpcClient.getNodeDid().then(res => {
if (res.did && res.did !== storedDid.value) {
storedDid.value = res.did
try { localStorage.setItem('neode_did', res.did) } catch { /* noop */ }
}
}).catch(() => { /* use cached localStorage value */ })
// Load all data from child components
connectedNodesRef.value?.loadPeers()
connectedNodesRef.value?.loadReceivedMessages()
connectedNodesRef.value?.loadConnectionRequests()
identitiesRef.value?.loadIdentities()
nodeVisibilityRef.value?.loadVisibility()
// domainsRef.value?.loadDomainNames() // hidden for now
nostrRelaysRef.value?.loadNostrRelays()
// dwnRef.value?.loadDwnStatus() // hidden for now
// dwnRef.value?.loadDwnProtocols() // hidden for now
// credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now
// Load local state data
loadEcashBalance()
loadNetworkingProfits()
loadLndBalances()
loadTransactions()
detectHardwareWallets()
// Shared content loaded by the component itself via expose
// The SharedContent component manages its own loadContentItems
walletRefreshInterval = setInterval(() => {
loadLndBalances()
loadTransactions()
loadEcashBalance()
}, 30000)
// Open Messages tab when navigated via toast
if (route.query.tab === 'messages') {
connectedNodesRef.value?.scrollToMessages()
}
})
onUnmounted(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
watch(() => route.query.tab, (tab) => {
if (tab === 'messages') {
connectedNodesRef.value?.scrollToMessages()
}
})
</script>