Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 76cfd55971
1578 changed files with 332870 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
<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 } from 'vue'
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 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 { 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 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 {
// Keep last-known balance on a transient failure rather than flashing 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()
// 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)
})
onUnmounted(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
</script>
@@ -0,0 +1,543 @@
<template>
<!-- Connected Nodes (P2P over Tor) -->
<div data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
<!-- Desktop: side-by-side layout -->
<div class="hidden md:flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
</div>
<div class="web5-card-actions-top gap-2 shrink-0">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
</div>
</div>
<!-- Mobile: stacked layout -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-4 mb-2">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
</div>
</div>
<!-- Tabs: Trusted | Observers | Messages | Requests -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="nodesContainerTab = 'trusted'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="nodesContainerTab === 'trusted' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.trusted') }}
<span v-if="peers.length > 0" class="ml-1.5 text-xs text-white/50">({{ peers.length }})</span>
</button>
<button
@click="nodesContainerTab = 'observers'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="nodesContainerTab === 'observers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.observers') }}
<span v-if="observers.length > 0" class="ml-1.5 text-xs text-white/50">({{ observers.length }})</span>
</button>
<button
@click="switchToRequestsTab"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
:class="nodesContainerTab === 'requests' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.requests') }}
<span v-if="connectionRequests.length > 0" class="ml-1.5 text-xs text-orange-400">({{ connectionRequests.length }})</span>
<span v-if="connectionRequests.length > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button>
</div>
<!-- Trusted tab -->
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
<div v-else-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noPeers') }}
</div>
<div v-else-if="loadingPeers" class="p-2 text-center text-white/45 text-xs">
{{ t('common.loading') }}
</div>
<div
v-for="p in peers"
:key="p.pubkey"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
</div>
</div>
<button
@click.stop="router.push({ path: '/dashboard/mesh', query: { peer: p.pubkey || p.did || p.onion } })"
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
>
{{ t('web5.message') }}
</button>
</div>
</div>
<!-- Observers tab -->
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingPeers && observers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
<div v-else-if="observers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noObservers') }}
</div>
<div v-else-if="loadingPeers" class="p-2 text-center text-white/45 text-xs">
{{ t('common.loading') }}
</div>
<div
v-for="p in observers"
:key="p.pubkey"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
</div>
</div>
<span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0">
{{ t('web5.observer') }}
</span>
</div>
</div>
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
<div v-else-if="connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noRequests') }}
</div>
<div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs">
{{ t('common.loading') }}
</div>
<div
v-for="req in connectionRequests"
:key="req.id"
class="p-3 bg-white/5 rounded-lg border-l-2 border-blue-500/50"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-xs font-mono text-white/70 truncate" :title="req.from_did">{{ peerNameFromPubkey(req.from_did) }}</p>
<p v-if="req.message" class="text-sm text-white/80 mt-1 break-words">{{ req.message }}</p>
<p class="text-xs text-white/40 mt-1">{{ formatMessageTime(req.created_at) }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="acceptRequest(req.id)"
:disabled="processingRequestId === req.id"
class="px-3 py-1.5 text-xs rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 transition-colors disabled:opacity-50"
>
{{ t('web5.accept') }}
</button>
<button
@click="rejectRequest(req.id)"
:disabled="processingRequestId === req.id"
class="px-3 py-1.5 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{{ t('web5.reject') }}
</button>
</div>
</div>
</div>
</div>
<div class="mt-auto pt-4 space-y-3">
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
<button
@click="router.push('/dashboard/server/federation')"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
:disabled="loadingPeers"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
</div>
<button
v-if="nodesContainerTab === 'trusted'"
@click="discoverAndAddPeers"
:disabled="discovering"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
<button
v-else-if="nodesContainerTab === 'observers'"
@click="loadPeers"
:disabled="loadingPeers"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
<button
v-else
@click="loadConnectionRequests"
:disabled="loadingRequests"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
</div>
<!-- Send Message Modal -->
<Teleport to="body">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeSendMessageModal()">
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('web5.sendMessageTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.to') }}</label>
<select
v-model="sendMessageTo"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.message') }}</label>
<textarea
v-model="sendMessageText"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
:placeholder="t('web5.messagePlaceholder')"
></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="sendMessage"
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ sendingMessage ? t('common.sending') : t('common.send') }}
</button>
<button
@click="closeSendMessageModal()"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
{{ t('common.cancel') }}
</button>
</div>
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { formatMessageTime } from './utils'
import type { Peer, ConnectionRequest } from './types'
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore()
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v1'
type ConnectedNodesCache = {
peers: Peer[]
observers: Peer[]
peerReachable: Record<string, boolean>
connectionRequests: ConnectionRequest[]
}
function readConnectedNodesCache(): Partial<ConnectedNodesCache> {
if (typeof window === 'undefined') return {}
try {
const raw = window.sessionStorage.getItem(CONNECTED_NODES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<ConnectedNodesCache>
return {
peers: Array.isArray(parsed.peers) ? parsed.peers : [],
observers: Array.isArray(parsed.observers) ? parsed.observers : [],
peerReachable: parsed.peerReachable && typeof parsed.peerReachable === 'object' ? parsed.peerReachable : {},
connectionRequests: Array.isArray(parsed.connectionRequests) ? parsed.connectionRequests : [],
}
} catch {
return {}
}
}
function writeConnectedNodesCache(state: ConnectedNodesCache) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(CONNECTED_NODES_CACHE_KEY, JSON.stringify(state))
} catch {
// Cache is best-effort.
}
}
const nodesContainerTab = ref<'trusted' | 'observers' | 'requests'>('trusted')
const { loadReceivedMessages } = messageToast
const cached = readConnectedNodesCache()
const peers = ref<Peer[]>(cached.peers ?? [])
const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false)
// Send message modal
const showSendMessageModal = ref(false)
const sendMessageModalRef = ref<HTMLElement | null>(null)
const sendMessageRestoreFocusRef = ref<HTMLElement | null>(null)
function closeSendMessageModal() {
sendMessageRestoreFocusRef.value?.focus?.()
showSendMessageModal.value = false
}
useModalKeyboard(sendMessageModalRef, showSendMessageModal, closeSendMessageModal, { restoreFocusRef: sendMessageRestoreFocusRef })
const sendMessageTo = ref('')
const sendMessageText = ref('')
const sendingMessage = ref(false)
const sendMessageError = ref('')
const sendMessageSuccess = ref('')
// Connection requests
const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
const loadingRequests = ref(false)
const processingRequestId = ref<string | null>(null)
const emit = defineEmits<{
toast: [text: string]
}>()
function peerNameFromPubkey(pubkey: string): string {
const peer = [...peers.value, ...observers.value].find(p => p.pubkey === pubkey || p.onion === pubkey)
if (peer?.name) return peer.name
return (pubkey || '').slice(0, 16) + '...'
}
type FederationNode = Awaited<ReturnType<typeof rpcClient.federationListNodes>>['nodes'][number]
function federationNodeToPeer(node: FederationNode): Peer {
return {
onion: node.onion,
pubkey: node.pubkey,
did: node.did,
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
}
}
function switchToRequestsTab() {
nodesContainerTab.value = 'requests'
if (connectionRequests.value.length === 0 && !loadingRequests.value) {
loadConnectionRequests()
}
}
async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true
try {
const res = await rpcClient.listPeers()
const peerList = res.peers || []
const observerList: Peer[] = []
try {
const fedRes = await rpcClient.federationListNodes()
const fedNodes = fedRes.nodes || []
for (const n of fedNodes) {
if (!n.onion || n.trust_level === 'untrusted') {
continue
}
if (n.trust_level === 'observer') {
if (!observerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
observerList.push(federationNodeToPeer(n))
}
continue
}
if (!peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
peerList.push(federationNodeToPeer(n))
}
}
} catch {
// Federation may not be set up
}
peers.value = peerList
observers.value = observerList
for (const p of [...peers.value, ...observers.value]) {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable
} catch {
peerReachableLocal.value[p.onion] = false
}
}
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
if (!hadPeers) {
peers.value = []
observers.value = []
}
} finally {
loadingPeers.value = false
}
}
async function sendMessage() {
if (!sendMessageTo.value || !sendMessageText.value.trim()) return
sendingMessage.value = true
sendMessageError.value = ''
sendMessageSuccess.value = ''
try {
await rpcClient.sendMessageToPeer(sendMessageTo.value, sendMessageText.value.trim())
sendMessageSuccess.value = t('web5.messageSent')
sendMessageText.value = ''
setTimeout(() => {
showSendMessageModal.value = false
sendMessageSuccess.value = ''
}, 1500)
} catch (e) {
sendMessageError.value = e instanceof Error ? e.message : t('web5.failedToSend')
} finally {
sendingMessage.value = false
}
}
async function discoverAndAddPeers() {
discovering.value = true
try {
const res = await rpcClient.discoverNodes()
const nodes = res.nodes || []
for (const n of nodes) {
if (n.onion && n.pubkey) {
try {
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
} catch (e) {
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
}
}
}
await loadPeers()
} catch (e) {
if (import.meta.env.DEV) console.error('Discover failed:', e)
} finally {
discovering.value = false
}
}
async function loadConnectionRequests() {
const hadRequests = connectionRequests.value.length > 0
loadingRequests.value = true
try {
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
connectionRequests.value = res.requests || []
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch {
if (!hadRequests) connectionRequests.value = []
} finally {
loadingRequests.value = false
}
}
async function acceptRequest(requestId: string) {
processingRequestId.value = requestId
try {
await rpcClient.call({ method: 'network.accept-request', params: { id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
await loadPeers()
emit('toast', t('web5.connectionAccepted'))
} catch {
emit('toast', t('web5.failedToAcceptRequest'))
} finally {
processingRequestId.value = null
}
}
async function rejectRequest(requestId: string) {
processingRequestId.value = requestId
try {
await rpcClient.call({ method: 'network.reject-request', params: { id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
emit('toast', t('web5.requestRejected'))
} catch {
emit('toast', t('web5.failedToRejectRequest'))
} finally {
processingRequestId.value = null
}
}
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers })
</script>
@@ -0,0 +1,121 @@
<template>
<!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.verifiableCredentialsDesc') }}</p>
</div>
</div>
<router-link to="/dashboard/web5/credentials" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
Manage &rarr;
</router-link>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.verifiableCredentialsDesc') }}</p>
<router-link to="/dashboard/web5/credentials" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
Manage &rarr;
</router-link>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Total</div>
<span class="text-sm text-white font-medium">{{ vcCredentials.length }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Active</div>
<span class="text-sm text-green-400 font-medium">{{ vcCredentials.filter(c => c.status === 'active').length }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Identities</div>
<span class="text-sm text-white font-medium">{{ identityCount }}</span>
</div>
</div>
<!-- Credentials List (summary) -->
<div v-if="credentialsLoading && vcCredentials.length === 0" class="text-center text-white/40 text-sm py-4">
Loading credentials...
</div>
<div v-else-if="vcCredentials.length" class="space-y-2">
<div v-if="credentialsLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing credentials...
</div>
<div v-else-if="credentialsError" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ credentialsError }}
</div>
<div v-for="vc in vcCredentials.slice(0, 3)" :key="vc.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="min-w-0 flex-1">
<div class="text-sm text-white font-medium">{{ vc.type }}</div>
<div class="text-xs text-white/50 truncate">To: {{ (vc.subject || '').slice(0, 30) }}...</div>
</div>
<span :class="{
'text-green-400': vc.status === 'active',
'text-red-400': vc.status === 'revoked',
'text-yellow-400': vc.status === 'expired'
}" class="text-xs font-medium capitalize">{{ vc.status }}</span>
</div>
<router-link v-if="vcCredentials.length > 3" to="/dashboard/web5/credentials" class="block text-center text-xs text-white/50 hover:text-white/70 py-2 transition-colors">
View all {{ vcCredentials.length }} credentials &rarr;
</router-link>
</div>
<div v-else class="text-center text-white/40 text-sm py-4">
{{ t('web5.noCredentials') }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import type { VCData } from './types'
const { t } = useI18n()
defineProps<{
identityCount: number
}>()
const vcCredentials = ref<VCData[]>([])
const credentialsLoading = ref(false)
const credentialsError = ref('')
async function loadCredentials() {
const hadCredentials = vcCredentials.value.length > 0
credentialsLoading.value = true
credentialsError.value = ''
try {
const res = await rpcClient.call<{ credentials: VCData[] }>({ method: 'identity.list-credentials' })
vcCredentials.value = res.credentials || []
} catch (e: unknown) {
credentialsError.value = e instanceof Error ? e.message : 'Failed to load credentials'
if (!hadCredentials) vcCredentials.value = []
} finally {
credentialsLoading.value = false
}
}
defineExpose({ loadCredentials })
</script>
+238
View File
@@ -0,0 +1,238 @@
<template>
<!-- Bitcoin Domain Name Portfolio -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.bitcoinDomains') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.domainsSubtitle') }}</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div v-if="domainsLoading && registeredNames.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing domains...
</div>
<div v-else-if="domainsLoadError && registeredNames.length > 0" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ domainsLoadError }}
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.namesRegistered') }}</span>
</div>
<span class="text-white/60 text-sm">{{ registeredNames.length }} {{ registeredNames.length === 1 ? 'name' : 'names' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">{{ t('common.status') }}</span>
</div>
<span :class="activeNamesCount > 0 ? 'text-green-400' : 'text-white/60'" class="text-sm font-medium">
{{ activeNamesCount > 0 ? `${activeNamesCount} Active` : 'None' }}
</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.expiringSoon') }}</span>
</div>
<span class="text-white/60 text-sm">{{ expiringNamesCount }} {{ expiringNamesCount === 1 ? 'name' : 'names' }}</span>
</div>
</div>
<button @click="showDomainsModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.manageDomains') }}
</button>
</div>
<!-- Domains Management Modal -->
<Teleport to="body">
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
<div class="flex items-center justify-between mb-4">
<h2 id="domains-title" class="text-lg font-bold text-white">{{ t('web5.domainsTitle') }}</h2>
<button @click="showDomainsModal = false" class="text-white/40 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Registered Names List -->
<div v-if="registeredNames.length" class="space-y-2 mb-4">
<div v-for="n in registeredNames" :key="n.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div>
<div class="text-sm text-white font-medium font-mono">{{ n.nip05 }}</div>
<div class="text-xs text-white/50 truncate max-w-[200px]">DID: {{ n.did }}</div>
</div>
<div class="flex items-center gap-2">
<span :class="{
'text-green-400': n.status === 'active',
'text-yellow-400': n.status === 'pending',
'text-red-400': n.status === 'expired' || n.status === 'failed'
}" class="text-xs font-medium capitalize">{{ n.status }}</span>
<button @click="removeName(n.id)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-white/40 text-sm py-4 mb-4">{{ t('web5.noDomains') }}</div>
<!-- Register New Name -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.registerNewName') }}</h3>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="text-white/60 text-xs block mb-1">Username</label>
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Domain</label>
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full input-glass" />
</div>
</div>
<div class="mb-3">
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
<select v-model="newDomainIdentityId" class="w-full input-glass">
<option value="" disabled>Select identity...</option>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
</select>
</div>
<div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div>
<button @click="registerNewName" :disabled="domainRegistering || !newDomainName.trim() || !newDomainDomain.trim() || !newDomainIdentityId" class="w-full glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ domainRegistering ? 'Registering...' : 'Register Name' }}
</button>
</div>
<!-- Verify NIP-05 -->
<div class="border-t border-white/10 pt-4 mt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
<div class="flex gap-2">
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 input-glass" />
<button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ nip05Verifying ? '...' : 'Verify' }}
</button>
</div>
<div v-if="nip05Result" class="mt-2 p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" :class="nip05Result.verified ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white font-medium">{{ nip05Result.verified ? 'Verified' : 'Not Found' }}</span>
</div>
<div v-if="nip05Result.nostr_pubkey" class="text-xs text-white/50 font-mono truncate">Pubkey: {{ nip05Result.nostr_pubkey }}</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import type { RegisteredNameData, Nip05Result, ManagedIdentity } from './types'
const { t } = useI18n()
const props = defineProps<{
showStagger: boolean
managedIdentities: ManagedIdentity[]
}>()
const registeredNames = ref<RegisteredNameData[]>([])
const showDomainsModal = ref(false)
const newDomainName = ref('')
const newDomainDomain = ref('')
const newDomainIdentityId = ref('')
const domainError = ref('')
const domainRegistering = ref(false)
const domainsLoading = ref(false)
const domainsLoadError = ref('')
const verifyNip05Input = ref('')
const nip05Verifying = ref(false)
const nip05Result = ref<Nip05Result | null>(null)
const activeNamesCount = computed(() => registeredNames.value.filter(n => n.status === 'active').length)
const expiringNamesCount = computed(() => registeredNames.value.filter(n => n.status === 'expired' || n.expires_at).length)
async function loadDomainNames() {
const hadNames = registeredNames.value.length > 0
domainsLoading.value = true
domainsLoadError.value = ''
try {
const res = await rpcClient.call<{ names: RegisteredNameData[] }>({ method: 'identity.list-names' })
registeredNames.value = res.names || []
} catch (e: unknown) {
domainsLoadError.value = e instanceof Error ? e.message : 'Failed to load domains'
if (!hadNames) registeredNames.value = []
} finally {
domainsLoading.value = false
}
}
async function registerNewName() {
if (!newDomainName.value.trim() || !newDomainDomain.value.trim() || !newDomainIdentityId.value) return
domainRegistering.value = true
domainError.value = ''
try {
const identity = props.managedIdentities.find(i => i.id === newDomainIdentityId.value)
await rpcClient.call({ method: 'identity.register-name', params: {
name: newDomainName.value.trim(),
domain: newDomainDomain.value.trim(),
identity_id: newDomainIdentityId.value,
did: identity?.did || '',
}})
newDomainName.value = ''
newDomainDomain.value = ''
newDomainIdentityId.value = ''
await loadDomainNames()
} catch (e: unknown) {
domainError.value = e instanceof Error ? e.message : t('web5.registrationFailed')
} finally {
domainRegistering.value = false
}
}
async function removeName(id: string) {
try {
await rpcClient.call({ method: 'identity.remove-name', params: { id } })
await loadDomainNames()
} catch (e: unknown) {
domainError.value = e instanceof Error ? e.message : t('web5.removeFailed')
}
}
async function verifyNip05() {
if (!verifyNip05Input.value.trim()) return
nip05Verifying.value = true
nip05Result.value = null
try {
const res = await rpcClient.call<Nip05Result>({ method: 'identity.resolve-name', params: { identifier: verifyNip05Input.value.trim() } })
nip05Result.value = res
} catch {
nip05Result.value = { name: '', domain: '', nostr_pubkey: null, relays: [], verified: false }
} finally {
nip05Verifying.value = false
}
}
// Expose for parent to call on mount
defineExpose({ loadDomainNames, registeredNames })
</script>
+134
View File
@@ -0,0 +1,134 @@
<template>
<!-- Federation Summary -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">Federation</h2>
<p class="text-xs text-white/60">Federated nodes &amp; peers</p>
</div>
</div>
<div class="web5-card-actions-top items-center gap-2">
<RouterLink to="/dashboard/server/federation" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
Find Nodes
</RouterLink>
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
Fleet
</RouterLink>
</div>
</div>
<div class="space-y-3">
<div v-if="loadingFederation && hasLoadedFederation" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing federation...
</div>
<div v-else-if="federationError && hasLoadedFederation" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ federationError }}
</div>
<div v-else-if="loadingFederation" class="p-2 text-center text-white/45 text-xs">
Loading federation...
</div>
<!-- Node Count -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="nodeCount > 0 ? 'bg-green-400' : 'bg-white/30'" />
<span class="text-sm text-white/80">Known Nodes</span>
</div>
<span class="text-sm font-medium text-white">{{ nodeCount }}</span>
</div>
<!-- Online -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="onlineCount > 0 ? 'bg-green-400' : 'bg-white/30'" />
<span class="text-sm text-white/80">Online</span>
</div>
<span class="text-sm font-medium" :class="onlineCount > 0 ? 'text-green-400' : 'text-white/40'">{{ onlineCount }}</span>
</div>
<!-- Pending Requests -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="pendingCount > 0 ? 'bg-orange-400' : 'bg-white/30'" />
<span class="text-sm text-white/80">Pending Requests</span>
</div>
<span class="text-sm font-medium" :class="pendingCount > 0 ? 'text-orange-400' : 'text-white/40'">{{ pendingCount }}</span>
</div>
<!-- Self DID -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<span class="text-sm text-white/80">Node DID</span>
<span class="text-sm font-medium text-white/40 truncate max-w-[140px]">{{ selfDid || 'Not set' }}</span>
</div>
</div>
<div class="web5-card-actions-bottom-grid mt-6 grid-cols-2 gap-3">
<RouterLink to="/dashboard/server/federation" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
Find Nodes
</RouterLink>
<RouterLink to="/dashboard/fleet" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
Fleet
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const nodeCount = ref(0)
const onlineCount = ref(0)
const pendingCount = ref(0)
const selfDid = ref('')
const loadingFederation = ref(false)
const hasLoadedFederation = ref(false)
const federationError = ref('')
async function loadFederationSummary() {
loadingFederation.value = true
federationError.value = ''
try {
const res = await rpcClient.call<{
nodes: Array<{ last_seen?: string }>
}>({ method: 'federation.list-nodes', timeout: 5000 })
const nodes = res.nodes || []
nodeCount.value = nodes.length
// list-nodes has no live status field — count a node seen in the
// last 10 minutes as online
const cutoff = Date.now() - 10 * 60 * 1000
onlineCount.value = nodes.filter(n => n.last_seen && new Date(n.last_seen).getTime() > cutoff).length
try {
const pend = await rpcClient.call<{ requests: Array<unknown> }>({ method: 'federation.list-pending-requests', timeout: 5000 })
pendingCount.value = (pend.requests || []).length
} catch { pendingCount.value = 0 }
hasLoadedFederation.value = true
} catch (e: unknown) {
federationError.value = e instanceof Error ? e.message : 'Federation unavailable'
}
try {
const res = await rpcClient.getNodeDid()
selfDid.value = res.did || ''
} catch (e: unknown) {
if (!federationError.value) federationError.value = e instanceof Error ? e.message : 'Node identity unavailable'
} finally {
loadingFederation.value = false
}
}
onMounted(loadFederationSummary)
defineExpose({ loadFederationSummary })
</script>
+724
View File
@@ -0,0 +1,724 @@
<template>
<!-- Identity Management -->
<div class="glass-card p-6 identities-card">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.identitiesDesc') }}</p>
</div>
</div>
<button @click="showCreateIdentityModal = true" class="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2">
Create
</button>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.identitiesDesc') }}</p>
</div>
<!-- Loading -->
<div v-if="identitiesLoading && managedIdentities.length === 0" class="py-6 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('common.loading') }}</p>
</div>
<!-- Empty State -->
<div v-else-if="managedIdentities.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.noIdentities') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.createFirstIdentity') }}</p>
</div>
<!-- Identity List -->
<div v-else class="space-y-3">
<div v-if="identitiesLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing identities...
</div>
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="identity-row-main flex items-center gap-4 min-w-0">
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-sm font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-medium text-sm">{{ identity.profile?.display_name || identity.name }}</span>
<span v-if="identity.profile?.display_name && identity.profile.display_name !== identity.name" class="text-white/40 text-xs truncate max-w-[160px]" :title="`Internal name: ${identity.name}`">({{ identity.name }})</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<div class="flex items-center gap-1 mt-0.5">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-1 shrink-0">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
<button @click="showCreateIdentityModal = true" class="web5-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
Create
</button>
</div>
<!-- Create Identity Modal -->
<Teleport to="body">
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Name</label>
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Purpose</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="p in ['personal', 'business', 'anonymous']"
:key="p"
@click="newIdentityPurpose = p"
class="px-3 py-2 rounded-lg text-sm capitalize transition-colors border"
:class="newIdentityPurpose === p ? 'bg-white/15 border-white/30 text-white' : 'bg-white/5 border-white/10 text-white/60 hover:bg-white/10'"
>{{ p }}</button>
</div>
</div>
</div>
<div v-if="createIdentityError" class="mt-3 alert-error">
<p class="text-xs">{{ createIdentityError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">
{{ creatingIdentity ? t('web5.creatingDid') : t('web5.createIdentity') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
<div class="flex gap-3">
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium">
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Key Viewer Modal -->
<Teleport to="body">
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
}">
<svg class="w-5 h-5" :class="{
'text-blue-400': keyViewerIdentity.purpose === 'personal',
'text-orange-400': keyViewerIdentity.purpose === 'business',
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
</div>
</div>
<!-- Public Keys -->
<div class="space-y-3 mb-5">
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
Public Keys
</h3>
<div class="space-y-2">
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">DID (Ed25519)</span>
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
</div>
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
</div>
</div>
</div>
<!-- Private Keys Section -->
<div class="border-t border-white/10 pt-5">
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
Private Keys
</h3>
<div v-if="!keyViewerPrivateKeys">
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
<div class="flex gap-2">
<input v-model="keyViewerPassword" type="password" placeholder="Password" class="flex-1 input-glass" @keydown.enter="unlockPrivateKeys" />
<button @click="unlockPrivateKeys" :disabled="!keyViewerPassword || keyViewerUnlocking" class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50">
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
</button>
</div>
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
</div>
<div v-else class="space-y-2">
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
</div>
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">Lock private keys</button>
</div>
</div>
<div class="flex justify-end mt-5">
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Profile Editor Modal -->
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img
v-if="profileForm.picture && !editorPictureFailed"
:src="displayableUrl(profileForm.picture)"
class="w-full h-full object-cover"
@error="editorPictureFailed = true"
@load="editorPictureFailed = false"
/>
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
<div>
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} &middot; {{ profileEditorIdentity.purpose }}</p>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Display Name</label>
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Profile Picture</label>
<div class="flex gap-2">
<input v-model="profileForm.picture" type="url" placeholder="https://… or upload below" class="flex-1 input-glass" />
<label class="glass-button px-3 py-2 rounded-lg text-xs cursor-pointer whitespace-nowrap">
{{ avatarUploading ? 'Uploading…' : 'Upload' }}
<input type="file" accept="image/*" class="hidden" :disabled="avatarUploading" @change="uploadAsset($event, 'picture')" />
</label>
</div>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Banner Image</label>
<div class="flex gap-2">
<input v-model="profileForm.banner" type="url" placeholder="https://… or upload below" class="flex-1 input-glass" />
<label class="glass-button px-3 py-2 rounded-lg text-xs cursor-pointer whitespace-nowrap">
{{ bannerUploading ? 'Uploading…' : 'Upload' }}
<input type="file" accept="image/*" class="hidden" :disabled="bannerUploading" @change="uploadAsset($event, 'banner')" />
</label>
</div>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Website</label>
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full input-glass" />
</div>
</div>
</div>
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { ManagedIdentity, IdentityProfile } from './types'
const { t } = useI18n()
const IDENTITIES_CACHE_KEY = 'archipelago.web5.identities.v1'
function readIdentitiesCache(): ManagedIdentity[] {
if (typeof window === 'undefined') return []
try {
const raw = window.sessionStorage.getItem(IDENTITIES_CACHE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as ManagedIdentity[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeIdentitiesCache(identities: ManagedIdentity[]) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(IDENTITIES_CACHE_KEY, JSON.stringify(identities))
} catch {
// Cache is opportunistic only.
}
}
defineProps<{
showStagger: boolean
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
const managedIdentities = ref<ManagedIdentity[]>(readIdentitiesCache())
const identitiesLoading = ref(false)
const showCreateIdentityModal = ref(false)
const newIdentityName = ref('Personal')
const newIdentityPurpose = ref('personal')
const creatingIdentity = ref(false)
const createIdentityError = ref<string | null>(null)
const deleteIdentityTarget = ref<ManagedIdentity | null>(null)
const deletingIdentity = ref(false)
// Key viewer
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
const keyViewerPassword = ref('')
const keyViewerUnlocking = ref(false)
const keyViewerError = ref('')
const keyViewerCopied = ref<string | null>(null)
// Profile editor
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
const profilePublishing = ref(false)
const avatarUploading = ref(false)
const bannerUploading = ref(false)
// Track image load failures so the UI can fall back to the initial/
// identicon placeholder instead of showing a blank square. Pasted URLs
// that 404 (or point at an onion the local browser can't reach) were
// previously silently hidden by a display:none handler that left the
// fallback unrendered.
const editorPictureFailed = ref(false)
const listPictureFailed = reactive<Record<string, boolean>>({})
// Reset the failure flag when the URL changes so a freshly pasted URL
// gets re-tried (the watcher fires once the form reacts).
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
// The backend returns onion-based public URLs for uploaded profile
// pictures (so they're fetchable by external Nostr clients), but the
// local browser session isn't Tor-routed and can't resolve .onion hosts.
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
// URLs and plain external URLs pass through untouched.
function displayableUrl(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('data:') || url.startsWith('/')) return url
const onionMatch = url.match(/^https?:\/\/[a-z2-7]{16,56}\.onion(\/blob\/[0-9a-f]{64})(\?.*)?$/i)
if (onionMatch && onionMatch[1]) return onionMatch[1]
return url
}
// Upload to the node's blob store and drop a URL into the profile field.
// For small images (≤64KB) we inline the bytes as a data URL so external
// Nostr clients can render the picture without needing to reach a tor
// onion. Larger uploads fall back to the onion-rooted public_url.
const INLINE_MAX = 64 * 1024
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const input = ev.target as HTMLInputElement
const file = input?.files?.[0]
if (!file) return
const flag = field === 'picture' ? avatarUploading : bannerUploading
flag.value = true
profileError.value = ''
try {
const buf = await file.arrayBuffer()
// Inline small images as a data URL — universally fetchable by any
// Nostr client and bypasses the "only reachable over Tor" limitation.
if (buf.byteLength <= INLINE_MAX) {
const mime = file.type || 'image/png'
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
profileForm.value[field] = `data:${mime};base64,${b64}`
return
}
const resp = await fetch('/api/blob', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'X-Blob-Mime': file.type || 'application/octet-stream',
'X-Blob-Filename': file.name,
},
body: buf,
})
if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`)
const { public_url, self_test_url } = await resp.json() as { public_url?: string; self_test_url?: string }
const url = public_url || self_test_url
if (!url) throw new Error('blob API returned no URL')
profileForm.value[field] = url
// Heads-up for large uploads: onion URLs only render on Tor-routed
// clients. Not an error, but worth telling the user.
if (url.includes('.onion/')) {
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
}
} catch (e: unknown) {
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
} finally {
flag.value = false
// Clear the input so selecting the same file again re-fires change.
if (input) input.value = ''
}
}
const profileError = ref('')
const profileSuccess = ref('')
async function loadIdentities() {
const hadIdentities = managedIdentities.value.length > 0
identitiesLoading.value = true
try {
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
managedIdentities.value = res.identities || []
writeIdentitiesCache(managedIdentities.value)
} catch {
if (!hadIdentities) managedIdentities.value = []
} finally {
identitiesLoading.value = false
}
}
async function createIdentity() {
if (creatingIdentity.value) return
createIdentityError.value = null
creatingIdentity.value = true
try {
await rpcClient.call({
method: 'identity.create',
params: { name: newIdentityName.value.trim() || 'Personal', purpose: newIdentityPurpose.value },
})
showCreateIdentityModal.value = false
newIdentityName.value = 'Personal'
newIdentityPurpose.value = 'personal'
await loadIdentities()
emit('toast', t('web5.identityCreated'))
} catch (err: unknown) {
createIdentityError.value = err instanceof Error ? err.message : t('web5.failedToCreateIdentity')
} finally {
creatingIdentity.value = false
}
}
function copyIdentityDid(did: string) {
safeClipboardWrite(did)
emit('toast', t('web5.didCopied'))
}
async function setDefaultIdentity(id: string) {
try {
await rpcClient.call({ method: 'identity.set-default', params: { id } })
await loadIdentities()
emit('toast', t('web5.defaultIdentityUpdated'))
} catch {
emit('toast', t('web5.failedToSetDefault'))
}
}
function confirmDeleteIdentity(identity: ManagedIdentity) {
deleteIdentityTarget.value = identity
}
async function deleteIdentity() {
if (!deleteIdentityTarget.value || deletingIdentity.value) return
deletingIdentity.value = true
try {
await rpcClient.call({ method: 'identity.delete', params: { id: deleteIdentityTarget.value.id } })
deleteIdentityTarget.value = null
await loadIdentities()
emit('toast', t('web5.identityDeleted'))
} catch {
emit('toast', t('web5.failedToDeleteIdentity'))
} finally {
deletingIdentity.value = false
}
}
function openKeyViewer(identity: ManagedIdentity) {
keyViewerIdentity.value = identity
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
}
function closeKeyViewer() {
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
keyViewerIdentity.value = null
}
async function unlockPrivateKeys() {
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
keyViewerUnlocking.value = true
keyViewerError.value = ''
try {
const res = await rpcClient.call<{
ed25519_secret_hex: string
nostr_secret_hex: string | null
nostr_nsec: string | null
}>({
method: 'identity.export-keys',
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
})
keyViewerPrivateKeys.value = {
ed25519_secret_hex: res.ed25519_secret_hex,
nostr_secret_hex: res.nostr_secret_hex || '',
nostr_nsec: res.nostr_nsec || '',
}
keyViewerPassword.value = ''
} catch (err: unknown) {
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
} finally {
keyViewerUnlocking.value = false
}
}
function copyKeyValue(label: string, value: string) {
safeClipboardWrite(value)
keyViewerCopied.value = label
setTimeout(() => { keyViewerCopied.value = null }, 2000)
}
function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
try {
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
const res = await rpcClient.call<{
event_id: string
accepted: string[]
rejected: Array<[string, string]>
relays_attempted: number
published: boolean
}>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
})
await loadIdentities()
const n = res.accepted?.length ?? 0
const total = res.relays_attempted ?? 0
const tail = `(${res.event_id.slice(0, 12)}…)`
if (n === total) {
profileSuccess.value = `Published to all ${total} relays ${tail}`
} else if (n > 0) {
profileSuccess.value = `Published to ${n}/${total} relays ${tail}`
const first = res.rejected?.[0]
if (first) profileError.value = `Rejected by ${first[0]}: ${first[1]}`
} else {
profileError.value = `Published to 0/${total} relays — check Manage Relays`
}
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
} finally {
profilePublishing.value = false
}
}
defineExpose({ loadIdentities, managedIdentities })
</script>
<style scoped>
/* This card sits in a 2-column page grid, so its own rendered width is
roughly half the viewport — a viewport media query can't tell whether
the row actually has room for a horizontal layout. Use a container query
instead, keyed to the row's real width. */
.identities-card {
container-type: inline-size;
container-name: identities-card;
}
@container identities-card (min-width: 560px) {
.identity-row {
flex-direction: row;
align-items: center;
gap: 1rem;
}
.identity-row-main {
flex: 1 1 auto;
}
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<!-- System Monitoring Summary -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">Monitoring</h2>
<p class="text-xs text-white/60">System resources &amp; health</p>
</div>
</div>
<RouterLink to="/dashboard/monitoring" class="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2 no-underline">
Details
</RouterLink>
</div>
<div class="space-y-3">
<!-- CPU -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-sm text-white/80">CPU</span>
</div>
<div class="flex items-center gap-3">
<div class="w-24 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="barColor(cpuPercent)" :style="{ width: cpuPercent + '%' }" />
</div>
<span class="text-sm font-medium text-white min-w-[3rem] text-right">{{ cpuPercent.toFixed(0) }}%</span>
</div>
</div>
<!-- Memory -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-sm text-white/80">Memory</span>
</div>
<div class="flex items-center gap-3">
<div class="w-24 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="barColor(memPercent)" :style="{ width: memPercent + '%' }" />
</div>
<span class="text-sm font-medium text-white min-w-[3rem] text-right">{{ memPercent.toFixed(0) }}%</span>
</div>
</div>
<!-- Disk -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-sm text-white/80">Disk</span>
</div>
<div class="flex items-center gap-3">
<div class="w-24 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="barColor(diskPercent)" :style="{ width: diskPercent + '%' }" />
</div>
<span class="text-sm font-medium text-white min-w-[3rem] text-right">{{ diskPercent.toFixed(0) }}%</span>
</div>
</div>
<!-- Uptime -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<span class="text-sm text-white/80">Uptime</span>
<span class="text-sm font-medium text-white/60">{{ uptimeDisplay }}</span>
</div>
</div>
<RouterLink to="/dashboard/monitoring" class="web5-card-actions-bottom mt-6 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0 no-underline">
Details
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { useHomeStatusStore } from '@/stores/homeStatus'
const homeStatus = useHomeStatusStore()
const cpuPercent = computed(() => homeStatus.stats.cpuPercent)
const memPercent = computed(() => homeStatus.stats.memPercent)
const diskPercent = computed(() => homeStatus.stats.diskPercent)
const uptimeDisplay = computed(() => {
const s = homeStatus.stats.uptimeSecs
if (s === 0) return '--'
const days = Math.floor(s / 86400)
const hours = Math.floor((s % 86400) / 3600)
const mins = Math.floor((s % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
return `${hours}h ${mins}m`
})
function barColor(pct: number): string {
if (pct > 85) return 'bg-red-400'
if (pct > 60) return 'bg-orange-400'
return 'bg-green-400'
}
async function loadStats() {
await homeStatus.refreshSystemStats()
}
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
loadStats()
refreshInterval = setInterval(loadStats, 30000)
})
onBeforeUnmount(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
</script>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import BackButton from '@/components/BackButton.vue'
const router = useRouter()
// Mirrors `crate::streaming::pricing::ServicePricing` (Metric is rename_all =
// "lowercase", so it round-trips verbatim back to streaming.configure-service).
type Metric = 'bytes' | 'milliseconds' | 'requests'
interface ServicePricing {
service_id: string
name: string
metric: Metric
step_size: number
price_per_step: number
min_steps: number
enabled: boolean
description: string
accepted_mints: string[]
}
const services = ref<ServicePricing[]>([])
const loading = ref(true)
const loadError = ref('')
const savingId = ref<string | null>(null)
const statusMsg = ref('')
const statusIsError = ref(false)
// "Free everything" is the default — every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled))
function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg
statusIsError.value = isError
setTimeout(() => { statusMsg.value = '' }, 5000)
}
/** Human label for one priced step, e.g. "MB", "minute", "request". */
function unitLabel(metric: Metric, stepSize: number): string {
if (metric === 'bytes') {
if (stepSize === 1_073_741_824) return 'GB'
if (stepSize === 1_048_576) return 'MB'
if (stepSize === 1024) return 'KB'
return `${stepSize.toLocaleString()} bytes`
}
if (metric === 'milliseconds') {
if (stepSize === 3_600_000) return 'hour'
if (stepSize === 60_000) return 'minute'
if (stepSize === 1000) return 'second'
return `${stepSize.toLocaleString()} ms`
}
// requests
return stepSize === 1 ? 'request' : `${stepSize.toLocaleString()} requests`
}
function minimumNote(svc: ServicePricing): string {
if (svc.min_steps <= 0) return ''
return `Minimum purchase: ${svc.min_steps.toLocaleString()} ${unitLabel(svc.metric, svc.step_size)}${svc.min_steps > 1 ? 's' : ''}`
}
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await rpcClient.call<{ services: ServicePricing[] }>({ method: 'streaming.list-services' })
services.value = (res.services || []).map((s) => ({
...s,
// Price must stay >= 1 sat: the backend rejects price_per_step == 0.
price_per_step: Math.max(1, s.price_per_step),
}))
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Failed to load services'
} finally {
loading.value = false
}
}
async function saveService(svc: ServicePricing) {
if (svc.price_per_step < 1) svc.price_per_step = 1
savingId.value = svc.service_id
try {
await rpcClient.call({
method: 'streaming.configure-service',
params: {
service_id: svc.service_id,
name: svc.name,
metric: svc.metric,
step_size: svc.step_size,
price_per_step: svc.price_per_step,
min_steps: svc.min_steps,
enabled: svc.enabled,
description: svc.description,
accepted_mints: svc.accepted_mints,
},
})
showStatus(
svc.enabled
? `Charging ${svc.price_per_step} sats per ${unitLabel(svc.metric, svc.step_size)} for ${svc.name}.`
: `${svc.name} is now free.`,
false,
)
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save', true)
// Reload so the UI reflects the persisted truth after a failed write.
void load()
} finally {
savingId.value = null
}
}
onMounted(load)
</script>
<template>
<div class="pb-6">
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl">✓</span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,248 @@
<template>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
<p class="text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<div v-if="visibilityLoading" class="shrink-0">
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<!-- Enable switch -->
<div class="flex items-center justify-between gap-3 p-3 rounded-lg bg-white/5 border border-white/10">
<div class="min-w-0">
<p class="text-sm font-medium text-white">Enable</p>
<p class="text-xs text-white/50">
{{ discoverEnabled ? 'Your node is public — anyone can discover it and request to peer' : 'Your node is hidden from discovery' }}
</p>
</div>
<ToggleSwitch
:model-value="discoverEnabled"
:disabled="settingVisibility"
aria-label="Enable public discoverability"
@update:model-value="toggleDiscoverable"
/>
</div>
<!-- Onion address (shown when public) -->
<div v-if="discoverEnabled && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourTorAddress') }}</p>
<p class="text-xs font-mono text-white/80 truncate" :title="nodeOnionAddress">{{ nodeOnionAddress }}</p>
</div>
<button @click="copyOnionAddress" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
</button>
</div>
</div>
<!-- Discoverable nodes -->
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-white">Discoverable nodes</p>
<button
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
</div>
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
Searching relays
</div>
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
</div>
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1">
<div
v-for="node in discoveredNodes"
:key="node.nostr_pubkey"
class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-white truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
:disabled="requestingPeer === node.nostr_pubkey || requestedPeers.has(node.nostr_pubkey)"
@click="requestModalTarget = node"
>
{{ requestedPeers.has(node.nostr_pubkey) ? 'Requested' : requestingPeer === node.nostr_pubkey ? 'Sending…' : 'Request to Peer' }}
</button>
</div>
</div>
</div>
<!-- Warning -->
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
{{ t('web5.discoverableWarning') }}
</p>
<PeerRequestModal
:show="requestModalTarget !== null"
:target-label="requestModalTarget ? shortNpub(requestModalTarget.nostr_npub) : ''"
:sending="requestingPeer !== null"
@send="confirmPeerRequest"
@cancel="requestModalTarget = null"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
import { safeClipboardWrite } from './utils'
import type { VisibilityLevel } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
const nodeVisibility = ref<VisibilityLevel>('hidden')
const nodeOnionAddress = ref<string | null>(null)
const visibilityLoading = ref(false)
const settingVisibility = ref(false)
const discoverEnabled = ref(false)
interface DiscoverableNode {
nostr_pubkey: string
nostr_npub: string
did: string
version: string
}
const discoveredNodes = ref<DiscoverableNode[]>([])
const discovering = ref(false)
const requestingPeer = ref<string | null>(null)
const requestedPeers = ref(new Set<string>())
function shortNpub(npub: string): string {
if (!npub) return 'unknown'
return npub.length > 21 ? `${npub.slice(0, 12)}${npub.slice(-6)}` : npub
}
async function loadVisibility() {
visibilityLoading.value = true
try {
// Nostr discovery is the functional flag: when on, a presence event
// (DID + npub — never the onion) is published to public relays so
// ANYONE can find this node and request a connection. The legacy
// network.get-visibility tri-state is only read for the onion display.
const [disc, vis] = await Promise.all([
rpcClient.nostrDiscoveryStatus(),
rpcClient
.call<{ visibility: string; onion_address?: string; tor_address?: string }>({ method: 'network.get-visibility' })
.catch(() => null),
])
discoverEnabled.value = !!disc.enabled
nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden'
nodeOnionAddress.value = vis?.onion_address || vis?.tor_address || null
if (discoverEnabled.value) void discoverNodes()
} catch {
discoverEnabled.value = false
} finally {
visibilityLoading.value = false
}
}
async function toggleDiscoverable(enabled: boolean) {
if (settingVisibility.value) return
settingVisibility.value = true
try {
// Public means public: the switch drives nostr presence publishing.
const res = await rpcClient.nostrSetDiscovery(enabled)
discoverEnabled.value = !!res.enabled
// Keep the legacy visibility string in sync (cosmetic; best-effort).
const level: VisibilityLevel = enabled ? 'public' : 'hidden'
rpcClient
.call({ method: 'network.set-visibility', params: { visibility: level } })
.then(() => { nodeVisibility.value = level })
.catch(() => {})
emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery')
if (enabled) void discoverNodes()
else discoveredNodes.value = []
} catch {
emit('toast', t('web5.failedToUpdateVisibility'))
} finally {
settingVisibility.value = false
}
}
async function discoverNodes() {
if (discovering.value) return
discovering.value = true
try {
const res = await rpcClient.handshakeDiscover()
discoveredNodes.value = res.nodes || []
} catch {
// keep whatever we had; the empty-state copy explains relay gossip lag
} finally {
discovering.value = false
}
}
const requestModalTarget = ref<DiscoverableNode | null>(null)
async function confirmPeerRequest(message: string | undefined) {
const node = requestModalTarget.value
if (!node) return
await requestToPeer(node, message)
requestModalTarget.value = null
}
async function requestToPeer(node: DiscoverableNode, message?: string) {
if (requestingPeer.value) return
requestingPeer.value = node.nostr_pubkey
try {
// Connection requests always land as Peer (observer) on approval —
// never trusted — so a mistaken request can't hand over fleet access.
await rpcClient.handshakeConnect(node.nostr_pubkey, message)
requestedPeers.value.add(node.nostr_pubkey)
requestedPeers.value = new Set(requestedPeers.value)
emit('toast', 'Peer request sent — awaiting their approval')
} catch (e) {
emit('toast', e instanceof Error ? e.message : 'Failed to send peer request')
} finally {
requestingPeer.value = null
}
}
function copyOnionAddress() {
if (!nodeOnionAddress.value) return
safeClipboardWrite(nodeOnionAddress.value)
emit('toast', t('web5.onionAddressCopied'))
}
defineExpose({ loadVisibility })
</script>
+190
View File
@@ -0,0 +1,190 @@
<template>
<!-- Nostr Relays -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 2">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nostrRelays') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.nostrRelaysDesc') }}</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div v-if="relaysLoading && hasRelayData" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing relays...
</div>
<div v-else-if="relaysLoadError && hasRelayData" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ relaysLoadError }}
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.relaysConnectedLabel') }}</span>
</div>
<span class="text-white/60 text-sm">{{ nostrRelayStats?.connected_count ?? 0 }} active</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.totalRelays') }}</span>
</div>
<span :class="(nostrRelayStats?.total_relays ?? 0) > 0 ? 'text-green-400' : 'text-white/60'" class="text-sm font-medium">
{{ nostrRelayStats?.total_relays ?? 0 }} configured
</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-white/80 text-sm">{{ t('common.enabled') }}</span>
</div>
<span class="text-white/60 text-sm">{{ nostrRelayStats?.enabled_count ?? 0 }} relays</span>
</div>
</div>
<button @click="showRelaysModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.relays') }}
</button>
</div>
<!-- Relay Management Modal -->
<Teleport to="body">
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
<div class="flex items-center justify-between mb-4">
<h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2>
<button @click="showRelaysModal = false" class="text-white/40 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Relay List -->
<div v-if="nostrRelays.length" class="space-y-2 mb-4">
<div v-for="relay in nostrRelays" :key="relay.url" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-2 h-2 rounded-full flex-shrink-0" :class="relay.connected ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white font-mono truncate">{{ relay.url }}</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button @click="toggleNostrRelay(relay.url, !relay.enabled)" class="text-xs px-2 py-1 rounded" :class="relay.enabled ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-white/40'">
{{ relay.enabled ? 'On' : 'Off' }}
</button>
<button @click="removeNostrRelay(relay.url)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-white/40 text-sm py-4 mb-4">{{ t('web5.noRelays') }}</div>
<!-- Add Relay -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
<div class="flex gap-2">
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 input-glass" @keyup.enter="addNostrRelay" />
<button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
Add
</button>
</div>
<div v-if="relayError" class="text-xs text-red-400 mt-2">{{ relayError }}</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import type { NostrRelayData, NostrRelayStatsData } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
}>()
const nostrRelays = ref<NostrRelayData[]>([])
const nostrRelayStats = ref<NostrRelayStatsData | null>(null)
const showRelaysModal = ref(false)
const newRelayUrl = ref('')
const relayError = ref('')
const relaysLoading = ref(false)
const relaysLoadError = ref('')
const hasRelayData = computed(() => nostrRelays.value.length > 0 || nostrRelayStats.value !== null)
async function loadNostrRelays() {
const hadRelayData = hasRelayData.value
relaysLoading.value = true
relaysLoadError.value = ''
try {
const [relayRes, statsRes] = await Promise.all([
rpcClient.call<{ relays: NostrRelayData[] }>({ method: 'nostr.list-relays' }),
rpcClient.call<NostrRelayStatsData>({ method: 'nostr.get-stats' }),
])
nostrRelays.value = relayRes.relays || []
nostrRelayStats.value = statsRes
} catch (e: unknown) {
relaysLoadError.value = e instanceof Error ? e.message : 'Failed to load relays'
if (!hadRelayData) {
nostrRelays.value = []
nostrRelayStats.value = null
}
} finally {
relaysLoading.value = false
}
}
async function addNostrRelay() {
if (!newRelayUrl.value.trim()) return
relayError.value = ''
try {
await rpcClient.call({ method: 'nostr.add-relay', params: { url: newRelayUrl.value.trim() } })
newRelayUrl.value = ''
await loadNostrRelays()
} catch (e: unknown) {
relayError.value = e instanceof Error ? e.message : t('web5.failedToAddRelay')
}
}
async function removeNostrRelay(url: string) {
try {
await rpcClient.call({ method: 'nostr.remove-relay', params: { url } })
await loadNostrRelays()
} catch (e: unknown) {
relayError.value = e instanceof Error ? e.message : t('web5.failedToRemoveRelay')
}
}
async function toggleNostrRelay(url: string, enabled: boolean) {
try {
await rpcClient.call({ method: 'nostr.toggle-relay', params: { url, enabled } })
await loadNostrRelays()
} catch (e: unknown) {
relayError.value = e instanceof Error ? e.message : t('web5.failedToToggleRelay')
}
}
function openRelaysModal() {
showRelaysModal.value = true
}
defineExpose({ loadNostrRelays, nostrRelayStats, openRelaysModal })
</script>
@@ -0,0 +1,245 @@
<template>
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<span class="text-2xl text-orange-500 font-bold"></span>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.networkingProfits') }}</p>
<p class="text-xs text-orange-500 font-medium">{{ networkingProfitsDisplay }}</p>
</div>
</div>
<div v-if="profitsBreakdown" class="text-xs text-white/40 space-y-0.5">
<p v-if="profitsBreakdown.content_sales_sats > 0">Content: {{ profitsBreakdown.content_sales_sats.toLocaleString() }} sats</p>
<p v-if="profitsBreakdown.routing_fees_sats > 0">Routing: {{ profitsBreakdown.routing_fees_sats.toLocaleString() }} sats</p>
</div>
<button
@click="router.push('/dashboard/web5/networking-profits')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('common.settings') }}
</button>
</div>
<!-- Mobile-only toggle: DID Status through Connected Nodes are often not
needed at a glance, so collapse them by default to save space. -->
<button
type="button"
class="md:hidden flex items-center justify-center gap-1.5 py-2 text-xs font-medium text-white/50 hover:text-white/80 transition-colors"
@click="mobileCollapsed = !mobileCollapsed"
>
{{ mobileCollapsed ? 'Show more' : 'Show less' }}
<svg class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': !mobileCollapsed }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div :class="mobileCollapsed ? 'hidden md:contents' : 'contents'">
<!-- DID Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">{{ t('web5.didStatus') }}</p>
<p v-if="userDid" class="text-xs text-white/60 font-mono truncate" :title="userDid">{{ userDid }}</p>
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
</div>
</div>
<div v-if="userDid" class="flex gap-2 mt-auto">
<button
@click="$emit('copyDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button>
<button
@click="$emit('showDidDocument')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.viewDidDocument') }}
</button>
</div>
<button
v-else
@click="$emit('createDid')"
:disabled="creatingDid"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
</button>
</div>
<!-- did:dht Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="dhtDid ? 'bg-blue-400' : 'bg-gray-500'"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">DHT Identity</p>
<p v-if="dhtDid" class="text-xs text-white/60 font-mono truncate" :title="dhtDid">{{ dhtDid }}</p>
<p v-else class="text-xs text-white/60">Not published</p>
</div>
</div>
<div v-if="dhtDid" class="flex gap-2 mt-auto">
<button
@click="$emit('copyDhtDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button>
<button
@click="$emit('refreshDhtDid')"
:disabled="publishingDht"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button>
</div>
<button
v-else-if="userDid"
@click="$emit('publishDhtDid')"
:disabled="publishingDht"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
</button>
</div>
<!-- Wallet Connection -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.wallet') }}</p>
<p class="text-xs text-white/60">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
</div>
</div>
<button
@click="$emit('connectWallet')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="connectingWallet"
>
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
</button>
</div>
<!-- Nostr Relay Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="(nostrRelayStats?.connected_count ?? 0) > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="(nostrRelayStats?.connected_count ?? 0) > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.nostrRelays') }}</p>
<p class="text-xs text-white/60">{{ t('web5.relaysConnected', { count: nostrRelayStats?.connected_count ?? 0 }) }}</p>
</div>
</div>
<button
@click="$emit('manageRelays')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('common.manage') }}
</button>
</div>
<!-- Connected Nodes -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 4">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
<div v-if="connectedNodesCount > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-pulse opacity-75"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">{{ t('web5.connectedNodes') }}</p>
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
</div>
</div>
<div class="flex gap-2 mt-auto">
<button
@click="router.push('/dashboard/server/federation')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Nodes
</button>
<button
@click="router.push('/dashboard/mesh')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Message
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Hardware Wallet Detected Banner -->
<div v-if="detectedHwWallets.length > 0" class="mb-6 alert-warning flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-orange-400">{{ t('web5.hardwareWalletDetected') }}</p>
<p class="text-xs text-white/60">
{{ detectedHwWallets.map(d => `${d.type}${d.product ? ' (' + d.product + ')' : ''}`).join(', ') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
const router = useRouter()
const { t } = useI18n()
// DID Status through Connected Nodes are often not needed at a glance on
// mobile, so start collapsed there to save space. Desktop always shows them.
const mobileCollapsed = ref(true)
defineProps<{
showStagger: boolean
profitsBreakdown: ProfitsData | null
networkingProfitsDisplay: string
userDid: string | null
didStatus: 'active' | 'inactive' | 'pending'
didCopied: boolean
creatingDid: boolean
dhtDid: string | null
dhtDidCopied: boolean
publishingDht: boolean
walletConnected: boolean
connectingWallet: boolean
nostrRelayStats: NostrRelayStatsData | null
connectedNodesCount: number
detectedHwWallets: HwWalletDevice[]
}>()
defineEmits<{
copyDid: []
showDidDocument: []
createDid: []
copyDhtDid: []
refreshDhtDid: []
publishDhtDid: []
connectWallet: []
manageRelays: []
}>()
</script>
@@ -0,0 +1,527 @@
<template>
<!-- Unified Send Modal -->
<Teleport to="body">
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash &lt; 1k sats, Lightning 1k-500k, on-chain &gt; 500k</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div v-if="effectiveSendMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
</label>
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashSendToken && effectiveSendMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashSendToken }}</p>
<button @click="copyEcashToken(ecashSendToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="effectiveSendMethod === 'onchain'" class="mb-3 flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="useHardwareWallet" class="sr-only peer" />
<div class="w-9 h-5 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:bg-orange-500/40 transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
<div>
<p class="text-sm text-white">{{ t('web5.signWithHwWallet') }}</p>
<p class="text-xs text-white/40">{{ t('web5.createsPsbt') }}</p>
</div>
</div>
<div v-if="psbtStep === 'created' && psbtData" class="mb-3 space-y-2">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/50 mb-1">Unsigned PSBT (copy or download):</p>
<textarea readonly :value="psbtData" rows="3" class="w-full bg-black/20 border border-white/10 rounded px-2 py-1 text-xs font-mono text-white/80 focus:outline-none"></textarea>
<div class="flex gap-2 mt-2">
<button @click="copyPsbt" class="text-xs text-orange-400 hover:text-orange-300">Copy PSBT</button>
<button @click="downloadPsbt" class="text-xs text-orange-400 hover:text-orange-300">Download .psbt</button>
</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/50 mb-1">Paste signed PSBT or upload file:</p>
<textarea v-model="signedPsbtInput" rows="3" placeholder="Paste signed PSBT base64 here..." class="w-full bg-black/20 border border-white/10 rounded px-2 py-1 text-xs font-mono text-white/80 focus:outline-none focus:border-white/30"></textarea>
<div class="flex gap-2 mt-2">
<label class="text-xs text-orange-400 hover:text-orange-300 cursor-pointer">
Upload .psbt
<input type="file" accept=".psbt,.txt" class="hidden" @change="handlePsbtFileUpload" />
</label>
</div>
</div>
</div>
<div v-if="showMeshRelayPrompt" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">&#x1F4E1;</span>
<p class="text-orange-300 text-sm font-medium">You are offline</p>
</div>
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
<div class="flex gap-2">
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
<button @click="handleMeshRelaySend" class="flex-1 glass-button glass-button-warning px-3 py-2 rounded-lg text-xs font-medium">Send via Mesh</button>
</div>
</div>
<div v-if="meshRelayActive" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-1">
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-orange-300 text-xs font-medium">Mesh Relay</p>
</div>
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
</div>
<div v-if="sendResultTxid" class="mb-3 alert-success"><p class="text-xs">Sent! TX: {{ sendResultTxid }}</p></div>
<div v-if="sendResultHash" class="mb-3 alert-success"><p class="text-xs">Paid! Hash: {{ sendResultHash }}</p></div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3">
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }}
</button>
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Unified Receive Modal -->
<Teleport to="body">
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
:key="m"
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full input-glass" />
</div>
<div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ receiveInvoiceResult }}</p>
<button @click="copyToClipboard(receiveInvoiceResult, 'Invoice copied')" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
</div>
<div v-if="receiveMethod === 'onchain'">
<div v-if="receiveOnchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
<p class="text-sm font-mono text-white/90 break-all">{{ receiveOnchainAddress }}</p>
<button @click="copyToClipboard(receiveOnchainAddress, 'Address copied')" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="unifiedReceiveProcessing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</div>
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
<div v-if="unifiedReceiveError" class="mb-3 text-xs text-red-400">{{ unifiedReceiveError }}</div>
<div class="flex gap-3">
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useTransportStore } from '@/stores/transport'
import { useMeshStore } from '@/stores/mesh'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
import { safeClipboardWrite } from './utils'
const { t } = useI18n()
const transportStore = useTransportStore()
const meshStore = useMeshStore()
const emit = defineEmits<{
toast: [text: string]
balancesChanged: []
}>()
// Send state
const showUnifiedSendModal = ref(false)
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
const unifiedSendAmount = ref<number>(0)
const unifiedSendDest = ref('')
const unifiedSendProcessing = ref(false)
const unifiedSendError = ref('')
const sendResultTxid = ref('')
const sendResultHash = ref('')
const useHardwareWallet = ref(false)
const meshRelayActive = ref(false)
const meshRelayStatus = ref('')
const meshRelayRequestId = ref(0)
const showMeshRelayPrompt = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
const ecashSendToken = ref('')
// Receive state
const showUnifiedReceiveModal = ref(false)
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('onchain')
const receiveInvoiceAmount = ref<number>(0)
const receiveInvoiceMemo = ref('')
const receiveInvoiceResult = ref('')
const receiveOnchainAddress = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const unifiedReceiveProcessing = ref(false)
const unifiedReceiveError = ref('')
const ecashReceiveToken = ref('')
const ecashReceiveResult = ref('')
const effectiveSendMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = unifiedSendAmount.value || 0
if (amt <= 0) return 'lightning'
if (amt < 1000) return 'ecash'
if (amt > 500000) return 'onchain'
return 'lightning'
})
function openSend() { showUnifiedSendModal.value = true }
function openReceive() { showUnifiedReceiveModal.value = true }
function closeUnifiedSendModal() {
showUnifiedSendModal.value = false
ecashSendToken.value = ''
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
psbtData.value = ''
psbtStep.value = 'idle'
signedPsbtInput.value = ''
}
function closeUnifiedReceiveModal() {
showUnifiedReceiveModal.value = false
receiveInvoiceResult.value = ''
receiveOnchainAddress.value = ''
ecashReceiveToken.value = ''
ecashReceiveResult.value = ''
unifiedReceiveError.value = ''
}
function copyEcashToken(token: string) {
safeClipboardWrite(token)
emit('toast', t('web5.ecashTokenCopied'))
}
function copyToClipboard(text: string, msg: string) {
safeClipboardWrite(text)
emit('toast', msg)
}
async function unifiedSend() {
if (!unifiedSendAmount.value || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
ecashSendToken.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
meshRelayActive.value = false
meshRelayStatus.value = ''
const method = effectiveSendMethod.value
try {
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
params: { amount_sats: unifiedSendAmount.value },
})
ecashSendToken.value = res.token
} else if (method === 'lightning') {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.pasteInvoice')
return
}
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: unifiedSendDest.value.trim() },
})
sendResultHash.value = res.payment_hash
} else {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.enterBitcoinAddress')
return
}
if (useHardwareWallet.value) {
const res = await rpcClient.createPsbt({
outputs: [{ address: unifiedSendDest.value.trim(), amount_sats: unifiedSendAmount.value }],
})
psbtData.value = res.psbt_base64
psbtStep.value = 'created'
signedPsbtInput.value = ''
unifiedSendProcessing.value = false
return
}
await transportStore.fetchStatus()
if (transportStore.meshOnly) {
showMeshRelayPrompt.value = true
unifiedSendProcessing.value = false
return
}
try {
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
} catch (sendErr: unknown) {
const errMsg = sendErr instanceof Error ? sendErr.message : ''
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
showMeshRelayPrompt.value = true
unifiedSendProcessing.value = false
return
}
throw sendErr
}
}
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
unifiedSendProcessing.value = false
}
}
async function handleMeshRelaySend() {
showMeshRelayPrompt.value = false
unifiedSendProcessing.value = true
meshRelayActive.value = true
meshRelayStatus.value = 'Creating signed transaction...'
unifiedSendError.value = ''
try {
meshRelayStatus.value = 'Signing transaction locally...'
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
method: 'lnd.create-raw-tx',
params: { addr: unifiedSendDest.value.trim(), amount_sats: unifiedSendAmount.value },
})
meshRelayStatus.value = 'Sending via mesh radio to connected peers...'
const relayRes = await meshStore.relayTransaction(rawRes.raw_tx_hex)
meshRelayRequestId.value = relayRes.request_id
meshRelayStatus.value = 'Transaction sent via mesh -- waiting for broadcast confirmation...'
startMeshRelayPolling(relayRes.request_id)
} catch (err: unknown) {
meshRelayActive.value = false
meshRelayStatus.value = ''
unifiedSendError.value = err instanceof Error ? err.message : 'Mesh relay failed'
} finally {
unifiedSendProcessing.value = false
}
}
function dismissMeshRelayPrompt() {
showMeshRelayPrompt.value = false
}
let meshRelayPollTimer: ReturnType<typeof setInterval> | null = null
function startMeshRelayPolling(_requestId: number) {
if (meshRelayPollTimer) clearInterval(meshRelayPollTimer)
meshRelayPollTimer = setInterval(async () => {
await meshStore.fetchMessages()
const msgs = meshStore.messages
for (const msg of msgs) {
if (msg.direction !== 'received') continue
const text = msg.plaintext
if (text.includes(`[tx_relay_response]`) && text.includes('txid:')) {
const match = text.match(/txid:\s*(\w+)/)
if (match && match[1]) {
sendResultTxid.value = match[1]
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... -- waiting for confirmations`
}
}
if (text.includes('[tx_confirmation]')) {
const confMatch = text.match(/(\d)\/3 confirmations/)
if (confMatch && confMatch[1]) {
const confs = parseInt(confMatch[1])
meshRelayStatus.value = `${confs}/3 confirmations${confs >= 3 ? ' -- Transaction confirmed!' : '...'}`
if (confs >= 3) {
meshRelayActive.value = false
if (meshRelayPollTimer) {
clearInterval(meshRelayPollTimer)
meshRelayPollTimer = null
}
emit('balancesChanged')
}
}
}
}
}, 5000)
setTimeout(() => {
if (meshRelayPollTimer) {
clearInterval(meshRelayPollTimer)
meshRelayPollTimer = null
}
}, 3 * 60 * 60 * 1000)
}
async function finalizePsbt() {
if (!signedPsbtInput.value.trim() || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
try {
await rpcClient.finalizePsbt(signedPsbtInput.value.trim())
psbtStep.value = 'idle'
psbtData.value = ''
signedPsbtInput.value = ''
sendResultTxid.value = t('web5.broadcastViaHwWallet')
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
} finally {
unifiedSendProcessing.value = false
}
}
function copyPsbt() {
if (!psbtData.value) return
safeClipboardWrite(psbtData.value)
unifiedSendError.value = t('web5.psbtCopied')
}
function downloadPsbt() {
if (!psbtData.value) return
const blob = new Blob([psbtData.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transaction.psbt'
a.click()
URL.revokeObjectURL(url)
}
function handlePsbtFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
signedPsbtInput.value = (e.target?.result as string) || ''
}
reader.readAsText(file)
input.value = ''
}
async function unifiedReceive() {
if (unifiedReceiveProcessing.value) return
unifiedReceiveProcessing.value = true
unifiedReceiveError.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) {
unifiedReceiveError.value = t('web5.enterAmount')
return
}
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: receiveInvoiceAmount.value, memo: receiveInvoiceMemo.value },
})
receiveInvoiceResult.value = res.payment_request
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
if (!res.address) {
throw new Error('LND did not return a Bitcoin address')
}
receiveOnchainAddress.value = res.address
nextTick(() => renderQrCode(res.address, onchainQrCanvas.value))
} else {
if (!ecashReceiveToken.value.trim()) {
unifiedReceiveError.value = t('web5.pasteEcashToken')
return
}
const res = await rpcClient.call<{ received_sats: number; kind?: string }>({
method: 'wallet.ecash-receive',
params: { token: ecashReceiveToken.value.trim() },
})
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
ecashReceiveToken.value = ''
emit('balancesChanged')
}
} catch (err: unknown) {
unifiedReceiveError.value = receiveMethod.value === 'onchain'
? explainReceiveAddressFailure(err)
: err instanceof Error ? err.message : t('web5.receiveFailed')
} finally {
unifiedReceiveProcessing.value = false
}
}
async function renderQrCode(data: string, canvas: HTMLCanvasElement | null) {
if (!canvas || !data) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(canvas, `bitcoin:${data}`, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
} catch { /* QR rendering failed silently */ }
}
defineExpose({
openSend,
openReceive,
meshRelayActive,
meshRelayStatus,
sendResultTxid,
})
</script>
@@ -0,0 +1,634 @@
<template>
<!-- Shared Content -->
<div class="glass-card p-6">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
</div>
</div>
<div v-if="contentTab === 'mine'" class="flex items-center gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.contentDesc') }}</p>
<div v-if="contentTab === 'mine'" class="grid grid-cols-2 gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Browse Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Tabs: My Content | Browse Peers -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="contentTab = 'mine'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'mine' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.myContent') }}
<span v-if="contentItems.length > 0" class="ml-1.5 text-xs text-white/50">({{ contentItems.length }})</span>
</button>
<button
@click="contentTab = 'browse'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'browse' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.browsePeers') }}
</button>
</div>
<!-- My Content tab -->
<div v-show="contentTab === 'mine'">
<div v-if="contentLoading && contentItems.length === 0" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('common.loading') }}</p>
</div>
<div v-else-if="contentItems.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.noSharedContent') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.addFilesToShare') }}</p>
</div>
<div v-else class="space-y-3">
<div v-if="contentLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing shared content...
</div>
<div
v-for="(item, idx) in contentItems"
:key="item.id"
:class="{ 'card-stagger': showStagger }" class="p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p v-if="item.description" class="text-xs text-white/50 mt-0.5">{{ item.description }}</p>
<p class="text-xs text-white/40 mt-0.5">{{ item.mime_type }} &middot; {{ formatBytes(item.size_bytes) }}</p>
</div>
<button
@click="removeContentItem(item.id)"
:disabled="removingContentId === item.id"
class="p-2 rounded-lg text-white/40 hover:text-red-400 hover:bg-white/10 transition-colors shrink-0"
title="Remove"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<button
v-for="opt in accessOptions"
:key="opt.value"
@click="setContentPricing(item, opt.value)"
:disabled="updatingPricingId === item.id"
class="px-3 py-1 text-xs rounded-lg border transition-colors"
:class="getAccessType(item.access) === opt.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white/70'"
>
{{ opt.label }}
</button>
</div>
<div v-if="getAccessType(item.access) === 'paid'" class="flex items-center gap-3 mt-2">
<div class="flex items-center gap-2 flex-1">
<input
:value="getItemPrice(item.access)"
@change="updateItemPrice(item, ($event.target as HTMLInputElement).value)"
type="number"
min="1"
placeholder="100"
class="w-24 px-2 py-1 text-xs rounded-lg bg-white/5 border border-white/10 text-white focus:outline-none focus:border-white/30"
/>
<span class="text-xs text-white/50">sats</span>
</div>
<p class="text-xs text-orange-400/80">Peers will pay {{ getItemPrice(item.access) || 0 }} sats to access this</p>
</div>
<p v-else-if="getAccessType(item.access) === 'free'" class="text-xs text-green-400/70 mt-1">{{ t('web5.freeAccessDesc') }}</p>
<p v-else-if="getAccessType(item.access) === 'peers_only'" class="text-xs text-blue-400/70 mt-1">{{ t('web5.peersOnlyAccessDesc') }}</p>
</div>
</div>
</div>
<!-- Browse Peers tab -->
<div v-show="contentTab === 'browse'">
<div v-if="browsingPeerContent && peerContentItems.length === 0" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('web5.connectingToPeer') }}</p>
</div>
<div v-else-if="!browsePeerOnion && peerContentItems.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.selectPeerToBrowse') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.choosePeerDesc') }}</p>
</div>
<div v-else-if="peerContentItems.length === 0 && browsePeerOnion && !browsingPeerContent" class="py-6 text-center">
<p class="text-white/60 text-sm">{{ t('web5.peerNoContent') }}</p>
</div>
<div v-else class="space-y-2">
<div v-if="browsingPeerContent" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing peer content...
</div>
<div
v-for="(pItem, idx) in peerContentItems"
:key="pItem.id"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center shrink-0">
<svg v-if="isMediaType(pItem.mime_type)" class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ pItem.filename }}</p>
<p v-if="pItem.description" class="text-xs text-white/50 truncate">{{ pItem.description }}</p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-white/40">{{ pItem.mime_type }}</span>
<span class="text-xs text-white/30">&middot;</span>
<span class="text-xs text-white/40">{{ formatBytes(pItem.size_bytes) }}</span>
<span v-if="getItemPrice(pItem.access) > 0" class="text-xs text-orange-400 ml-1">{{ getItemPrice(pItem.access) }} sats</span>
<span v-else class="text-xs text-green-400/70 ml-1">Free</span>
</div>
</div>
<button
v-if="isMediaType(pItem.mime_type) && getItemPrice(pItem.access) === 0"
@click="streamPeerContent(pItem)"
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
>
{{ t('web5.stream') }}
</button>
<button
v-else-if="getItemPrice(pItem.access) > 0"
@click="purchaseAndDownload(pItem)"
:disabled="purchasingId === pItem.id"
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
>
<template v-if="purchasingId === pItem.id">
<div class="w-3 h-3 border-2 border-orange-400/30 border-t-orange-400 rounded-full animate-spin"></div>
Paying...
</template>
<template v-else>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Buy {{ getItemPrice(pItem.access) }} sats
</template>
</button>
<button
v-else
@click="downloadPeerContent(pItem)"
class="px-3 py-1.5 text-xs rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors shrink-0"
>
{{ t('web5.download') }}
</button>
</div>
</div>
</div>
</div>
<!-- Content Streaming Player -->
<Teleport to="body">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
<div class="glass-card p-0 w-full max-w-2xl overflow-hidden" role="dialog" aria-modal="true">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ streamingItem.filename }}</p>
<p class="text-xs text-white/50">{{ streamingItem.mime_type }}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div v-if="streamCostSats > 0" class="flex items-center gap-1 px-2 py-1 rounded bg-orange-500/20">
<span class="text-xs text-orange-400 font-medium">{{ streamCostSats }} sats</span>
</div>
<button @click="closePlayer" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="p-4">
<div v-if="streamingItem.mime_type.startsWith('audio/')">
<audio ref="audioPlayerRef" :src="streamUrl" controls class="w-full" @timeupdate="onPlayerTimeUpdate" @error="onPlayerError"></audio>
</div>
<div v-else-if="streamingItem.mime_type.startsWith('video/')">
<video ref="videoPlayerRef" :src="streamUrl" controls class="w-full rounded-lg max-h-[60vh]" @timeupdate="onPlayerTimeUpdate" @error="onPlayerError"></video>
</div>
<div v-if="playerError" class="mt-3 alert-error">
<p>{{ playerError }}</p>
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
</div>
<div class="flex items-center justify-between mt-3">
<div class="text-xs text-white/40">
{{ formatBytes(streamingItem.size_bytes) }}
<span v-if="streamProgress > 0"> &middot; {{ Math.round(streamProgress * 100) }}% streamed</span>
</div>
<button @click="copyStreamUrl" class="text-xs text-white/50 hover:text-white transition-colors">Copy URL</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal -->
<Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="add-content-title">
<h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Filename</label>
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">MIME Type</label>
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Description (optional)</label>
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-2">Access</label>
<div class="flex gap-2">
<button
v-for="opt in accessOptions"
:key="opt.value"
@click="newContentAccess = opt.value"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="newContentAccess === opt.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'"
>{{ opt.label }}</button>
</div>
</div>
<div v-if="newContentAccess === 'paid'">
<label class="text-white/60 text-sm block mb-1">Price (sats)</label>
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full input-glass" />
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
</div>
</div>
<div v-if="addContentError" class="mt-3 alert-error">
<p class="text-xs">{{ addContentError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ addingContent ? 'Adding...' : 'Add' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { formatBytes, isMediaType, getAccessType, getItemPrice, safeClipboardWrite } from './utils'
import type { ContentItemData, PeerContentItem, Peer } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
peers: Peer[]
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
const contentItems = ref<ContentItemData[]>([])
const contentLoading = ref(false)
const contentTab = ref<'mine' | 'browse'>('mine')
const showAddContentModal = ref(false)
const newContentFilename = ref('')
const newContentMimeType = ref('application/octet-stream')
const newContentDescription = ref('')
const newContentAccess = ref<'free' | 'peers_only' | 'paid'>('free')
const newContentPrice = ref<number>(100)
const addingContent = ref(false)
const addContentError = ref('')
const removingContentId = ref<string | null>(null)
const updatingPricingId = ref<string | null>(null)
const accessOptions = [
{ value: 'free' as const, label: 'Free' },
{ value: 'peers_only' as const, label: 'Peers Only' },
{ value: 'paid' as const, label: 'Paid' },
]
// Browse peers
const browsePeerOnion = ref('')
const activeBrowsePeerOnion = ref('')
const browsingPeerContent = ref(false)
const browsePeerError = ref('')
const peerContentItems = ref<PeerContentItem[]>([])
// Purchase flow
const purchasingId = ref<string | null>(null)
// Streaming player
const streamingItem = ref<PeerContentItem | null>(null)
const streamUrl = ref('')
const streamCostSats = ref(0)
const streamProgress = ref(0)
const playerError = ref('')
const audioPlayerRef = ref<HTMLAudioElement | null>(null)
const videoPlayerRef = ref<HTMLVideoElement | null>(null)
async function loadContentItems() {
contentLoading.value = true
try {
const res = await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })
contentItems.value = res.items || []
} catch {
if (contentItems.value.length === 0) contentItems.value = []
} finally {
contentLoading.value = false
}
}
async function addContentItem() {
if (addingContent.value || !newContentFilename.value.trim()) return
addingContent.value = true
addContentError.value = ''
try {
await rpcClient.call({
method: 'content.add',
params: {
filename: newContentFilename.value.trim(),
mime_type: newContentMimeType.value.trim() || 'application/octet-stream',
description: newContentDescription.value.trim(),
},
})
if (newContentAccess.value !== 'free') {
const items = (await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })).items || []
const latest = items[items.length - 1]
if (latest) {
await rpcClient.call({
method: 'content.set-pricing',
params: {
id: latest.id,
access: newContentAccess.value,
...(newContentAccess.value === 'paid' ? { price_sats: newContentPrice.value || 100 } : {}),
},
})
}
}
showAddContentModal.value = false
newContentFilename.value = ''
newContentMimeType.value = 'application/octet-stream'
newContentDescription.value = ''
newContentAccess.value = 'free'
newContentPrice.value = 100
await loadContentItems()
emit('toast', t('web5.contentAdded'))
} catch (err: unknown) {
addContentError.value = err instanceof Error ? err.message : t('web5.failedToAddContent')
} finally {
addingContent.value = false
}
}
async function removeContentItem(id: string) {
removingContentId.value = id
try {
await rpcClient.call({ method: 'content.remove', params: { id } })
contentItems.value = contentItems.value.filter(i => i.id !== id)
emit('toast', t('web5.contentRemoved'))
} catch {
emit('toast', t('web5.failedToRemoveContent'))
} finally {
removingContentId.value = null
}
}
async function setContentPricing(item: ContentItemData, access: 'free' | 'peers_only' | 'paid') {
updatingPricingId.value = item.id
try {
const params: Record<string, unknown> = { id: item.id, access }
if (access === 'paid') {
params.price_sats = getItemPrice(item.access) || 100
}
await rpcClient.call({ method: 'content.set-pricing', params })
await loadContentItems()
} catch {
emit('toast', t('web5.failedToUpdatePricing'))
} finally {
updatingPricingId.value = null
}
}
async function updateItemPrice(item: ContentItemData, value: string) {
const price = parseInt(value, 10)
if (!price || price <= 0) return
updatingPricingId.value = item.id
try {
await rpcClient.call({
method: 'content.set-pricing',
params: { id: item.id, access: 'paid', price_sats: price },
})
await loadContentItems()
} catch {
emit('toast', t('web5.failedToUpdatePrice'))
} finally {
updatingPricingId.value = null
}
}
async function browsePeerContent() {
if (!browsePeerOnion.value || browsingPeerContent.value) return
const isRefreshingActivePeer = browsePeerOnion.value === activeBrowsePeerOnion.value
browsingPeerContent.value = true
browsePeerError.value = ''
if (!isRefreshingActivePeer) peerContentItems.value = []
try {
const res = await rpcClient.call<{ items: PeerContentItem[] }>({
method: 'content.browse-peer',
params: { onion: browsePeerOnion.value },
})
activeBrowsePeerOnion.value = browsePeerOnion.value
peerContentItems.value = res.items || []
} catch (err: unknown) {
browsePeerError.value = err instanceof Error ? err.message : t('web5.failedToConnectPeer')
} finally {
browsingPeerContent.value = false
}
}
function streamPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
streamingItem.value = item
streamUrl.value = `http://${browsePeerOnion.value}/content/${item.id}`
streamCostSats.value = getItemPrice(item.access)
streamProgress.value = 0
playerError.value = ''
}
function downloadPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
const url = `http://${browsePeerOnion.value}/content/${item.id}`
emit('toast', t('web5.downloadUrlCopied'))
safeClipboardWrite(url)
}
async function purchaseAndDownload(item: PeerContentItem) {
if (!browsePeerOnion.value || purchasingId.value) return
const price = getItemPrice(item.access)
if (price <= 0) return
purchasingId.value = item.id
try {
// Check balance first
try {
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
const balance = balRes?.balance_sats ?? 0
if (balance < price) {
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
return
}
} catch {
// Balance check failed — try the purchase anyway
}
const result = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-paid',
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
timeout: 120000,
})
if (result?.data) {
const blob = new Blob(
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))],
{ type: item.mime_type },
)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = item.filename.split('/').pop() || item.filename
a.click()
URL.revokeObjectURL(url)
emit('toast', `Downloaded for ${price} sats`)
} else {
emit('toast', 'Purchase failed — no data received')
}
} catch (e: unknown) {
emit('toast', e instanceof Error ? e.message : 'Purchase failed')
} finally {
purchasingId.value = null
}
}
function closePlayer() {
if (audioPlayerRef.value) {
audioPlayerRef.value.pause()
audioPlayerRef.value.src = ''
}
if (videoPlayerRef.value) {
videoPlayerRef.value.pause()
videoPlayerRef.value.src = ''
}
streamingItem.value = null
streamUrl.value = ''
streamProgress.value = 0
playerError.value = ''
}
function onPlayerTimeUpdate() {
const player = audioPlayerRef.value || videoPlayerRef.value
if (player && player.duration > 0) {
streamProgress.value = player.currentTime / player.duration
}
}
function onPlayerError() {
playerError.value = t('web5.playerError')
}
function copyStreamUrl() {
if (streamUrl.value) {
safeClipboardWrite(streamUrl.value)
emit('toast', t('web5.streamUrlCopied'))
}
}
defineExpose({ loadContentItems })
</script>
+184
View File
@@ -0,0 +1,184 @@
<template>
<!-- Wallet -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
</div>
<!-- Transaction Activity Badge -->
<button
v-if="txActivityCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span v-if="incomingTxCount > 0">Incoming {{ incomingTxCount }}</span>
<span v-if="meshRelayActive" class="ml-1">Mesh TX</span>
<span class="incoming-tx-ping"></span>
</button>
</div>
<!-- Transaction Activity Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && (incomingTransactions.length > 0 || meshRelayActive)" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<!-- Mesh Relay TX (outgoing via mesh) -->
<div v-if="meshRelayActive" class="incoming-tx-row">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" style="background: rgba(251,146,60,0.15);">
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-orange-400">Mesh Relay</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">
{{ sendResultTxid ? 'Broadcast' : 'Sending...' }}
</span>
</div>
<p class="text-[11px] text-white/40 mt-0.5">{{ meshRelayStatus }}</p>
</div>
</div>
</div>
<!-- Incoming TXs -->
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="incoming-tx-row"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" :class="tx.num_confirmations === 0 ? 'incoming-tx-icon-pending' : 'incoming-tx-icon-confirmed'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</div>
</transition>
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
<div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold"></span>
<span class="text-white/80 text-sm">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ lndOnchainBalance.toLocaleString() }} sats</span>
</div>
<!-- Lightning Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ lndChannelBalance.toLocaleString() }} sats</span>
</div>
<!-- Ecash Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.ecash') }}</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ ecashBalance.toLocaleString() }} sats</span>
</div>
</div>
<!-- Action buttons -->
<div class="grid grid-cols-2 gap-2 mt-auto pt-4 shrink-0">
<button
@click="$emit('openSend')"
:disabled="!walletConnected && ecashBalance <= 0"
class="px-3 py-2 glass-button rounded-lg text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ t('common.send') }}
</button>
<button
@click="$emit('openReceive')"
class="px-3 py-2 glass-button rounded-lg text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.receiveBitcoin') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatTxTime } from './utils'
import type { WalletTransaction } from './types'
import { useAppLauncherStore } from '@/stores/appLauncher'
const { t } = useI18n()
const showIncomingTxPanel = ref(false)
defineProps<{
showStagger: boolean
walletConnected: boolean
walletError: string
lndOnchainBalance: number
lndChannelBalance: number
ecashBalance: number
incomingTransactions: WalletTransaction[]
incomingTxCount: number
txActivityCount: number
meshRelayActive: boolean
meshRelayStatus: string
sendResultTxid: string
}>()
defineEmits<{
openSend: []
openReceive: []
}>()
function openInMempool(txHash: string) {
// Overlay the explorer above the current page — never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` })
}
</script>
@@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(),
},
}))
vi.mock('@/composables/useMessageToast', () => ({
useMessageToast: () => ({
loadReceivedMessages: vi.fn(),
}),
}))
vi.mock('@/stores/web5Badge', () => ({
useWeb5BadgeStore: () => ({ pendingRequestCount: 0 }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ peerHealth: {} }),
}))
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
describe('Web5ConnectedNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
const wrapper = mount(Web5ConnectedNodes)
wrapper.vm.loadPeers()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('common.loading')
expect(wrapper.text()).not.toContain('web5.noPeers')
})
it('keeps connection requests visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
requests: [{
id: 'req-1',
from_did: 'did:key:alice',
from_pubkey: 'alice-pubkey',
message: 'Please connect',
created_at: '2026-06-10T10:00:00Z',
}],
})
const wrapper = mount(Web5ConnectedNodes)
await (wrapper.vm as unknown as { loadConnectionRequests: () => Promise<void> }).loadConnectionRequests()
expect(wrapper.text()).toContain('Please connect')
vi.mocked(rpcClient.call).mockReturnValueOnce(new Promise((_, reject) => {
setTimeout(() => reject(new Error('offline')), 0)
}))
const refresh = (wrapper.vm as unknown as { loadConnectionRequests: () => Promise<void> }).loadConnectionRequests()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Please connect')
expect(wrapper.text()).toContain('common.loading')
await refresh
expect(wrapper.text()).toContain('Please connect')
})
})
@@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Web5CredentialsSummary from '../Web5CredentialsSummary.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeCredential() {
return {
id: 'cred-one',
issuer: 'did:key:issuer',
subject: 'did:key:subject',
type: 'NodeOperator',
claims: {},
issued_at: '2026-06-10T10:00:00Z',
expires_at: null,
status: 'active',
}
}
describe('Web5CredentialsSummary', () => {
it('keeps credential rows visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ credentials: [makeCredential()] })
const wrapper = mount(Web5CredentialsSummary, {
props: { identityCount: 1 },
global: {
stubs: {
RouterLink: true,
},
},
})
await (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
await flushPromises()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('did:key:subject')
const pending = deferred<{ credentials: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('Refreshing credentials...')
expect(wrapper.text()).not.toContain('Loading credentials...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing credentials...')
})
})
@@ -0,0 +1,79 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Web5Domains from '../Web5Domains.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeDomain() {
return {
id: 'name-one',
name: 'satoshi',
domain: 'example.com',
nip05: 'satoshi@example.com',
identity_id: 'identity-one',
did: 'did:key:identity',
nostr_pubkey: null,
status: 'active',
registered_at: '2026-06-10T10:00:00Z',
expires_at: null,
}
}
describe('Web5Domains', () => {
it('keeps registered names visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ names: [makeDomain()] })
const wrapper = mount(Web5Domains, {
props: { showStagger: false, managedIdentities: [] },
global: {
stubs: {
Teleport: true,
},
},
})
await (wrapper.vm as unknown as { loadDomainNames: () => Promise<void> }).loadDomainNames()
await flushPromises()
expect(wrapper.text()).toContain('1 name')
expect(wrapper.text()).toContain('1 Active')
const pending = deferred<{ names: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadDomainNames: () => Promise<void> }).loadDomainNames()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('1 name')
expect(wrapper.text()).toContain('1 Active')
expect(wrapper.text()).toContain('Refreshing domains...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('1 name')
expect(wrapper.text()).toContain('1 Active')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing domains...')
})
})
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import Web5Federation from '../Web5Federation.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({ nodes: [], pending_requests: [] }),
getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:test' }),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function mountFederation() {
return mount(Web5Federation, {
global: {
stubs: {
RouterLink: {
props: ['to'],
template: '<a :href="to"><slot /></a>',
},
},
},
})
}
describe('Web5Federation', () => {
it('surfaces Find Nodes and Fleet routes', () => {
const wrapper = mountFederation()
const links = wrapper.findAll('a').map(link => link.attributes('href'))
expect(links).toContain('/dashboard/server/federation')
expect(links).toContain('/dashboard/fleet')
expect(wrapper.text()).toContain('Find Nodes')
expect(wrapper.text()).toContain('Fleet')
})
it('shows federation refresh state without replacing existing counts', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
nodes: [{ status: 'online' }, { status: 'offline' }],
pending_requests: [{}],
})
vi.mocked(rpcClient.getNodeDid).mockResolvedValueOnce({ did: 'did:key:node', pubkey: 'node-pubkey' })
const wrapper = mountFederation()
await flushPromises()
expect(wrapper.text()).toContain('Known Nodes')
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('did:key:node')
const pending = deferred<{ nodes: []; pending_requests: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
vi.mocked(rpcClient.getNodeDid).mockResolvedValueOnce({ did: 'did:key:node', pubkey: 'node-pubkey' })
const refresh = (wrapper.vm as unknown as { loadFederationSummary: () => Promise<void> }).loadFederationSummary()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('Refreshing federation...')
expect(wrapper.text()).not.toContain('Loading federation...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing federation...')
})
})
@@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Web5Identities from '../Web5Identities.vue'
import { rpcClient } from '@/api/rpc-client'
import type { ManagedIdentity } from '../types'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
vi.mock('../utils', () => ({
safeClipboardWrite: vi.fn(),
}))
function makeIdentity(name: string): ManagedIdentity {
return {
id: name,
name,
purpose: 'personal',
pubkey: `${name}-pubkey`,
did: `did:key:${name}`,
created_at: '2026-06-10T10:00:00Z',
is_default: true,
profile: {},
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('Web5Identities', () => {
it('keeps identities visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
identities: [makeIdentity('Personal')],
})
const wrapper = mount(Web5Identities, {
props: { showStagger: false },
})
await (wrapper.vm as unknown as { loadIdentities: () => Promise<void> }).loadIdentities()
await flushPromises()
expect(wrapper.text()).toContain('Personal')
const pending = deferred<{ identities: ManagedIdentity[] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadIdentities: () => Promise<void> }).loadIdentities()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Personal')
expect(wrapper.text()).toContain('Refreshing identities...')
expect(wrapper.text()).not.toContain('common.loading')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Personal')
expect(wrapper.text()).not.toContain('Refreshing identities...')
})
})
@@ -0,0 +1,84 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Web5NostrRelays from '../Web5NostrRelays.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function mockRelayCalls() {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'nostr.list-relays') {
return Promise.resolve({
relays: [{ url: 'wss://relay.example', connected: true, enabled: true, added_at: '2026-06-10T10:00:00Z' }],
})
}
if (request.method === 'nostr.get-stats') {
return Promise.resolve({ connected_count: 2, total_relays: 3, enabled_count: 1 })
}
return Promise.resolve({})
})
}
describe('Web5NostrRelays', () => {
it('keeps relay stats visible while refresh is pending or fails', async () => {
mockRelayCalls()
const wrapper = mount(Web5NostrRelays, {
props: { showStagger: false },
global: {
stubs: {
Teleport: true,
},
},
})
await (wrapper.vm as unknown as { loadNostrRelays: () => Promise<void> }).loadNostrRelays()
await flushPromises()
expect(wrapper.text()).toContain('2 active')
expect(wrapper.text()).toContain('3 configured')
const pending = deferred<{ relays: [] }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'nostr.list-relays') return pending.promise
if (request.method === 'nostr.get-stats') {
return Promise.resolve({ connected_count: 0, total_relays: 0, enabled_count: 0 })
}
return Promise.resolve({})
})
const refresh = (wrapper.vm as unknown as { loadNostrRelays: () => Promise<void> }).loadNostrRelays()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('2 active')
expect(wrapper.text()).toContain('3 configured')
expect(wrapper.text()).toContain('Refreshing relays...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('2 active')
expect(wrapper.text()).toContain('3 configured')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing relays...')
})
})
@@ -0,0 +1,118 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Web5SharedContent from '../Web5SharedContent.vue'
import { rpcClient } from '@/api/rpc-client'
import type { ContentItemData, PeerContentItem } from '../types'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function makePeerItem(filename: string): PeerContentItem {
return {
id: filename,
filename,
mime_type: 'text/plain',
size_bytes: 128,
description: '',
access: 'free',
}
}
function makeContentItem(filename: string): ContentItemData {
return {
...makePeerItem(filename),
added_at: '2026-06-10T10:00:00Z',
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function getButtonByText(wrapper: ReturnType<typeof mount>, text: string) {
const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text)
if (!button) throw new Error(`Button not found: ${text}`)
return button
}
describe('Web5SharedContent', () => {
it('keeps my content visible while a refresh is pending or fails', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({ items: [makeContentItem('notes.txt')] })
const wrapper = mount(Web5SharedContent, {
props: {
showStagger: false,
peers: [],
},
})
await (wrapper.vm as unknown as { loadContentItems: () => Promise<void> }).loadContentItems()
await flushPromises()
expect(wrapper.text()).toContain('notes.txt')
const pending = deferred<{ items: ContentItemData[] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadContentItems: () => Promise<void> }).loadContentItems()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('notes.txt')
expect(wrapper.text()).toContain('Refreshing shared content...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('notes.txt')
expect(wrapper.text()).not.toContain('Refreshing shared content...')
})
it('keeps peer content visible while refreshing the same peer tab', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({ items: [makePeerItem('episode-one.txt')] })
const wrapper = mount(Web5SharedContent, {
props: {
showStagger: false,
peers: [{ onion: 'peer-a.onion', pubkey: 'peer-a', name: 'Peer A' }],
},
})
await getButtonByText(wrapper, 'web5.browsePeers').trigger('click')
await wrapper.get('select').setValue('peer-a.onion')
await getButtonByText(wrapper, 'web5.browse').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('episode-one.txt')
const pending = deferred<{ items: PeerContentItem[] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
await getButtonByText(wrapper, 'web5.browse').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('episode-one.txt')
expect(wrapper.text()).toContain('Refreshing peer content...')
pending.resolve({ items: [makePeerItem('episode-two.txt')] })
await flushPromises()
expect(wrapper.text()).toContain('episode-two.txt')
expect(wrapper.text()).not.toContain('Refreshing peer content...')
})
})
+162
View File
@@ -0,0 +1,162 @@
// Shared types for Web5 subcomponents
export interface ProfitsData {
total_sats: number
content_sales_sats: number
routing_fees_sats: number
}
export interface RegisteredNameData {
id: string
name: string
domain: string
nip05: string
identity_id: string
did: string
nostr_pubkey: string | null
status: string
registered_at: string
expires_at: string | null
}
export interface Nip05Result {
name: string
domain: string
nostr_pubkey: string | null
relays: string[]
verified: boolean
}
export interface VCData {
id: string
issuer: string
subject: string
type: string
claims: Record<string, unknown>
issued_at: string
expires_at: string | null
status: string
}
export interface NostrRelayData {
url: string
connected: boolean
enabled: boolean
added_at: string
}
export interface NostrRelayStatsData {
total_relays: number
connected_count: number
enabled_count: number
}
export interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
export interface HwWalletDevice {
type: string
vendor_id: string
product_id: string
manufacturer: string
product: string
}
export interface ContentItemData {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
added_at: string
}
export interface PeerContentItem {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
}
export interface ConnectionRequest {
id: string
from_did: string
from_onion?: string
from_pubkey?: string
message?: string
created_at: string
}
export interface IdentityProfile {
display_name?: string
about?: string
picture?: string
banner?: string
website?: string
nip05?: string
lud16?: string
}
export interface ManagedIdentity {
id: string
name: string
purpose: string
pubkey: string
did: string
created_at: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
profile?: IdentityProfile
}
export interface DwnStatusData {
running: boolean
version: string
sync_status: string
last_sync: string | null
messages_synced: number
storage_bytes: number
message_count: number
protocol_count: number
registered_protocols: string[]
peer_sync_targets: string[]
}
export interface DwnProtocol {
protocol: string
published: boolean
types: Record<string, unknown>
structure: Record<string, unknown>
dateRegistered: string
}
export interface DwnMessageEntry {
record_id: string
author: string
date_created: string
descriptor: {
interface: string
method: string
protocol?: string
schema?: string
dataFormat?: string
}
data?: unknown
}
export type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
export type Peer = { onion: string; pubkey: string; name?: string; did?: string }
+76
View File
@@ -0,0 +1,76 @@
// Shared utility functions for Web5 subcomponents
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
export function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function formatMessageTime(ts: string): string {
try {
const d = new Date(ts)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return d.toLocaleDateString()
} catch {
return ts
}
}
export async function safeClipboardWrite(text: string): Promise<void> {
// navigator.clipboard is unavailable on HTTP (non-secure contexts)
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
} catch { /* fall through to textarea fallback */ }
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
export function isMediaType(mime: string): boolean {
return mime.startsWith('audio/') || mime.startsWith('video/')
}
export function getAccessType(access: string | { paid: { price_sats: number } }): 'free' | 'peers_only' | 'paid' {
if (typeof access === 'string') {
if (access === 'peersonly' || access === 'peers_only') return 'peers_only'
if (access === 'paid') return 'paid'
return 'free'
}
if (access && typeof access === 'object' && 'paid' in access) return 'paid'
return 'free'
}
export function getItemPrice(access: string | { paid: { price_sats: number } }): number {
if (typeof access === 'object' && access && 'paid' in access) {
return access.paid.price_sats
}
return 0
}