2026-01-24 22:59:20 +00:00
< template >
2026-03-14 17:12:41 +00:00
< div class = "pb-6" >
2026-01-24 22:59:20 +00:00
<!-- Quick Actions Container -- >
< div class = "glass-card p-6 mb-6" >
2026-03-14 17:12:41 +00:00
< div class = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid" >
2026-01-24 22:59:20 +00:00
<!-- Networking Profits -- >
2026-03-09 07:43:12 +00:00
< div data -controller -container tabindex = "0" class = "card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style = "--stagger-index: 0" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-center gap-3 min-w-0" >
< div class = "relative shrink-0" >
2026-01-24 22:59:20 +00:00
< span class = "text-2xl text-orange-500 font-bold" > ₿ < / span >
< / div >
2026-02-17 15:03:34 +00:00
< div class = "min-w-0" >
2026-03-11 13:45:59 +00:00
< p class = "text-sm font-medium text-white" > { { t ( 'web5.networkingProfits' ) } } < / p >
2026-03-09 07:43:12 +00:00
< p class = "text-xs text-orange-500 font-medium" > { { networkingProfitsDisplay } } < / p >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-09 07:43:12 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
<!-- DID Status -- >
2026-03-09 07:43:12 +00:00
< div data -controller -container tabindex = "0" class = "card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style = "--stagger-index: 1" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-center gap-3 min-w-0" >
< div class = "relative shrink-0" >
2026-01-24 22:59:20 +00:00
< 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 >
2026-02-17 15:03:34 +00:00
< div class = "min-w-0 flex-1" >
2026-03-11 13:45:59 +00:00
< p class = "text-sm font-medium text-white" > { { t ( 'web5.didStatus' ) } } < / p >
2026-02-17 15:03:34 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-11 13:11:45 +00:00
< div v-if = "userDid" class="flex gap-2" >
< button
@ click = "copyDid"
class = "px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
2026-03-11 13:45:59 +00:00
{ { didCopied ? t ( 'common.copiedBang' ) : t ( 'web5.copyDid' ) } }
2026-03-11 13:11:45 +00:00
< / button >
< button
@ click = "showDidDocument"
class = "px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.viewDidDocument' ) } }
2026-03-11 13:11:45 +00:00
< / button >
< / div >
2026-03-05 08:14:47 +00:00
< button
v - else
@ click = "createDID"
: disabled = "creatingDid"
class = "w-fit 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"
>
2026-03-11 13:45:59 +00:00
{ { creatingDid ? t ( 'web5.creatingDid' ) : t ( 'web5.createDid' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
2026-03-14 04:08:21 +00:00
<!-- did : dht Status -- >
< div data -controller -container tabindex = "0" class = "card-stagger 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" >
< button
@ click = "copyDhtDid"
class = "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 = "refreshDhtDid"
: disabled = "publishingDht"
class = "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 = "publishDhtDid"
: disabled = "publishingDht"
class = "w-fit 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 >
2026-01-24 22:59:20 +00:00
<!-- Wallet Connection -- >
2026-03-09 07:43:12 +00:00
< div data -controller -container tabindex = "0" class = "card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style = "--stagger-index: 2" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-center gap-3 min-w-0" >
< div class = "relative shrink-0" >
2026-01-24 22:59:20 +00:00
< 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 >
2026-02-17 15:03:34 +00:00
< div class = "min-w-0" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
< button
@ click = "connectWallet"
2026-02-17 15:03:34 +00:00
class = "w-fit 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"
2026-01-24 22:59:20 +00:00
: disabled = "connectingWallet"
>
2026-03-11 13:45:59 +00:00
{ { connectingWallet ? t ( 'common.connecting' ) : walletConnected ? t ( 'common.disconnect' ) : t ( 'common.connect' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
<!-- Nostr Relay Status -- >
2026-03-09 07:43:12 +00:00
< div data -controller -container tabindex = "0" class = "card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style = "--stagger-index: 3" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-center gap-3 min-w-0" >
< div class = "relative shrink-0" >
2026-03-09 07:43:12 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-02-17 15:03:34 +00:00
< div class = "min-w-0" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
< button
@ click = "manageRelays"
2026-02-17 15:03:34 +00:00
class = "w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
2026-01-24 22:59:20 +00:00
>
2026-03-11 13:45:59 +00:00
{ { t ( 'common.manage' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
2026-02-17 15:03:34 +00:00
<!-- Connected Nodes -- >
2026-03-09 07:43:12 +00:00
< div data -controller -container tabindex = "0" class = "card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style = "--stagger-index: 4" >
2026-02-17 15:03:34 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-02-17 15:03:34 +00:00
< / div >
< / div >
2026-03-14 04:08:21 +00:00
< div class = "flex gap-2" >
< 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 = "showSendMessageModal = true"
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.sendMessage' ) } }
< / button >
< / div >
2026-02-17 15:03:34 +00:00
< / div >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-11 13:11:45 +00:00
<!-- Hardware Wallet Detected Banner -- >
< div v-if = "detectedHwWallets.length > 0" class="mb-6 p-4 bg-orange-500/10 border border-orange-500/20 rounded-xl 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" >
2026-03-11 13:45:59 +00:00
< p class = "text-sm font-medium text-orange-400" > { { t ( 'web5.hardwareWalletDetected' ) } } < / p >
2026-03-11 13:11:45 +00:00
< p class = "text-xs text-white/60" >
{ { detectedHwWallets . map ( d => ` ${ d . type } ${ d . product ? ' (' + d . product + ')' : '' } ` ) . join ( ', ' ) } }
< / p >
< / div >
< / div >
<!-- DID Document Modal -- >
< Teleport to = "body" >
2026-03-14 17:12:41 +00:00
< 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 " >
2026-03-11 13:11:45 +00:00
< div class = "flex items-center justify-between mb-4" >
2026-03-11 13:45:59 +00:00
< h3 id = "did-doc-title" class = "text-lg font-semibold text-white" > { { t ( 'web5.didDocument' ) } } < / h3 >
2026-03-11 13:11:45 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.verified' ) } }
2026-03-11 13:11:45 +00:00
< / span >
2026-03-11 13:45:59 +00:00
< span v -else -if = " didDocVerified = = = false " class = "text-xs text-red-400" > { { t ( 'web5.invalid' ) } } < / span >
2026-03-11 13:11:45 +00:00
< / div >
< / div >
2026-03-11 13:45:59 +00:00
< div v-if = "loadingDidDoc" class="text-white/60 text-sm" > {{ t ( ' common.loading ' ) }} < / div >
2026-03-11 13:11:45 +00:00
< 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"
>
2026-03-11 13:45:59 +00:00
{ { didDocCopied ? t ( 'common.copiedBang' ) : t ( 'common.copy' ) } }
2026-03-11 13:11:45 +00:00
< / 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'common.close' ) } }
2026-03-11 13:11:45 +00:00
< / button >
< / div >
< / div >
< / div >
< / Teleport >
2026-02-17 15:03:34 +00:00
<!-- Send Message Modal -- >
< Teleport to = "body" >
2026-03-14 17:12:41 +00:00
< 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()" >
2026-03-14 19:08:09 +00:00
< div ref = "sendMessageModalRef" class = "glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto" >
2026-03-11 13:45:59 +00:00
< h3 class = "text-lg font-semibold text-white mb-4" > { { t ( 'web5.sendMessageTitle' ) } } < / h3 >
2026-02-17 15:03:34 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" > { { t ( 'web5.to' ) } } < / label >
2026-02-17 15:03:34 +00:00
< 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"
>
2026-03-11 13:45:59 +00:00
< option value = "" > { { t ( 'web5.selectPeer' ) } } < / option >
2026-02-17 15:03:34 +00:00
< option v-for = "p in peers" :key="p.pubkey" :value="p.onion" >
2026-03-14 17:12:41 +00:00
{ { p . name || p . onion || ( p . pubkey || '' ) . slice ( 0 , 12 ) + '...' } }
2026-02-17 15:03:34 +00:00
< / option >
< / select >
< / div >
< div >
2026-03-11 13:45:59 +00:00
< label class = "block text-sm font-medium text-white/80 mb-2" > { { t ( 'web5.message' ) } } < / label >
2026-02-17 15:03:34 +00:00
< 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"
2026-03-11 13:45:59 +00:00
: placeholder = "t('web5.messagePlaceholder')"
2026-02-17 15:03:34 +00:00
> < / 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"
>
2026-03-11 13:45:59 +00:00
{ { sendingMessage ? t ( 'common.sending' ) : t ( 'common.send' ) } }
2026-02-17 15:03:34 +00:00
< / button >
< button
2026-02-17 22:10:38 +00:00
@ click = "closeSendMessageModal()"
2026-02-17 15:03:34 +00:00
class = "px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'common.cancel' ) } }
2026-02-17 15:03:34 +00:00
< / 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 >
2026-03-14 17:12:41 +00:00
<!-- Core Services Overview Cards — Row 1 -- >
< div class = "flex flex-col md:flex-row gap-6 mb-6" >
2026-01-24 22:59:20 +00:00
<!-- Bitcoin Domain Name Portfolio -- >
2026-03-14 17:12:41 +00:00
< div data -controller -container tabindex = "0" class = "glass-card card-stagger p-6 flex flex-col md:w-1/2" style = "--stagger-index: 0" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-start gap-4 mb-4 shrink-0" >
2026-01-24 22:59:20 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-02-17 15:03:34 +00:00
< div class = "space-y-3 flex-1 min-h-0" >
2026-01-24 22:59:20 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.namesRegistered' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-white/60 text-sm" > { { registeredNames . length } } { { registeredNames . length === 1 ? 'name' : 'names' } } < / span >
2026-01-24 22:59:20 +00:00
< / 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 >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'common.status' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span : class = "activeNamesCount > 0 ? 'text-green-400' : 'text-white/60'" class = "text-sm font-medium" >
{ { activeNamesCount > 0 ? ` ${ activeNamesCount } Active ` : 'None' } }
< / span >
2026-01-24 22:59:20 +00:00
< / 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" >
2026-03-09 07:43:12 +00:00
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.expiringSoon' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-white/60 text-sm" > { { expiringNamesCount } } { { expiringNamesCount === 1 ? 'name' : 'names' } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.manageDomains' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
2026-03-09 07:43:12 +00:00
<!-- Wallet -- >
2026-03-14 17:12:41 +00:00
< div data -controller -container tabindex = "0" class = "glass-card card-stagger p-6 flex flex-col md:w-1/2" style = "--stagger-index: 1" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-start gap-4 mb-4 shrink-0" >
2026-01-24 22:59:20 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-02-17 15:03:34 +00:00
< div class = "space-y-3 flex-1 min-h-0" >
2026-03-09 07:43:12 +00:00
<!-- On - chain Balance -- >
2026-01-24 22:59:20 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.onChain' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-orange-500 text-sm font-medium" > { { lndOnchainBalance . toLocaleString ( ) } } sats < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Lightning Balance -- >
2026-01-24 22:59:20 +00:00
< div class = "flex items-center justify-between p-3 bg-white/5 rounded-lg" >
< div class = "flex items-center gap-3" >
2026-03-09 07:43:12 +00:00
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.lightning' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-yellow-400 text-sm font-medium" > { { lndChannelBalance . toLocaleString ( ) } } sats < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Ecash Balance -- >
2026-01-24 22:59:20 +00:00
< div class = "flex items-center justify-between p-3 bg-white/5 rounded-lg" >
< div class = "flex items-center gap-3" >
2026-03-09 07:43:12 +00:00
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.ecash' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-purple-400 text-sm font-medium" > { { ecashBalance . toLocaleString ( ) } } sats < / span >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-09 07:43:12 +00:00
<!-- Action buttons -- >
< div class = "grid grid-cols-2 gap-2 mt-auto pt-4 shrink-0" >
< button
@ click = "showUnifiedSendModal = true"
: 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'common.send' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< button
@ click = "showUnifiedReceiveModal = true"
class = "px-3 py-2 glass-button rounded-lg text-xs font-medium text-white/90 hover:text-white transition-colors"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.receiveBitcoin' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< / div >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-14 17:12:41 +00:00
< / div >
2026-01-24 22:59:20 +00:00
2026-03-14 17:12:41 +00:00
<!-- Core Services Overview Cards — Row 2 -- >
< div class = "flex flex-col md:flex-row gap-6 mb-8" >
2026-01-24 22:59:20 +00:00
<!-- Nostr Relays -- >
2026-03-14 17:12:41 +00:00
< div data -controller -container tabindex = "0" class = "glass-card card-stagger p-6 flex flex-col md:w-1/2" style = "--stagger-index: 2" >
2026-02-17 15:03:34 +00:00
< div class = "flex items-start gap-4 mb-4 shrink-0" >
2026-01-24 22:59:20 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-02-17 15:03:34 +00:00
< div class = "space-y-3 flex-1 min-h-0" >
2026-01-24 22:59:20 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.relaysConnectedLabel' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-white/60 text-sm" > { { nostrRelayStats ? . connected _count ? ? 0 } } active < / span >
2026-01-24 22:59:20 +00:00
< / 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 >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'web5.totalRelays' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span : class = "(nostrRelayStats?.total_relays ?? 0) > 0 ? 'text-green-400' : 'text-white/60'" class = "text-sm font-medium" >
{ { nostrRelayStats ? . total _relays ? ? 0 } } configured
< / span >
2026-01-24 22:59:20 +00:00
< / 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" >
2026-03-09 07:43:12 +00:00
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M13 10V3L4 14h7v7l9-11h-7z" / >
2026-01-24 22:59:20 +00:00
< / svg >
2026-03-11 13:45:59 +00:00
< span class = "text-white/80 text-sm" > { { t ( 'common.enabled' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< span class = "text-white/60 text-sm" > { { nostrRelayStats ? . enabled _count ? ? 0 } } relays < / span >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.relays' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
2026-02-17 15:03:34 +00:00
2026-03-09 07:43:12 +00:00
<!-- Node Visibility -- >
2026-03-14 17:12:41 +00:00
< div data -controller -container tabindex = "0" class = "glass-card card-stagger p-6 flex flex-col md:w-1/2" style = "--stagger-index: 3" >
2026-03-09 07:43:12 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white mb-2" > { { t ( 'web5.nodeVisibility' ) } } < / h2 >
< p class = "text-white/70 text-sm mb-4" > { { t ( 'web5.nodeVisibilityDesc' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / 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 >
<!-- Visibility Options -- >
< div class = "space-y-2 flex-1 min-h-0" >
< button
v - for = "opt in visibilityOptions"
: key = "opt.value"
@ click = "setVisibility(opt.value)"
: disabled = "settingVisibility"
class = "w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left"
: class = " nodeVisibility === opt . value
? 'bg-white/10 border-white/25 text-white'
: 'bg-white/5 border-white/10 text-white/60 hover:bg-white/8 hover:text-white/80' "
>
< div class = "w-3 h-3 rounded-full shrink-0 border-2 flex items-center justify-center"
: class = "nodeVisibility === opt.value ? 'border-green-400' : 'border-white/30'"
>
< div v-if = "nodeVisibility === opt.value" class="w-1.5 h-1.5 rounded-full bg-green-400" > < / div >
< / div >
< div class = "min-w-0 flex-1" >
< p class = "text-sm font-medium" > { { opt . label } } < / p >
< p class = "text-xs text-white/50" > { { opt . description } } < / p >
< / div >
< / button >
< / div >
<!-- Onion address ( shown when discoverable / public ) -- >
< div v-if = "nodeVisibility !== 'hidden' && 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" >
2026-03-11 13:45:59 +00:00
< p class = "text-xs text-white/50 mb-1" > { { t ( 'web5.yourTorAddress' ) } } < / p >
2026-03-09 07:43:12 +00:00
< 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 >
<!-- Warning -- >
< p v-if = "nodeVisibility !== 'hidden'" class="mt-3 text-xs text-amber-400/80" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.discoverableWarning' ) } }
2026-03-09 07:43:12 +00:00
< / p >
< / div >
2026-03-14 17:12:41 +00:00
< / div >
2026-03-09 07:43:12 +00:00
2026-03-14 17:12:41 +00:00
<!-- Connected Nodes ( P2P over Tor ) -- >
< div ref = "nodesContainerRef" data -controller -container tabindex = "0" class = "glass-card p-6 mb-8 scroll-mt-24" >
<!-- Desktop : side - by - side layout -- >
< div class = "hidden md:flex items-start gap-4 mb-4" >
2026-02-17 15:03:34 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< h2 class = "text-xl font-semibold text-white mb-2" > { { t ( 'web5.connectedNodes' ) } } < / h2 >
< p class = "text-white/70 text-sm mb-4" > { { t ( 'web5.peerNodesDescription' ) } } < / p >
2026-02-17 15:03:34 +00:00
< / div >
2026-03-14 04:08:21 +00:00
< div class = "flex 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"
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.refresh' ) } }
< / button >
< / div >
2026-02-17 15:03:34 +00:00
< / div >
2026-03-14 17:12:41 +00:00
<!-- 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 >
< p class = "text-white/70 text-sm mb-3" > { { t ( 'web5.peerNodesDescription' ) } } < / p >
< div class = "grid grid-cols-2 gap-2" >
< button
@ click = "router.push('/dashboard/server/federation')"
class = "min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
>
{ { t ( 'web5.findNodes' ) } }
< / button >
< button
@ click = "loadPeers"
class = "min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
>
{ { loadingPeers ? '...' : t ( 'common.refresh' ) } }
< / button >
< / div >
< / div >
2026-02-17 15:03:34 +00:00
2026-03-09 07:43:12 +00:00
<!-- Tabs : Peers | Messages | Requests -- >
2026-02-17 15:03:34 +00:00
< div class = "flex gap-1 mb-4 border-b border-white/10" >
< button
@ click = "nodesContainerTab = 'peers'"
class = "px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
: class = "nodesContainerTab === 'peers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.peers' ) } }
2026-02-17 15:03:34 +00:00
< span v-if = "peers.length > 0" class="ml-1.5 text-xs text-white/50" > ( {{ peers.length }} ) < / span >
< / button >
< button
@ click = "switchToMessagesTab"
class = "px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
: class = "nodesContainerTab === 'messages' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.messages' ) } }
2026-02-17 15:03:34 +00:00
< span v-if = "receivedMessages.length > 0" class="ml-1.5 text-xs" :class="unreadCount > 0 ? 'text-orange-400' : 'text-white/50'" > ( {{ receivedMessages.length }} ) < / span >
< span v-if = "unreadCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse" > < / span >
< / button >
2026-03-09 07:43:12 +00:00
< 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'"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.requests' ) } }
2026-03-09 07:43:12 +00:00
< 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 >
2026-02-17 15:03:34 +00:00
< / div >
<!-- Peers tab -- >
< div v-show = "nodesContainerTab === 'peers'" class="space-y-2 max-h-48 overflow-y-auto" >
< div v-if = "peers.length === 0" class="p-4 text-center text-white/60 text-sm" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.noPeers' ) } }
2026-02-17 15:03:34 +00:00
< / div >
< div
v - for = "p in peers"
: key = "p.pubkey"
class = "flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
< 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" >
2026-03-14 17:12:41 +00:00
< p class = "text-sm font-mono text-white/90 truncate" > { { p . name || p . onion || ( p . pubkey || '' ) . slice ( 0 , 16 ) + '...' } } < / p >
2026-02-17 15:03:34 +00:00
< p class = "text-xs text-white/50 truncate" > { { p . onion } } < / p >
< / div >
< / div >
< button
@ click = "showSendMessageModal = true; sendMessageTo = 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.message' ) } }
2026-02-17 15:03:34 +00:00
< / button >
< / div >
< / div >
<!-- Messages tab -- >
< div v-show = "nodesContainerTab === 'messages'" class="space-y-2 max-h-64 overflow-y-auto" >
< div v-if = "loadingMessages" class="p-4 text-center text-white/60 text-sm" >
2026-03-11 13:45:59 +00:00
{ { t ( 'common.loading' ) } }
2026-02-17 15:03:34 +00:00
< / div >
< div v -else -if = " receivedMessages.length = = = 0 " class = "p-4 text-center text-white/60 text-sm" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.noMessages' ) } }
2026-02-17 15:03:34 +00:00
< / div >
< div
v - for = "(m, idx) in receivedMessages"
: key = "idx"
class = "p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
>
< div class = "flex items-center justify-between gap-2 mb-1" >
2026-03-14 17:12:41 +00:00
< p class = "text-xs font-mono text-white/60 truncate" :title = "m.from_pubkey" > { { ( m . from _pubkey || '' ) . slice ( 0 , 16 ) } } ... < / p >
2026-02-17 15:03:34 +00:00
< span class = "text-xs text-white/40 shrink-0" > { { formatMessageTime ( m . timestamp ) } } < / span >
< / div >
< p class = "text-sm text-white/90 break-words" > { { m . message } } < / p >
< / div >
< / div >
2026-03-09 07:43:12 +00:00
<!-- Requests tab -- >
< div v-show = "nodesContainerTab === 'requests'" class="space-y-2 max-h-64 overflow-y-auto" >
< div v-if = "loadingRequests" class="p-4 text-center text-white/60 text-sm" >
2026-03-11 13:45:59 +00:00
{ { t ( 'common.loading' ) } }
2026-03-09 07:43:12 +00:00
< / div >
< div v -else -if = " connectionRequests.length = = = 0 " class = "p-4 text-center text-white/60 text-sm" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.noRequests' ) } }
2026-03-09 07:43:12 +00:00
< / 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" > { { 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.accept' ) } }
2026-03-09 07:43:12 +00:00
< / 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.reject' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-17 15:03:34 +00:00
< button
v - if = "nodesContainerTab === 'peers'"
@ click = "discoverAndAddPeers"
: disabled = "discovering"
class = "mt-4 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"
>
2026-03-11 13:45:59 +00:00
{ { discovering ? t ( 'web5.discovering' ) : t ( 'web5.discoverNodes' ) } }
2026-02-17 15:03:34 +00:00
< / button >
< button
2026-03-09 07:43:12 +00:00
v - else - if = "nodesContainerTab === 'messages'"
2026-02-17 15:03:34 +00:00
@ click = "loadReceivedMessages"
: disabled = "loadingMessages"
class = "mt-4 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"
>
2026-03-11 13:45:59 +00:00
{ { loadingMessages ? t ( 'common.loading' ) : t ( 'web5.refreshMessages' ) } }
2026-02-17 15:03:34 +00:00
< / button >
2026-03-09 07:43:12 +00:00
< button
v - else
@ click = "loadConnectionRequests"
: disabled = "loadingRequests"
class = "mt-4 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"
>
2026-03-11 13:45:59 +00:00
{ { loadingRequests ? t ( 'common.loading' ) : t ( 'web5.refreshRequests' ) } }
2026-03-09 07:43:12 +00:00
< / button >
2026-02-17 15:03:34 +00:00
< / div >
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
<!-- Shared Content -- >
< div class = "glass-card p-6 mb-8" >
2026-03-14 17:12:41 +00:00
<!-- Desktop : side - by - side -- >
< div class = "hidden md:flex items-center justify-between mb-4" >
2026-03-09 07:43:12 +00:00
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
< / div >
2026-03-09 07:43:12 +00:00
< div >
2026-03-11 13:45:59 +00:00
< h2 class = "text-lg font-semibold text-white" > { { t ( 'web5.content' ) } } < / h2 >
< p class = "text-xs text-white/60" > { { t ( 'web5.contentDesc' ) } } < / p >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-09 07:43:12 +00:00
{ { contentLoading ? '...' : 'Refresh' } }
< / button >
2026-03-14 17:12:41 +00:00
< button @ click = "showAddContentModal = true" class = "glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2" >
2026-03-09 07:43:12 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
<!-- 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'"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.myContent' ) } }
2026-03-09 07:43:12 +00:00
< 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'"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.browsePeers' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< / div >
<!-- My Content tab -- >
< div v-show = "contentTab === 'mine'" >
<!-- Loading -- >
< 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-white/50 text-sm" > { { t ( 'common.loading' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
<!-- Empty -- >
< 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-white/60 text-sm mb-1" > { { t ( 'web5.noSharedContent' ) } } < / p >
< p class = "text-white/40 text-xs" > { { t ( 'web5.addFilesToShare' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
<!-- Content List -- >
< div v -else class = "space-y-3" >
< div
v - for = "(item, idx) in contentItems"
: key = "item.id"
class = "card-stagger 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Pricing Controls -- >
< 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 ) === 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Price Input ( visible when "paid" is selected ) -- >
< div v-if = "getAccessType(item) === 'paid'" class="flex items-center gap-3 mt-2" >
< div class = "flex items-center gap-2 flex-1" >
< input
: value = "getItemPrice(item)"
@ 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< p class = "text-xs text-orange-400/80" > Peers will pay { { getItemPrice ( item ) || 0 } } sats to access this < / p >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Free label -- >
2026-03-11 13:45:59 +00:00
< p v -else -if = " getAccessType ( item ) = = = ' free ' " class = "text-xs text-green-400/70 mt-1" > { { t ( 'web5.freeAccessDesc' ) } } < / p >
< p v -else -if = " getAccessType ( item ) = = = ' peers_only ' " class = "text-xs text-blue-400/70 mt-1" > { { t ( 'web5.peersOnlyAccessDesc' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Browse Peers tab -- >
< div v-show = "contentTab === '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"
>
2026-03-11 13:45:59 +00:00
< option value = "" > { { t ( 'web5.selectPeer' ) } } < / option >
2026-03-09 07:43:12 +00:00
< option v-for = "p in peers" :key="p.pubkey" :value="p.onion" >
2026-03-14 17:12:41 +00:00
{ { p . name || p . onion || ( p . pubkey || '' ) . slice ( 0 , 12 ) + '...' } }
2026-03-09 07:43:12 +00:00
< / 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"
>
2026-03-11 13:45:59 +00:00
{ { browsingPeerContent ? t ( 'common.loading' ) : t ( 'web5.browse' ) } }
2026-03-09 07:43:12 +00:00
< / button >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< p v-if = "browsePeerError" class="text-xs text-red-400 mt-2" > {{ browsePeerError }} < / p >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Peer Content Loading -- >
< div v-if = "browsingPeerContent" 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-white/50 text-sm" > { { t ( 'web5.connectingToPeer' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
<!-- No peer selected -- >
< 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-white/60 text-sm mb-1" > { { t ( 'web5.selectPeerToBrowse' ) } } < / p >
< p class = "text-white/40 text-xs" > { { t ( 'web5.choosePeerDesc' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
<!-- Peer has no content -- >
< div v -else -if = " peerContentItems.length = = = 0 & & browsePeerOnion & & ! browsingPeerContent " class = "py-6 text-center" >
2026-03-11 13:45:59 +00:00
< p class = "text-white/60 text-sm" > { { t ( 'web5.peerNoContent' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
<!-- Peer Content List -- >
< div v -else class = "space-y-2" >
< div
v - for = "(pItem, idx) in peerContentItems"
: key = "pItem.id"
class = "card-stagger flex items-center gap-4 p-3 bg-white/5 rounded-lg"
: style = "{ '--stagger-index': idx }"
>
<!-- Media type icon -- >
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
< / div >
2026-03-09 07:43:12 +00:00
< 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 = "getPeerItemPrice(pItem) > 0" class="text-xs text-orange-400 ml-1" > {{ getPeerItemPrice ( pItem ) }} sats < / span >
< span v -else class = "text-xs text-green-400/70 ml-1" > Free < / span >
< / div >
< / div >
<!-- Stream / Download button -- >
< button
v - if = "isMediaType(pItem.mime_type)"
@ 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.stream' ) } }
2026-03-09 07:43:12 +00:00
< / 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.download' ) } }
2026-03-09 07:43:12 +00:00
< / button >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
< / div >
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
<!-- Content Streaming Player -- >
< Teleport to = "body" >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-11 13:11:45 +00:00
< div class = "glass-card p-0 w-full max-w-2xl overflow-hidden" role = "dialog" aria -modal = " true " >
2026-03-09 07:43:12 +00:00
<!-- Player Header -- >
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-09 07:43:12 +00:00
<!-- Player Body -- >
< div class = "p-4" >
<!-- Audio Player -- >
< div v-if = "streamingItem.mime_type.startsWith('audio/')" >
< audio
ref = "audioPlayerRef"
: src = "streamUrl"
controls
class = "w-full"
@ timeupdate = "onPlayerTimeUpdate"
@ error = "onPlayerError"
> < / audio >
< / div >
<!-- Video Player -- >
< 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 >
<!-- Player Error -- >
< div v-if = "playerError" class="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg" >
< p class = "text-red-400 text-sm" > { { 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 >
<!-- Stream Info -- >
< 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
< / div >
2026-03-09 07:43:12 +00:00
< / div >
< / Teleport >
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
<!-- Add Content Modal -- >
< Teleport to = "body" >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-11 13:45:59 +00:00
< h2 id = "add-content-title" class = "text-lg font-bold text-white mb-4" > { { t ( 'web5.addContentTitle' ) } } < / h2 >
2026-03-09 07:43:12 +00:00
< 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< 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 p-2 bg-red-500/20 border border-red-500/30 rounded-lg" >
< p class = "text-red-300 text-xs" > { { addContentError } } < / p >
< / div >
< div class = "flex gap-3 mt-6" >
2026-03-11 13:45:59 +00:00
< button @ click = "showAddContentModal = false; addContentError = ''" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm" > { { t ( 'common.cancel' ) } } < / button >
2026-03-09 07:43:12 +00:00
< button @click ="addContentItem" : disabled = "addingContent || !newContentFilename.trim()" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50" >
{ { addingContent ? 'Adding...' : 'Add' } }
< / button >
< / div >
< / div >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< / Teleport >
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
<!-- Identity Management -- >
< div class = "glass-card p-6 mb-8" >
2026-03-14 17:12:41 +00:00
<!-- Desktop : side - by - side -- >
< div class = "hidden md:flex items-center justify-between mb-4" >
2026-03-09 07:43:12 +00:00
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
< / div >
2026-03-09 07:43:12 +00:00
< div >
2026-03-11 13:45:59 +00:00
< h2 class = "text-lg font-semibold text-white" > { { t ( 'web5.identities' ) } } < / h2 >
< p class = "text-xs text-white/60" > { { t ( 'web5.identitiesDesc' ) } } < / p >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-09 07:43:12 +00:00
< button @ click = "showCreateIdentityModal = 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 >
Create
< / button >
< / div >
2026-03-14 17:12:41 +00:00
<!-- 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 >
< button @ click = "showCreateIdentityModal = true" class = "w-full min-h-[44px] glass-button 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 >
Create
< / button >
< / div >
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
<!-- Loading -- >
< div v-if = "identitiesLoading" 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-white/50 text-sm" > { { t ( 'common.loading' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-white/60 text-sm mb-1" > { { t ( 'web5.noIdentities' ) } } < / p >
< p class = "text-white/40 text-xs" > { { t ( 'web5.createFirstIdentity' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
<!-- Identity List -- >
< div v -else class = "space-y-3" >
< div
v - for = "(identity, idx) in managedIdentities"
: key = "identity.id"
class = "card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
: style = "{ '--stagger-index': idx }"
>
<!-- Purpose Icon -- >
< div class = "flex-shrink-0 w-10 h-10 rounded-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' ,
} " >
< svg class = "w-5 h-5" : class = " {
'text-blue-400' : identity . purpose === 'personal' ,
'text-orange-400' : identity . purpose === 'business' ,
'text-purple-400' : identity . purpose === 'anonymous' ,
} " 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Info -- >
< div class = "flex-1 min-w-0" >
< div class = "flex items-center gap-2" >
< span class = "text-white font-medium text-sm" > { { 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< p class = "text-white/50 text-xs font-mono truncate mt-0.5" :title = "identity.did" > { { identity . did } } < / p >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Actions -- >
< div class = "flex items-center gap-1 shrink-0" >
2026-03-14 17:12:41 +00:00
< button @click ="copyIdentityDid(identity.did)" class = "p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title = "Copy" >
2026-03-09 07:43:12 +00:00
< 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 >
< 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" / >
2026-01-24 22:59:20 +00:00
< / svg >
2026-03-09 07:43:12 +00:00
< / 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 >
< / div >
<!-- Create Identity Modal -- >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-11 13:45:59 +00:00
< h2 id = "create-identity-title" class = "text-lg font-bold text-white mb-4" > { { t ( 'web5.createIdentityTitle' ) } } < / h2 >
2026-03-09 07:43:12 +00:00
< 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
< div v-if = "createIdentityError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg" >
< p class = "text-red-300 text-xs" > { { createIdentityError } } < / p >
< / div >
< div class = "flex gap-3 mt-6" >
2026-03-11 13:45:59 +00:00
< button @ click = "showCreateIdentityModal = false" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm" > { { t ( 'common.cancel' ) } } < / button >
2026-03-09 07:43:12 +00:00
< button @click ="createIdentity" :disabled = "creatingIdentity" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30" >
2026-03-11 13:45:59 +00:00
{ { creatingIdentity ? t ( 'web5.creatingDid' ) : t ( 'web5.createIdentity' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< / div >
< / div >
< / div >
<!-- Delete Confirmation Modal -- >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-11 13:45:59 +00:00
< 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 >
2026-03-09 07:43:12 +00:00
< div class = "flex gap-3" >
2026-03-11 13:45:59 +00:00
< button @ click = "deleteIdentityTarget = null" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm" > { { t ( 'common.cancel' ) } } < / button >
2026-03-09 07:43:12 +00:00
< button @click ="deleteIdentity" :disabled = "deletingIdentity" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30" >
2026-03-11 13:45:59 +00:00
{ { deletingIdentity ? t ( 'web5.deleting' ) : t ( 'common.delete' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< / div >
< / div >
< / div >
<!-- Unified Send Modal -- >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-11 13:45:59 +00:00
< h2 id = "send-bitcoin-title" class = "text-lg font-bold text-white mb-4" > { { t ( 'web5.sendBitcoinTitle' ) } } < / h2 >
2026-03-09 07:43:12 +00:00
<!-- 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 >
<!-- Auto mode hint -- >
< 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 ; 1 k sats , Lightning 1 k – 500 k , on - chain & gt ; 500 k < / p >
< / div >
<!-- Amount -- >
< 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / div >
<!-- Destination ( varies by method ) -- >
< 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30" > < / textarea >
< / div >
<!-- Ecash token output -- >
< 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 >
2026-03-11 13:11:45 +00:00
<!-- Hardware Wallet toggle ( on - chain only ) -- >
< 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 >
2026-03-11 13:45:59 +00:00
< p class = "text-sm text-white" > { { t ( 'web5.signWithHwWallet' ) } } < / p >
< p class = "text-xs text-white/40" > { { t ( 'web5.createsPsbt' ) } } < / p >
2026-03-11 13:11:45 +00:00
< / div >
< / div >
<!-- PSBT display ( hardware wallet flow ) -- >
< 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 >
2026-03-09 07:43:12 +00:00
<!-- On - chain txid result -- >
< div v-if = "sendResultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg" >
< p class = "text-green-400 text-xs" > Sent ! TX : { { sendResultTxid } } < / p >
< / div >
<!-- Lightning payment result -- >
< div v-if = "sendResultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg" >
< p class = "text-green-400 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" >
2026-03-11 13:45:59 +00:00
< button @click ="closeUnifiedSendModal" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm" > { { t ( 'common.close' ) } } < / button >
2026-03-11 13:11:45 +00:00
< button v-if = "psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50" >
{ { unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' } }
< / button >
< button v -else @click ="unifiedSend" : disabled = "unifiedSendProcessing || !unifiedSendAmount" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50" >
{ { unifiedSendProcessing ? 'Sending...' : ( useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send' ) } }
2026-03-09 07:43:12 +00:00
< / button >
< / div >
< / div >
< / div >
<!-- Unified Receive Modal -- >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-11 13:45:59 +00:00
< h2 id = "receive-bitcoin-title" class = "text-lg font-bold text-white mb-4" > { { t ( 'web5.receiveBitcoinTitle' ) } } < / h2 >
2026-03-09 07:43:12 +00:00
<!-- Method tabs -- >
< div class = "flex gap-1 mb-4 p-1 bg-white/5 rounded-lg" >
< button
v - for = "m in (['lightning', 'onchain', '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 >
<!-- Lightning : create invoice -- >
< 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 >
<!-- On - chain : new address -- >
< div v-if = "receiveMethod === 'onchain'" >
< div v-if = "receiveOnchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center" >
< 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" >
2026-03-11 13:45:59 +00:00
< p class = "text-white/50 text-sm mb-2" > { { t ( 'web5.generateFreshAddress' ) } } < / p >
2026-01-24 22:59:20 +00:00
< / div >
< / div >
2026-03-09 07:43:12 +00:00
<!-- Ecash : paste token -- >
< div v-if = "receiveMethod === 'ecash'" >
< div class = "mb-3" >
< label class = "text-white/60 text-sm block mb-1" > Paste ecash token < / label >
< textarea v-model = "ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" > < / 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" >
2026-03-11 13:45:59 +00:00
< button @click ="closeUnifiedReceiveModal" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm" > { { t ( 'common.close' ) } } < / button >
2026-03-09 07:43:12 +00:00
< button @click ="unifiedReceive" :disabled = "unifiedReceiveProcessing" class = "flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50" >
{ { unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' } }
< / button >
< / div >
< / div >
< / div >
<!-- Decentralized Web Node ( DWN ) -- >
< div class = "glass-card p-6 mb-8" >
< 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 = "M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" / >
< / svg >
< / div >
< div >
2026-03-11 13:45:59 +00:00
< h2 class = "text-lg font-semibold text-white" > { { t ( 'web5.decentralizedWebNode' ) } } < / h2 >
< p class = "text-xs text-white/60" > { { t ( 'web5.dwnDescription' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
2026-03-14 04:08:21 +00:00
< router-link v-if = "dwnInstalled && dwnRunning" to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.manageDwn' ) } }
2026-03-09 07:43:12 +00:00
< / router-link >
< / div >
2026-03-14 04:08:21 +00:00
<!-- DWN not installed or not running -- >
< div v-if = "!dwnInstalled || !dwnRunning" class="py-6 text-center" >
< p class = "text-white/60 text-sm mb-4" >
{ { ! dwnInstalled ? 'The DWN container is not installed.' : 'The DWN container is not running.' } }
{ { ! dwnInstalled ? 'Install it from the App Store to enable decentralized data storage and sync.' : 'Start it from the App Store to enable decentralized data storage and sync.' } }
< / p >
< router-link to = "/dashboard/marketplace" class = "glass-button px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline" >
< 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 = "M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" / >
< / svg >
Open App Store
< / router-link >
< / div >
<!-- Status ( only shown when DWN is installed and running ) -- >
< template v-if = "dwnInstalled && dwnRunning" >
2026-03-09 07:43:12 +00:00
< div class = "grid grid-cols-2 md:grid-cols-4 gap-3 mb-4" >
< div class = "bg-white/5 rounded-lg p-3" >
2026-03-11 13:45:59 +00:00
< div class = "text-xs text-white/50 mb-1" > { { t ( 'common.status' ) } } < / div >
2026-03-09 07:43:12 +00:00
< div class = "flex items-center gap-2" >
< div class = "w-2 h-2 rounded-full" : class = "dwnStatus?.running ? 'bg-green-400' : 'bg-red-400'" > < / div >
2026-03-11 13:45:59 +00:00
< span class = "text-sm text-white font-medium" > { { dwnStatus ? . running ? t ( 'common.running' ) : t ( 'common.stopped' ) } } < / span >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
< div class = "bg-white/5 rounded-lg p-3" >
< div class = "text-xs text-white/50 mb-1" > Sync < / div >
< div class = "flex items-center gap-2" >
< div class = "w-2 h-2 rounded-full" : class = " {
'bg-green-400' : dwnSyncStatus === 'synced' ,
'bg-yellow-400 animate-pulse' : dwnSyncStatus === 'syncing' ,
'bg-red-400' : dwnSyncStatus === 'error' ,
'bg-white/30' : dwnSyncStatus === 'idle'
} " > < / div >
< span class = "text-sm text-white font-medium capitalize" > { { dwnSyncStatus } } < / span >
< / div >
< / div >
< div class = "bg-white/5 rounded-lg p-3" >
< div class = "text-xs text-white/50 mb-1" > Storage < / div >
< span class = "text-sm text-white font-medium" > { { formatDwnStorage } } < / span >
< / div >
< div class = "bg-white/5 rounded-lg p-3" >
< div class = "text-xs text-white/50 mb-1" > Messages < / div >
2026-03-11 13:11:45 +00:00
< span class = "text-sm text-white font-medium" > { { dwnStatus ? . message _count ? ? 0 } } < / span >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
<!-- Protocols -- >
2026-03-11 13:11:45 +00:00
< div class = "mb-4" >
< div class = "flex items-center justify-between mb-2" >
< div class = "text-xs text-white/50" > Registered Protocols ( { { dwnProtocols . length } } ) < / div >
< button @ click = "showRegisterProtocol = !showRegisterProtocol" class = "text-xs text-blue-400 hover:text-blue-300 transition-colors" >
{ { showRegisterProtocol ? 'Cancel' : '+ Register' } }
< / button >
2026-03-09 07:43:12 +00:00
< / div >
2026-03-11 13:11:45 +00:00
<!-- Register Protocol Form -- >
< div v-if = "showRegisterProtocol" class="bg-white/5 rounded-lg p-3 mb-3" >
< div class = "flex gap-2 items-end" >
< div class = "flex-1" >
< label class = "text-xs text-white/50 block mb-1" > Protocol URI < / label >
< input v-model = "newProtocolUri" type="text" placeholder="https://example.com/protocol" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" / >
< / div >
< label class = "flex items-center gap-1.5 text-xs text-white/60 cursor-pointer whitespace-nowrap pb-1.5" >
< input v-model = "newProtocolPublished" type="checkbox" class="rounded bg-black/30 border-white/20" / >
Published
< / label >
< button @click ="registerDwnProtocol" : disabled = "registeringProtocol || !newProtocolUri.trim()" class = "glass-button glass-button-sm px-3 rounded-lg text-xs font-medium disabled:opacity-50 whitespace-nowrap" >
{ { registeringProtocol ? 'Registering...' : 'Register' } }
< / button >
< / div >
< / div >
< div v-if = "dwnProtocols.length" class="flex flex-wrap gap-2" >
< div v-for = "proto in dwnProtocols" :key="proto.protocol" class="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/15 border border-blue-500/20 text-xs text-blue-300 group" >
< span > { { proto . protocol } } < / span >
< span v-if = "proto.published" class="text-green-400/60" title="Published" > & # x2022 ; < / span >
< button @click ="removeDwnProtocol(proto.protocol)" : disabled = "removingProtocol === proto.protocol" class = "opacity-0 group-hover:opacity-100 text-red-400/60 hover:text-red-400 transition-all ml-1" title = "Remove" >
& times ;
< / button >
< / div >
< / div >
< div v -else class = "text-xs text-white/30 italic" > No protocols registered < / div >
2026-03-09 07:43:12 +00:00
< / div >
<!-- Sync Targets -- >
< div v-if = "dwnStatus?.peer_sync_targets?.length" class="mb-4" >
< div class = "text-xs text-white/50 mb-2" > Peer Sync Targets < / div >
< div class = "space-y-1" >
< div v-for = "target in dwnStatus.peer_sync_targets" :key="target" class="flex items-center gap-2 text-xs text-white/70 bg-white/5 rounded-lg px-3 py-2" >
< svg class = "w-3 h-3 text-green-400 flex-shrink-0" fill = "currentColor" viewBox = "0 0 24 24" > < circle cx = "12" cy = "12" r = "4" / > < / svg >
< span class = "truncate font-mono" > { { target } } < / span >
< / div >
< / div >
< / div >
2026-03-11 13:11:45 +00:00
<!-- Messages Browser -- >
< div class = "mb-4" >
< div class = "flex items-center justify-between mb-2" >
< div class = "text-xs text-white/50" > Messages < / div >
< button @click ="toggleDwnMessages" class = "text-xs text-blue-400 hover:text-blue-300 transition-colors" >
{ { showDwnMessages ? 'Hide' : 'Browse' } }
< / button >
< / div >
< div v-if = "showDwnMessages" >
< div v-if = "loadingDwnMessages" class="text-xs text-white/40 py-4 text-center" > Loading messages... < / div >
< div v -else -if = " dwnMessages.length = = = 0 " class = "text-xs text-white/30 italic py-2" > No messages stored < / div >
< div v -else class = "space-y-2 max-h-64 overflow-y-auto" >
< div v-for = "msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3" >
< div class = "flex items-center justify-between mb-1" >
2026-03-14 17:12:41 +00:00
< span class = "text-xs font-mono text-white/50 truncate max-w-[200px]" :title = "msg.record_id" > { { ( msg . record _id || '' ) . slice ( 0 , 8 ) } } ... < / span >
2026-03-11 13:11:45 +00:00
< span class = "text-xs text-white/40" > { { new Date ( msg . date _created ) . toLocaleString ( ) } } < / span >
< / div >
< div class = "flex flex-wrap gap-2 text-xs" >
< span class = "text-white/70" > { { msg . author } } < / span >
< span v-if = "msg.descriptor.protocol" class="text-blue-300/80" > {{ msg.descriptor.protocol }} < / span >
< span v-if = "msg.descriptor.schema" class="text-purple-300/80" > {{ msg.descriptor.schema }} < / span >
< / div >
< div v-if = "msg.data" class="mt-1 text-xs text-white/40 font-mono truncate" > {{ JSON.stringify ( msg.data ) .slice ( 0 , 120 ) }} < / div >
< / div >
< / div >
< / div >
< / div >
2026-03-09 07:43:12 +00:00
<!-- Last Sync & Actions -- >
< div class = "flex items-center justify-between pt-3 border-t border-white/10" >
< div class = "text-xs text-white/40" >
{ { dwnStatus ? . last _sync ? ` Last sync: ${ new Date ( dwnStatus . last _sync ) . toLocaleString ( ) } ` : 'Never synced' } }
< / div >
< button @click ="syncDWNs" : disabled = "syncingDWNs || !dwnStatus?.running" class = "glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50" >
< svg class = "w-4 h-4" : class = "{ 'animate-spin': syncingDWNs }" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" / >
< / svg >
2026-03-11 13:45:59 +00:00
{ { syncingDWNs ? t ( 'web5.syncing' ) : t ( 'web5.syncNow' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
2026-03-14 04:08:21 +00:00
< / template >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-09 07:43:12 +00:00
<!-- Verifiable Credentials -- >
< div class = "glass-card p-6 mb-8" >
2026-03-14 17:12:41 +00:00
<!-- Desktop : side - by - side -- >
< div class = "hidden md:flex items-center justify-between mb-4" >
2026-03-09 07:43:12 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< h2 class = "text-lg font-semibold text-white" > { { t ( 'web5.verifiableCredentials' ) } } < / h2 >
< p class = "text-xs text-white/60" > { { t ( 'web5.verifiableCredentialsDesc' ) } } < / p >
2026-03-09 07:43:12 +00:00
< / div >
< / div >
2026-03-11 13:11:45 +00:00
< 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 →
< / router-link >
2026-03-09 07:43:12 +00:00
< / div >
2026-03-14 17:12:41 +00:00
<!-- 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 →
< / router-link >
< / div >
2026-03-09 07:43:12 +00:00
<!-- 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" > { { managedIdentities . length } } < / span >
< / div >
< / div >
2026-03-11 13:11:45 +00:00
<!-- Credentials List ( summary ) -- >
2026-03-09 07:43:12 +00:00
< div v-if = "vcCredentials.length" class="space-y-2" >
2026-03-11 13:11:45 +00:00
< 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" >
2026-03-09 07:43:12 +00:00
< div class = "min-w-0 flex-1" >
< div class = "text-sm text-white font-medium" > { { vc . type } } < / div >
2026-03-14 17:12:41 +00:00
< div class = "text-xs text-white/50 truncate" > To : { { ( vc . subject || '' ) . slice ( 0 , 30 ) } } ... < / div >
2026-03-09 07:43:12 +00:00
< / div >
2026-03-11 13:11:45 +00:00
< 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 >
2026-03-09 07:43:12 +00:00
< / div >
2026-03-11 13:11:45 +00:00
< 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 →
< / router-link >
2026-03-09 07:43:12 +00:00
< / div >
< div v -else class = "text-center text-white/40 text-sm py-4" >
2026-03-11 13:45:59 +00:00
{ { t ( 'web5.noCredentials' ) } }
2026-03-09 07:43:12 +00:00
< / div >
< / div >
<!-- Domains Management Modal -- >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-09 07:43:12 +00:00
< div class = "flex items-center justify-between mb-4" >
2026-03-11 13:45:59 +00:00
< h2 id = "domains-title" class = "text-lg font-bold text-white" > { { t ( 'web5.domainsTitle' ) } } < / h2 >
2026-03-09 07:43:12 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< div v -else class = "text-center text-white/40 text-sm py-4 mb-4" > { { t ( 'web5.noDomains' ) } } < / div >
2026-03-09 07:43:12 +00:00
<!-- Register New Name -- >
< div class = "border-t border-white/10 pt-4" >
2026-03-11 13:45:59 +00:00
< h3 class = "text-sm font-semibold text-white mb-3" > { { t ( 'web5.registerNewName' ) } } < / h3 >
2026-03-09 07:43:12 +00:00
< 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< / 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 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" >
< option value = "" disabled > Select identity ... < / option >
2026-03-14 17:12:41 +00:00
< option v-for = "id in managedIdentities" :key="id.id" :value="id.id" > {{ id.name }} ( {{ ( id.did | | ' ' ) .slice ( 0 , 24 ) }} ... ) < / option >
2026-03-09 07:43:12 +00:00
< / 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" >
2026-03-11 13:45:59 +00:00
< h3 class = "text-sm font-semibold text-white mb-3" > { { t ( 'web5.verifyNip05' ) } } < / h3 >
2026-03-09 07:43:12 +00:00
< div class = "flex gap-2" >
< input v-model = "verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" / >
< 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 >
<!-- Relay Management Modal -- >
2026-03-14 17:12:41 +00:00
< 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" >
2026-03-14 19:08:09 +00:00
< 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 " >
2026-03-09 07:43:12 +00:00
< div class = "flex items-center justify-between mb-4" >
2026-03-11 13:45:59 +00:00
< h2 id = "relays-title" class = "text-lg font-bold text-white" > { { t ( 'web5.nostrRelays' ) } } < / h2 >
2026-03-09 07:43:12 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< div v -else class = "text-center text-white/40 text-sm py-4 mb-4" > { { t ( 'web5.noRelays' ) } } < / div >
2026-03-09 07:43:12 +00:00
<!-- Add Relay -- >
< div class = "border-t border-white/10 pt-4" >
2026-03-11 13:45:59 +00:00
< h3 class = "text-sm font-semibold text-white mb-3" > { { t ( 'web5.addRelay' ) } } < / h3 >
2026-03-09 07:43:12 +00:00
< div class = "flex gap-2" >
2026-03-11 13:45:59 +00:00
< input v-model = "newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" @keyup.enter="addNostrRelay" / >
2026-03-09 07:43:12 +00:00
< 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 >
<!-- 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 >
2026-01-24 22:59:20 +00:00
< / div >
< / template >
< script setup lang = "ts" >
2026-02-17 15:03:34 +00:00
import { ref , computed , onMounted , nextTick , watch } from 'vue'
2026-03-14 04:08:21 +00:00
import { useRoute , useRouter } from 'vue-router'
2026-03-11 13:45:59 +00:00
import { useI18n } from 'vue-i18n'
2026-02-17 15:03:34 +00:00
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
2026-03-09 07:43:12 +00:00
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
2026-03-14 04:08:21 +00:00
import { PackageState } from '@/types/api'
2026-02-17 22:10:38 +00:00
import { useModalKeyboard } from '@/composables/useModalKeyboard'
2026-02-17 15:03:34 +00:00
const route = useRoute ( )
2026-03-14 04:08:21 +00:00
const router = useRouter ( )
2026-03-11 13:45:59 +00:00
const { t } = useI18n ( )
2026-02-17 15:03:34 +00:00
const messageToast = useMessageToast ( )
2026-03-09 07:43:12 +00:00
const web5Badge = useWeb5BadgeStore ( )
const appStore = useAppStore ( )
// --- Networking Profits ---
interface ProfitsData {
total _sats : number
content _sales _sats : number
routing _fees _sats : number
}
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 `
// Convert to BTC for large amounts
const btc = sats / 100 _000 _000
return ` ₿ ${ 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 }
}
}
// --- Domain Names (NIP-05) ---
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
}
interface Nip05Result {
name : string
domain : string
nostr _pubkey : string | null
relays : string [ ]
verified : boolean
}
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 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 ( ) {
try {
const res = await rpcClient . call < { names : RegisteredNameData [ ] } > ( { method : 'identity.list-names' } )
registeredNames . value = res . names || [ ]
} catch {
registeredNames . value = [ ]
}
}
async function registerNewName ( ) {
if ( ! newDomainName . value . trim ( ) || ! newDomainDomain . value . trim ( ) || ! newDomainIdentityId . value ) return
domainRegistering . value = true
domainError . value = ''
try {
const identity = managedIdentities . value . 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 ) {
2026-03-11 13:45:59 +00:00
domainError . value = e instanceof Error ? e . message : t ( 'web5.registrationFailed' )
2026-03-09 07:43:12 +00:00
} finally {
domainRegistering . value = false
}
}
async function removeName ( id : string ) {
try {
await rpcClient . call ( { method : 'identity.remove-name' , params : { id } } )
await loadDomainNames ( )
} catch ( e : unknown ) {
2026-03-11 13:45:59 +00:00
domainError . value = e instanceof Error ? e . message : t ( 'web5.removeFailed' )
2026-03-09 07:43:12 +00:00
}
}
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
}
}
// --- Verifiable Credentials ---
interface VCData {
id : string
issuer : string
subject : string
type : string
claims : Record < string , unknown >
issued _at : string
expires _at : string | null
status : string
}
const vcCredentials = ref < VCData [ ] > ( [ ] )
async function loadCredentials ( ) {
try {
const res = await rpcClient . call < { credentials : VCData [ ] } > ( { method : 'identity.list-credentials' } )
vcCredentials . value = res . credentials || [ ]
} catch {
vcCredentials . value = [ ]
}
}
// --- Nostr Relay Functions ---
async function loadNostrRelays ( ) {
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 {
nostrRelays . value = [ ]
nostrRelayStats . value = null
}
}
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 ) {
2026-03-11 13:45:59 +00:00
relayError . value = e instanceof Error ? e . message : t ( 'web5.failedToAddRelay' )
2026-03-09 07:43:12 +00:00
}
}
async function removeNostrRelay ( url : string ) {
try {
await rpcClient . call ( { method : 'nostr.remove-relay' , params : { url } } )
await loadNostrRelays ( )
} catch ( e : unknown ) {
2026-03-11 13:45:59 +00:00
relayError . value = e instanceof Error ? e . message : t ( 'web5.failedToRemoveRelay' )
2026-03-09 07:43:12 +00:00
}
}
async function toggleNostrRelay ( url : string , enabled : boolean ) {
try {
await rpcClient . call ( { method : 'nostr.toggle-relay' , params : { url , enabled } } )
await loadNostrRelays ( )
} catch ( e : unknown ) {
2026-03-11 13:45:59 +00:00
relayError . value = e instanceof Error ? e . message : t ( 'web5.failedToToggleRelay' )
2026-03-09 07:43:12 +00:00
}
}
2026-02-17 15:03:34 +00:00
2026-03-05 08:14:47 +00:00
const storedDid = ref < string | null > ( null )
try {
storedDid . value = localStorage . getItem ( 'neode_did' ) || null
} catch { /* noop */ }
const userDid = computed ( ( ) => storedDid . value )
2026-01-24 22:59:20 +00:00
2026-02-17 15:03:34 +00:00
const didStatus = computed < 'active' | 'inactive' | 'pending' > ( ( ) =>
userDid . value ? 'active' : 'inactive'
)
2026-01-24 22:59:20 +00:00
2026-03-05 08:14:47 +00:00
const creatingDid = ref ( false )
const didCopied = ref ( false )
async function createDID ( ) {
creatingDid . value = true
try {
// Try backend RPC first
const res = await rpcClient . call < { did : string } > ( { method : 'identity.create-did' } )
storedDid . value = res . did
localStorage . setItem ( 'neode_did' , res . did )
} catch {
// Fallback: generate a did:key locally using Web Crypto
2026-03-14 17:12:41 +00:00
if ( ! crypto . subtle ) {
// crypto.subtle requires HTTPS — generate random fallback
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 )
}
2026-03-05 08:14:47 +00:00
} finally {
creatingDid . value = false
}
}
2026-03-14 04:08:21 +00:00
// did:dht state
const dhtDid = ref < string | null > ( null )
const publishingDht = ref ( false )
const dhtDidCopied = ref ( false )
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 )
2026-03-14 19:08:09 +00:00
} catch {
// identity.create-dht-did not yet implemented — generate placeholder
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 )
}
2026-03-14 04:08:21 +00:00
} 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 } } )
2026-03-14 19:08:09 +00:00
} catch {
// identity.refresh-dht-did not yet implemented — silently ignore
2026-03-14 04:08:21 +00:00
} finally {
publishingDht . value = false
}
}
async function copyDhtDid ( ) {
if ( ! dhtDid . value ) return
2026-03-14 17:12:41 +00:00
await safeClipboardWrite ( dhtDid . value )
2026-03-14 04:08:21 +00:00
dhtDidCopied . value = true
setTimeout ( ( ) => { dhtDidCopied . value = false } , 2000 )
}
// Load saved dht_did on mount
try {
dhtDid . value = localStorage . getItem ( 'neode_dht_did' ) || null
} catch { /* noop */ }
2026-03-05 08:14:47 +00:00
async function copyDid ( ) {
if ( ! userDid . value ) return
2026-03-14 17:12:41 +00:00
await safeClipboardWrite ( userDid . value )
2026-03-05 08:14:47 +00:00
didCopied . value = true
setTimeout ( ( ) => { didCopied . value = false } , 2000 )
}
2026-03-11 13:11:45 +00:00
// 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
// Verify the document
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
2026-03-14 17:12:41 +00:00
await safeClipboardWrite ( didDocumentFormatted . value )
2026-03-11 13:11:45 +00:00
didDocCopied . value = true
setTimeout ( ( ) => { didDocCopied . value = false } , 2000 )
}
2026-03-09 07:43:12 +00:00
// DWN Status & Sync
interface DwnStatusData {
running : boolean
version : string
sync _status : string
last _sync : string | null
messages _synced : number
storage _bytes : number
2026-03-11 13:11:45 +00:00
message _count : number
protocol _count : number
2026-03-09 07:43:12 +00:00
registered _protocols : string [ ]
peer _sync _targets : string [ ]
}
2026-03-11 13:11:45 +00:00
interface DwnProtocol {
protocol : string
published : boolean
types : Record < string , unknown >
structure : Record < string , unknown >
dateRegistered : string
}
interface DwnMessageEntry {
record _id : string
author : string
date _created : string
descriptor : {
interface : string
method : string
protocol ? : string
schema ? : string
dataFormat ? : string
}
data ? : unknown
}
2026-03-09 07:43:12 +00:00
const dwnStatus = ref < DwnStatusData | null > ( null )
const dwnSyncStatus = ref < 'synced' | 'syncing' | 'error' | 'idle' > ( 'idle' )
2026-03-14 04:08:21 +00:00
const dwnInstalled = computed ( ( ) => ! ! appStore . packages [ 'dwn' ] )
const dwnRunning = computed ( ( ) => appStore . packages [ 'dwn' ] ? . state === PackageState . Running )
2026-01-24 22:59:20 +00:00
const syncingDWNs = ref ( false )
2026-03-11 13:11:45 +00:00
const dwnProtocols = ref < DwnProtocol [ ] > ( [ ] )
const dwnMessages = ref < DwnMessageEntry [ ] > ( [ ] )
const showDwnMessages = ref ( false )
const loadingDwnMessages = ref ( false )
const showRegisterProtocol = ref ( false )
const newProtocolUri = ref ( '' )
const newProtocolPublished = ref ( false )
const registeringProtocol = ref ( false )
const removingProtocol = ref < string | null > ( null )
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
const formatDwnStorage = computed ( ( ) => {
if ( ! dwnStatus . value ) return '0 B'
const bytes = dwnStatus . value . storage _bytes
if ( bytes < 1024 ) return ` ${ bytes } B `
if ( bytes < 1024 * 1024 ) return ` ${ ( bytes / 1024 ) . toFixed ( 1 ) } KB `
if ( bytes < 1024 * 1024 * 1024 ) return ` ${ ( bytes / ( 1024 * 1024 ) ) . toFixed ( 1 ) } MB `
return ` ${ ( bytes / ( 1024 * 1024 * 1024 ) ) . toFixed ( 2 ) } GB `
} )
async function loadDwnStatus ( ) {
try {
const res = await rpcClient . call < DwnStatusData > ( { method : 'dwn.status' } )
dwnStatus . value = res
dwnSyncStatus . value = ( res . sync _status as 'synced' | 'syncing' | 'error' | 'idle' ) || 'idle'
} catch {
dwnStatus . value = null
dwnSyncStatus . value = 'idle'
}
}
// Wallet Connection & LND Balances
const walletConnected = ref ( false )
2026-01-24 22:59:20 +00:00
const connectingWallet = ref ( false )
2026-03-09 07:43:12 +00:00
const lndOnchainBalance = ref ( 0 )
const lndChannelBalance = ref ( 0 )
2026-01-24 22:59:20 +00:00
// Nostr Relays
2026-03-09 07:43:12 +00:00
interface NostrRelayData {
url : string
connected : boolean
enabled : boolean
added _at : string
}
interface NostrRelayStatsData {
total _relays : number
connected _count : number
enabled _count : number
}
const nostrRelays = ref < NostrRelayData [ ] > ( [ ] )
const nostrRelayStats = ref < NostrRelayStatsData | null > ( null )
const showRelaysModal = ref ( false )
const newRelayUrl = ref ( '' )
const relayError = ref ( '' )
2026-01-24 22:59:20 +00:00
2026-02-17 15:03:34 +00:00
// Connected Nodes (peers)
const peers = ref < Array < { onion : string ; pubkey : string ; name ? : string } > > ( [ ] )
const loadingPeers = ref ( false )
2026-03-09 07:43:12 +00:00
const peerReachableLocal = ref < Record < string , boolean > > ( { } )
const peerReachable = computed ( ( ) => ( { ... appStore . peerHealth , ... peerReachableLocal . value } ) )
2026-02-17 15:03:34 +00:00
const connectedNodesCount = computed ( ( ) => peers . value . length )
2026-03-11 13:11:45 +00:00
// Hardware wallet detection
interface HwWalletDevice {
type : string
vendor _id : string
product _id : string
manufacturer : string
product : string
}
const detectedHwWallets = ref < HwWalletDevice [ ] > ( [ ] )
async function detectHardwareWallets ( ) {
try {
const res = await rpcClient . detectUsbDevices ( )
detectedHwWallets . value = res . devices || [ ]
} catch {
detectedHwWallets . value = [ ]
}
}
2026-02-17 15:03:34 +00:00
// Send Message modal
const showSendMessageModal = ref ( false )
2026-02-17 22:10:38 +00:00
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 } )
2026-02-17 15:03:34 +00:00
const sendMessageTo = ref ( '' )
const sendMessageText = ref ( '' )
const sendingMessage = ref ( false )
const sendMessageError = ref ( '' )
const sendMessageSuccess = ref ( '' )
const discovering = ref ( false )
// Connected Nodes container: tabs + messages (uses shared composable for polling from Dashboard)
const nodesContainerRef = ref < HTMLElement | null > ( null )
2026-03-09 07:43:12 +00:00
const nodesContainerTab = ref < 'peers' | 'messages' | 'requests' > ( 'peers' )
2026-02-17 15:03:34 +00:00
const { receivedMessages , loadingMessages , unreadCount , loadReceivedMessages , markAsRead } = messageToast
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
}
}
function switchToMessagesTab ( ) {
nodesContainerTab . value = 'messages'
markAsRead ( )
}
async function loadPeers ( ) {
loadingPeers . value = true
try {
const res = await rpcClient . listPeers ( )
2026-03-14 04:08:21 +00:00
const peerList = res . peers || [ ]
// Also load federated nodes and merge them into the peers list
try {
const fedRes = await rpcClient . federationListNodes ( )
const fedNodes = fedRes . nodes || [ ]
for ( const n of fedNodes ) {
if ( n . onion && ! peerList . some ( p => p . onion === n . onion || p . pubkey === n . pubkey ) ) {
peerList . push ( { onion : n . onion , pubkey : n . pubkey , name : n . name || ` Federation: ${ n . did ? . slice ( 0 , 16 ) || 'node' } ` } )
}
}
} catch {
// Federation may not be set up — ignore
}
peers . value = peerList
2026-02-17 15:03:34 +00:00
for ( const p of peers . value ) {
try {
const check = await rpcClient . checkPeerReachable ( p . onion )
2026-03-09 07:43:12 +00:00
peerReachableLocal . value [ p . onion ] = check . reachable
2026-02-17 15:03:34 +00:00
} catch {
2026-03-09 07:43:12 +00:00
peerReachableLocal . value [ p . onion ] = false
2026-02-17 15:03:34 +00:00
}
}
} catch ( e ) {
2026-03-11 13:11:45 +00:00
if ( import . meta . env . DEV ) console . error ( 'Failed to load peers:' , e )
2026-02-17 15:03:34 +00:00
} 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 ( ) )
2026-03-11 13:45:59 +00:00
sendMessageSuccess . value = t ( 'web5.messageSent' )
2026-02-17 15:03:34 +00:00
sendMessageText . value = ''
setTimeout ( ( ) => {
showSendMessageModal . value = false
sendMessageSuccess . value = ''
} , 1500 )
} catch ( e ) {
2026-03-11 13:45:59 +00:00
sendMessageError . value = e instanceof Error ? e . message : t ( 'web5.failedToSend' )
2026-02-17 15:03:34 +00:00
} 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 } )
2026-03-11 00:58:55 +00:00
} catch ( e ) {
if ( import . meta . env . DEV ) console . warn ( 'Peer may already exist' , e )
2026-02-17 15:03:34 +00:00
}
}
}
await loadPeers ( )
} catch ( e ) {
2026-03-11 13:11:45 +00:00
if ( import . meta . env . DEV ) console . error ( 'Discover failed:' , e )
2026-02-17 15:03:34 +00:00
} finally {
discovering . value = false
}
}
2026-03-09 07:43:12 +00:00
// --- Wallet (Unified Send/Receive) ---
const ecashBalance = ref ( 0 )
const ecashTokenCount = ref ( 0 )
const ecashTxCount = ref ( 0 )
const ecashSendToken = ref ( '' )
const ecashReceiveToken = ref ( '' )
const ecashReceiveResult = ref ( '' )
// Unified Send
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 ( '' )
2026-03-11 13:11:45 +00:00
const useHardwareWallet = ref ( false )
const psbtData = ref ( '' )
const psbtStep = ref < 'idle' | 'created' | 'finalizing' > ( 'idle' )
const signedPsbtInput = ref ( '' )
2026-03-09 07:43:12 +00:00
// Unified Receive
const showUnifiedReceiveModal = ref ( false )
const receiveMethod = ref < 'lightning' | 'onchain' | 'ecash' > ( 'lightning' )
const receiveInvoiceAmount = ref < number > ( 0 )
const receiveInvoiceMemo = ref ( '' )
const receiveInvoiceResult = ref ( '' )
const receiveOnchainAddress = ref ( '' )
const unifiedReceiveProcessing = ref ( false )
const unifiedReceiveError = 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'
} )
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
ecashTokenCount . value = res . token _count ? ? 0
} catch {
ecashBalance . value = 0
ecashTokenCount . value = 0
}
try {
const hist = await rpcClient . call < { transactions : unknown [ ] } > ( { method : 'wallet.ecash-history' } )
ecashTxCount . value = hist . transactions ? . length ? ? 0
} catch {
ecashTxCount . value = 0
}
}
function closeUnifiedSendModal ( ) {
showUnifiedSendModal . value = false
ecashSendToken . value = ''
unifiedSendError . value = ''
sendResultTxid . value = ''
sendResultHash . value = ''
2026-03-11 13:11:45 +00:00
psbtData . value = ''
psbtStep . value = 'idle'
signedPsbtInput . value = ''
2026-03-09 07:43:12 +00:00
}
function closeUnifiedReceiveModal ( ) {
showUnifiedReceiveModal . value = false
receiveInvoiceResult . value = ''
receiveOnchainAddress . value = ''
ecashReceiveToken . value = ''
ecashReceiveResult . value = ''
unifiedReceiveError . value = ''
}
async function unifiedSend ( ) {
if ( ! unifiedSendAmount . value || unifiedSendProcessing . value ) return
unifiedSendProcessing . value = true
unifiedSendError . value = ''
ecashSendToken . value = ''
sendResultTxid . value = ''
sendResultHash . 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 ( ) ) {
2026-03-11 13:45:59 +00:00
unifiedSendError . value = t ( 'web5.pasteInvoice' )
2026-03-09 07:43:12 +00:00
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 ( ) ) {
2026-03-11 13:45:59 +00:00
unifiedSendError . value = t ( 'web5.enterBitcoinAddress' )
2026-03-09 07:43:12 +00:00
return
}
2026-03-11 13:11:45 +00:00
if ( useHardwareWallet . value ) {
// Hardware wallet flow: create unsigned PSBT
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
}
2026-03-09 07:43:12 +00:00
const res = await rpcClient . call < { txid : string } > ( {
method : 'lnd.sendcoins' ,
params : { addr : unifiedSendDest . value . trim ( ) , amount : unifiedSendAmount . value } ,
} )
sendResultTxid . value = res . txid
}
await loadEcashBalance ( )
await loadLndBalances ( )
} catch ( err : unknown ) {
2026-03-11 13:45:59 +00:00
unifiedSendError . value = err instanceof Error ? err . message : t ( 'web5.sendFailed' )
2026-03-09 07:43:12 +00:00
} finally {
unifiedSendProcessing . value = false
}
}
2026-03-11 13:11:45 +00:00
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 = ''
2026-03-11 13:45:59 +00:00
sendResultTxid . value = t ( 'web5.broadcastViaHwWallet' )
2026-03-11 13:11:45 +00:00
await loadLndBalances ( )
} catch ( err : unknown ) {
2026-03-11 13:45:59 +00:00
unifiedSendError . value = err instanceof Error ? err . message : t ( 'web5.broadcastFailed' )
2026-03-11 13:11:45 +00:00
} finally {
unifiedSendProcessing . value = false
}
}
function copyPsbt ( ) {
if ( ! psbtData . value ) return
2026-03-14 19:08:09 +00:00
safeClipboardWrite ( psbtData . value )
2026-03-11 13:45:59 +00:00
unifiedSendError . value = t ( 'web5.psbtCopied' )
2026-03-11 13:11:45 +00:00
}
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 = ''
}
2026-03-09 07:43:12 +00:00
async function unifiedReceive ( ) {
if ( unifiedReceiveProcessing . value ) return
unifiedReceiveProcessing . value = true
unifiedReceiveError . value = ''
try {
if ( receiveMethod . value === 'lightning' ) {
if ( ! receiveInvoiceAmount . value || receiveInvoiceAmount . value < 1 ) {
2026-03-11 13:45:59 +00:00
unifiedReceiveError . value = t ( 'web5.enterAmount' )
2026-03-09 07:43:12 +00:00
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' } )
receiveOnchainAddress . value = res . address
} else {
if ( ! ecashReceiveToken . value . trim ( ) ) {
2026-03-11 13:45:59 +00:00
unifiedReceiveError . value = t ( 'web5.pasteEcashToken' )
2026-03-09 07:43:12 +00:00
return
}
const res = await rpcClient . call < { received _sats : number } > ( {
method : 'wallet.ecash-receive' ,
params : { token : ecashReceiveToken . value . trim ( ) } ,
} )
ecashReceiveResult . value = ` Received ${ res . received _sats } sats! `
ecashReceiveToken . value = ''
await loadEcashBalance ( )
}
} catch ( err : unknown ) {
2026-03-11 13:45:59 +00:00
unifiedReceiveError . value = err instanceof Error ? err . message : t ( 'web5.receiveFailed' )
2026-03-09 07:43:12 +00:00
} finally {
unifiedReceiveProcessing . value = false
}
}
function copyEcashToken ( token : string ) {
2026-03-14 17:12:41 +00:00
safeClipboardWrite ( token )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.ecashTokenCopied' ) )
2026-03-09 07:43:12 +00:00
}
2026-03-14 17:12:41 +00:00
async function safeClipboardWrite ( text : string ) : Promise < void > {
if ( navigator . clipboard ? . writeText ) {
2026-03-14 19:08:09 +00:00
await navigator . clipboard . writeText ( text )
2026-03-14 17:12:41 +00:00
} else {
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 )
}
}
2026-03-09 07:43:12 +00:00
function copyToClipboard ( text : string , msg : string ) {
2026-03-14 17:12:41 +00:00
safeClipboardWrite ( text )
2026-03-09 07:43:12 +00:00
showIdentityToast ( msg )
}
// --- Shared Content ---
interface ContentItemData {
id : string
filename : string
mime _type : string
size _bytes : number
description : string
access : string | { paid : { price _sats : number } }
added _at : string
}
const contentItems = ref < ContentItemData [ ] > ( [ ] )
const contentLoading = ref ( false )
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' } ,
]
function getAccessType ( item : ContentItemData ) : 'free' | 'peers_only' | 'paid' {
if ( typeof item . access === 'string' ) {
if ( item . access === 'peersonly' || item . access === 'peers_only' ) return 'peers_only'
if ( item . access === 'paid' ) return 'paid'
return 'free'
}
if ( item . access && typeof item . access === 'object' && 'paid' in item . access ) return 'paid'
return 'free'
}
function getItemPrice ( item : ContentItemData ) : number {
if ( typeof item . access === 'object' && item . access && 'paid' in item . access ) {
return item . access . paid . price _sats
}
return 0
}
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 ] } `
}
async function loadContentItems ( ) {
contentLoading . value = true
try {
const res = await rpcClient . call < { items : ContentItemData [ ] } > ( { method : 'content.list-mine' } )
contentItems . value = res . items || [ ]
} catch {
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 paid access was selected, set pricing after adding
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 ( )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.contentAdded' ) )
2026-03-09 07:43:12 +00:00
} catch ( err : unknown ) {
2026-03-11 13:45:59 +00:00
addContentError . value = err instanceof Error ? err . message : t ( 'web5.failedToAddContent' )
2026-03-09 07:43:12 +00:00
} 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 )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.contentRemoved' ) )
2026-03-09 07:43:12 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToRemoveContent' ) )
2026-03-09 07:43:12 +00:00
} 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 ) || 100
}
await rpcClient . call ( { method : 'content.set-pricing' , params } )
await loadContentItems ( )
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToUpdatePricing' ) )
2026-03-09 07:43:12 +00:00
} 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 {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToUpdatePrice' ) )
2026-03-09 07:43:12 +00:00
} finally {
updatingPricingId . value = null
}
}
// --- Content Tab + Browse Peers ---
const contentTab = ref < 'mine' | 'browse' > ( 'mine' )
const browsePeerOnion = ref ( '' )
const browsingPeerContent = ref ( false )
const browsePeerError = ref ( '' )
interface PeerContentItem {
id : string
filename : string
mime _type : string
size _bytes : number
description : string
access : string | { paid : { price _sats : number } }
}
const peerContentItems = ref < PeerContentItem [ ] > ( [ ] )
function isMediaType ( mime : string ) : boolean {
return mime . startsWith ( 'audio/' ) || mime . startsWith ( 'video/' )
}
function getPeerItemPrice ( item : PeerContentItem ) : number {
if ( typeof item . access === 'object' && item . access && 'paid' in item . access ) {
return item . access . paid . price _sats
}
return 0
}
async function browsePeerContent ( ) {
if ( ! browsePeerOnion . value || browsingPeerContent . value ) return
browsingPeerContent . value = true
browsePeerError . value = ''
peerContentItems . value = [ ]
try {
const res = await rpcClient . call < { items : PeerContentItem [ ] } > ( {
method : 'content.browse-peer' ,
params : { onion : browsePeerOnion . value } ,
} )
peerContentItems . value = res . items || [ ]
if ( peerContentItems . value . length === 0 ) {
browsePeerError . value = ''
}
} catch ( err : unknown ) {
2026-03-11 13:45:59 +00:00
browsePeerError . value = err instanceof Error ? err . message : t ( 'web5.failedToConnectPeer' )
2026-03-09 07:43:12 +00:00
} finally {
browsingPeerContent . value = false
}
}
// --- Content 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 )
function streamPeerContent ( item : PeerContentItem ) {
if ( ! browsePeerOnion . value ) return
streamingItem . value = item
streamUrl . value = ` http:// ${ browsePeerOnion . value } /content/ ${ item . id } `
streamCostSats . value = getPeerItemPrice ( item )
streamProgress . value = 0
playerError . value = ''
}
function downloadPeerContent ( item : PeerContentItem ) {
if ( ! browsePeerOnion . value ) return
const url = ` http:// ${ browsePeerOnion . value } /content/ ${ item . id } `
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.downloadUrlCopied' ) )
2026-03-14 17:12:41 +00:00
safeClipboardWrite ( url )
2026-03-09 07:43:12 +00:00
}
function closePlayer ( ) {
// Stop playback
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 ( ) {
2026-03-11 13:45:59 +00:00
playerError . value = t ( 'web5.playerError' )
2026-03-09 07:43:12 +00:00
}
function copyStreamUrl ( ) {
if ( streamUrl . value ) {
2026-03-14 17:12:41 +00:00
safeClipboardWrite ( streamUrl . value )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.streamUrlCopied' ) )
2026-03-09 07:43:12 +00:00
}
}
// --- Connection Requests ---
interface ConnectionRequest {
id : string
from _did : string
from _onion ? : string
from _pubkey ? : string
message ? : string
created _at : string
}
const connectionRequests = ref < ConnectionRequest [ ] > ( [ ] )
const loadingRequests = ref ( false )
const processingRequestId = ref < string | null > ( null )
async function loadConnectionRequests ( ) {
loadingRequests . value = true
try {
const res = await rpcClient . call < { requests : ConnectionRequest [ ] } > ( { method : 'network.list-requests' } )
connectionRequests . value = res . requests || [ ]
web5Badge . pendingRequestCount = connectionRequests . value . length
} catch {
connectionRequests . value = [ ]
} finally {
loadingRequests . value = false
}
}
function switchToRequestsTab ( ) {
nodesContainerTab . value = 'requests'
if ( connectionRequests . value . length === 0 && ! loadingRequests . value ) {
loadConnectionRequests ( )
}
}
async function acceptRequest ( requestId : string ) {
processingRequestId . value = requestId
try {
await rpcClient . call ( { method : 'network.accept-request' , params : { request _id : requestId } } )
connectionRequests . value = connectionRequests . value . filter ( r => r . id !== requestId )
web5Badge . pendingRequestCount = connectionRequests . value . length
await loadPeers ( )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.connectionAccepted' ) )
2026-03-09 07:43:12 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToAcceptRequest' ) )
2026-03-09 07:43:12 +00:00
} finally {
processingRequestId . value = null
}
}
async function rejectRequest ( requestId : string ) {
processingRequestId . value = requestId
try {
await rpcClient . call ( { method : 'network.reject-request' , params : { request _id : requestId } } )
connectionRequests . value = connectionRequests . value . filter ( r => r . id !== requestId )
web5Badge . pendingRequestCount = connectionRequests . value . length
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.requestRejected' ) )
2026-03-09 07:43:12 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToRejectRequest' ) )
2026-03-09 07:43:12 +00:00
} finally {
processingRequestId . value = null
}
}
const identityToastText = ref ( '' )
const identityToastVisible = ref ( false )
let identityToastTimer : ReturnType < typeof setTimeout > | undefined
function showIdentityToast ( text : string ) {
identityToastText . value = text
identityToastVisible . value = true
clearTimeout ( identityToastTimer )
identityToastTimer = setTimeout ( ( ) => { identityToastVisible . value = false } , 2000 )
}
// --- Node Visibility ---
type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
const nodeVisibility = ref < VisibilityLevel > ( 'hidden' )
const nodeOnionAddress = ref < string | null > ( null )
const visibilityLoading = ref ( false )
const settingVisibility = ref ( false )
const visibilityOptions = [
{ value : 'hidden' as VisibilityLevel , label : 'Hidden' , description : 'Your node is not discoverable by others' } ,
2026-03-14 17:12:41 +00:00
{ value : 'discoverable' as VisibilityLevel , label : 'Discoverable' , description : 'Federated peers can find and connect to your node' } ,
{ value : 'public' as VisibilityLevel , label : 'Public' , description : 'Accepting connections from any Archipelago node' } ,
2026-03-09 07:43:12 +00:00
]
async function loadVisibility ( ) {
visibilityLoading . value = true
try {
const res = await rpcClient . call < { visibility : string ; onion _address ? : string } > ( { method : 'network.get-visibility' } )
nodeVisibility . value = ( res . visibility as VisibilityLevel ) || 'hidden'
nodeOnionAddress . value = res . onion _address || null
} catch {
nodeVisibility . value = 'hidden'
} finally {
visibilityLoading . value = false
}
}
async function setVisibility ( level : VisibilityLevel ) {
if ( settingVisibility . value || nodeVisibility . value === level ) return
settingVisibility . value = true
try {
const res = await rpcClient . call < { visibility : string ; onion _address ? : string } > ( {
method : 'network.set-visibility' ,
params : { visibility : level } ,
} )
nodeVisibility . value = ( res . visibility as VisibilityLevel ) || level
nodeOnionAddress . value = res . onion _address || nodeOnionAddress . value
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.visibilitySetTo' , { level } ) )
2026-03-09 07:43:12 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToUpdateVisibility' ) )
2026-03-09 07:43:12 +00:00
} finally {
settingVisibility . value = false
}
}
function copyOnionAddress ( ) {
if ( ! nodeOnionAddress . value ) return
2026-03-14 17:12:41 +00:00
safeClipboardWrite ( nodeOnionAddress . value )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.onionAddressCopied' ) )
2026-03-09 07:43:12 +00:00
}
// --- Identity Management ---
interface ManagedIdentity {
id : string
name : string
purpose : string
pubkey : string
did : string
created _at : string
is _default : boolean
}
const managedIdentities = ref < ManagedIdentity [ ] > ( [ ] )
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 )
async function loadIdentities ( ) {
identitiesLoading . value = true
try {
const res = await rpcClient . call < { identities : ManagedIdentity [ ] } > ( { method : 'identity.list' } )
managedIdentities . value = res . identities || [ ]
} catch {
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 ( )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.identityCreated' ) )
2026-03-09 07:43:12 +00:00
} catch ( err : unknown ) {
2026-03-11 13:45:59 +00:00
createIdentityError . value = err instanceof Error ? err . message : t ( 'web5.failedToCreateIdentity' )
2026-03-09 07:43:12 +00:00
} finally {
creatingIdentity . value = false
}
}
function copyIdentityDid ( did : string ) {
2026-03-14 17:12:41 +00:00
safeClipboardWrite ( did )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.didCopied' ) )
2026-03-09 07:43:12 +00:00
}
async function setDefaultIdentity ( id : string ) {
try {
await rpcClient . call ( { method : 'identity.set-default' , params : { id } } )
await loadIdentities ( )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.defaultIdentityUpdated' ) )
2026-03-09 07:43:12 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToSetDefault' ) )
2026-03-09 07:43:12 +00:00
}
}
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 ( )
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.identityDeleted' ) )
2026-03-09 07:43:12 +00:00
} catch {
2026-03-11 13:45:59 +00:00
showIdentityToast ( t ( 'web5.failedToDeleteIdentity' ) )
2026-03-09 07:43:12 +00:00
} finally {
deletingIdentity . value = false
}
}
2026-02-17 15:03:34 +00:00
onMounted ( ( ) => {
loadPeers ( )
loadReceivedMessages ( )
2026-03-09 07:43:12 +00:00
loadIdentities ( )
loadVisibility ( )
loadConnectionRequests ( )
loadEcashBalance ( )
loadContentItems ( )
loadNetworkingProfits ( )
loadDwnStatus ( )
2026-03-11 13:11:45 +00:00
loadDwnProtocols ( )
2026-03-09 07:43:12 +00:00
loadDomainNames ( )
loadNostrRelays ( )
loadCredentials ( )
loadLndBalances ( )
2026-03-11 13:11:45 +00:00
detectHardwareWallets ( )
2026-02-17 15:03:34 +00:00
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
if ( route . query . tab === 'messages' ) {
nodesContainerTab . value = 'messages'
markAsRead ( )
nextTick ( ( ) => {
nodesContainerRef . value ? . scrollIntoView ( { behavior : 'smooth' , block : 'center' } )
} )
}
} )
watch ( ( ) => route . query . tab , ( tab ) => {
if ( tab === 'messages' ) {
nodesContainerTab . value = 'messages'
markAsRead ( )
nextTick ( ( ) => {
nodesContainerRef . value ? . scrollIntoView ( { behavior : 'smooth' , block : 'center' } )
} )
}
} )
2026-01-24 22:59:20 +00:00
2026-03-09 07:43:12 +00:00
async function syncDWNs ( ) {
2026-01-24 22:59:20 +00:00
syncingDWNs . value = true
dwnSyncStatus . value = 'syncing'
2026-03-09 07:43:12 +00:00
try {
const res = await rpcClient . call < { sync _status : string ; last _sync : string ; messages _synced : number } > ( { method : 'dwn.sync' } )
dwnSyncStatus . value = ( res . sync _status as 'synced' | 'syncing' | 'error' | 'idle' ) || 'synced'
await loadDwnStatus ( )
} catch {
dwnSyncStatus . value = 'error'
} finally {
2026-01-24 22:59:20 +00:00
syncingDWNs . value = false
2026-03-09 07:43:12 +00:00
}
2026-01-24 22:59:20 +00:00
}
2026-03-11 13:11:45 +00:00
async function loadDwnProtocols ( ) {
try {
const res = await rpcClient . call < { protocols : DwnProtocol [ ] } > ( { method : 'dwn.list-protocols' } )
dwnProtocols . value = res . protocols || [ ]
} catch {
dwnProtocols . value = [ ]
}
}
async function registerDwnProtocol ( ) {
if ( registeringProtocol . value || ! newProtocolUri . value . trim ( ) ) return
registeringProtocol . value = true
try {
await rpcClient . call ( { method : 'dwn.register-protocol' , params : { protocol : newProtocolUri . value . trim ( ) , published : newProtocolPublished . value } } )
newProtocolUri . value = ''
newProtocolPublished . value = false
showRegisterProtocol . value = false
await loadDwnProtocols ( )
await loadDwnStatus ( )
} catch {
if ( import . meta . env . DEV ) console . error ( 'Failed to register protocol' )
} finally {
registeringProtocol . value = false
}
}
async function removeDwnProtocol ( protocol : string ) {
removingProtocol . value = protocol
try {
await rpcClient . call ( { method : 'dwn.remove-protocol' , params : { protocol } } )
await loadDwnProtocols ( )
await loadDwnStatus ( )
} catch {
if ( import . meta . env . DEV ) console . error ( 'Failed to remove protocol' )
} finally {
removingProtocol . value = null
}
}
async function toggleDwnMessages ( ) {
showDwnMessages . value = ! showDwnMessages . value
if ( showDwnMessages . value ) {
await loadDwnMessages ( )
}
}
async function loadDwnMessages ( ) {
loadingDwnMessages . value = true
try {
const res = await rpcClient . call < { messages : DwnMessageEntry [ ] ; count : number } > ( { method : 'dwn.query-messages' , params : { limit : 50 } } )
dwnMessages . value = res . messages || [ ]
} catch {
dwnMessages . value = [ ]
} finally {
loadingDwnMessages . value = false
}
}
2026-03-09 07:43:12 +00:00
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
} catch {
walletConnected . value = false
lndOnchainBalance . value = 0
lndChannelBalance . value = 0
}
}
async function connectWallet ( ) {
2026-01-24 22:59:20 +00:00
if ( walletConnected . value ) {
walletConnected . value = false
} else {
connectingWallet . value = true
2026-03-09 07:43:12 +00:00
await loadLndBalances ( )
connectingWallet . value = false
2026-01-24 22:59:20 +00:00
}
}
function manageRelays ( ) {
2026-03-14 17:12:41 +00:00
showRelaysModal . value = true
2026-01-24 22:59:20 +00:00
}
< / script >
2026-02-17 15:03:34 +00:00