archy/neode-ui/src/views/Web5.vue
Dorian b6f401e7f6 fix: indeedhub staging API, nginx caching, nostr identity and UI improvements
Switch IndeedHub to staging API, add _next asset caching in nginx,
simplify NostrIdentityPicker component, and update Apps/Web5/Marketplace views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 19:08:09 +00:00

3220 lines
148 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="pb-6">
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<span class="text-2xl text-orange-500 font-bold"></span>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.networkingProfits') }}</p>
<p class="text-xs text-orange-500 font-medium">{{ networkingProfitsDisplay }}</p>
</div>
</div>
<div v-if="profitsBreakdown" class="text-xs text-white/40 space-y-0.5">
<p v-if="profitsBreakdown.content_sales_sats > 0">Content: {{ profitsBreakdown.content_sales_sats.toLocaleString() }} sats</p>
<p v-if="profitsBreakdown.routing_fees_sats > 0">Routing: {{ profitsBreakdown.routing_fees_sats.toLocaleString() }} sats</p>
</div>
</div>
<!-- DID 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">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">{{ t('web5.didStatus') }}</p>
<p v-if="userDid" class="text-xs text-white/60 font-mono truncate" :title="userDid">{{ userDid }}</p>
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
</div>
</div>
<div v-if="userDid" class="flex gap-2">
<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"
>
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</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"
>
{{ t('web5.viewDidDocument') }}
</button>
</div>
<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"
>
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
</button>
</div>
<!-- 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>
<!-- Wallet Connection -->
<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">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.wallet') }}</p>
<p class="text-xs text-white/60">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
</div>
</div>
<button
@click="connectWallet"
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"
:disabled="connectingWallet"
>
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
</button>
</div>
<!-- Nostr Relay Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="(nostrRelayStats?.connected_count ?? 0) > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="(nostrRelayStats?.connected_count ?? 0) > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.nostrRelays') }}</p>
<p class="text-xs text-white/60">{{ t('web5.relaysConnected', { count: nostrRelayStats?.connected_count ?? 0 }) }}</p>
</div>
</div>
<button
@click="manageRelays"
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"
>
{{ t('common.manage') }}
</button>
</div>
<!-- Connected Nodes -->
<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">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
<div v-if="connectedNodesCount > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-pulse opacity-75"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">{{ t('web5.connectedNodes') }}</p>
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
</div>
</div>
<div class="flex gap-2">
<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>
</div>
</div>
</div>
<!-- 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">
<p class="text-sm font-medium text-orange-400">{{ t('web5.hardwareWalletDetected') }}</p>
<p class="text-xs text-white/60">
{{ detectedHwWallets.map(d => `${d.type}${d.product ? ' (' + d.product + ')' : ''}`).join(', ') }}
</p>
</div>
</div>
<!-- DID Document Modal -->
<Teleport to="body">
<div v-if="showDidDocModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showDidDocModal = false" @keydown.escape="showDidDocModal = false">
<div class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title">
<div class="flex items-center justify-between mb-4">
<h3 id="did-doc-title" class="text-lg font-semibold text-white">{{ t('web5.didDocument') }}</h3>
<div class="flex items-center gap-2">
<span v-if="didDocVerified === true" class="text-xs text-green-400 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
{{ t('web5.verified') }}
</span>
<span v-else-if="didDocVerified === false" class="text-xs text-red-400">{{ t('web5.invalid') }}</span>
</div>
</div>
<div v-if="loadingDidDoc" class="text-white/60 text-sm">{{ t('common.loading') }}</div>
<pre v-else class="text-xs text-white/80 font-mono bg-black/30 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap">{{ didDocumentFormatted }}</pre>
<div class="flex gap-3 mt-4">
<button
@click="copyDidDocument"
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ didDocCopied ? t('common.copiedBang') : t('common.copy') }}
</button>
<button
@click="showDidDocModal = false"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
{{ t('common.close') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Send Message Modal -->
<Teleport to="body">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeSendMessageModal()">
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('web5.sendMessageTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.to') }}</label>
<select
v-model="sendMessageTo"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.message') }}</label>
<textarea
v-model="sendMessageText"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
:placeholder="t('web5.messagePlaceholder')"
></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="sendMessage"
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ sendingMessage ? t('common.sending') : t('common.send') }}
</button>
<button
@click="closeSendMessageModal()"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
{{ t('common.cancel') }}
</button>
</div>
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
</div>
</div>
</Teleport>
<!-- Core Services Overview Cards — Row 1 -->
<div class="flex flex-col md:flex-row gap-6 mb-6">
<!-- Bitcoin Domain Name Portfolio -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.bitcoinDomains') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.domainsSubtitle') }}</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.namesRegistered') }}</span>
</div>
<span class="text-white/60 text-sm">{{ registeredNames.length }} {{ registeredNames.length === 1 ? 'name' : 'names' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">{{ t('common.status') }}</span>
</div>
<span :class="activeNamesCount > 0 ? 'text-green-400' : 'text-white/60'" class="text-sm font-medium">
{{ activeNamesCount > 0 ? `${activeNamesCount} Active` : 'None' }}
</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.expiringSoon') }}</span>
</div>
<span class="text-white/60 text-sm">{{ expiringNamesCount }} {{ expiringNamesCount === 1 ? 'name' : 'names' }}</span>
</div>
</div>
<button @click="showDomainsModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.manageDomains') }}
</button>
</div>
<!-- Wallet -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold">₿</span>
<span class="text-white/80 text-sm">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ lndOnchainBalance.toLocaleString() }} sats</span>
</div>
<!-- Lightning Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ lndChannelBalance.toLocaleString() }} sats</span>
</div>
<!-- Ecash Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.ecash') }}</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ ecashBalance.toLocaleString() }} sats</span>
</div>
</div>
<!-- Action buttons -->
<div class="grid grid-cols-2 gap-2 mt-auto pt-4 shrink-0">
<button
@click="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"
>
{{ t('common.send') }}
</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"
>
{{ t('web5.receiveBitcoin') }}
</button>
</div>
</div>
</div>
<!-- Core Services Overview Cards — Row 2 -->
<div class="flex flex-col md:flex-row gap-6 mb-8">
<!-- Nostr Relays -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 2">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nostrRelays') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.nostrRelaysDesc') }}</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.relaysConnectedLabel') }}</span>
</div>
<span class="text-white/60 text-sm">{{ nostrRelayStats?.connected_count ?? 0 }} active</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.totalRelays') }}</span>
</div>
<span :class="(nostrRelayStats?.total_relays ?? 0) > 0 ? 'text-green-400' : 'text-white/60'" class="text-sm font-medium">
{{ nostrRelayStats?.total_relays ?? 0 }} configured
</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-white/80 text-sm">{{ t('common.enabled') }}</span>
</div>
<span class="text-white/60 text-sm">{{ nostrRelayStats?.enabled_count ?? 0 }} relays</span>
</div>
</div>
<button @click="showRelaysModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.relays') }}
</button>
</div>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1">
<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>
</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">
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourTorAddress') }}</p>
<p class="text-xs font-mono text-white/80 truncate" :title="nodeOnionAddress">{{ nodeOnionAddress }}</p>
</div>
<button @click="copyOnionAddress" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
</button>
</div>
</div>
<!-- Warning -->
<p v-if="nodeVisibility !== 'hidden'" class="mt-3 text-xs text-amber-400/80">
{{ t('web5.discoverableWarning') }}
</p>
</div>
</div>
<!-- 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">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.peerNodesDescription') }}</p>
</div>
<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>
</div>
<!-- Mobile: stacked layout -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-4 mb-2">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
</div>
<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>
<!-- Tabs: Peers | Messages | Requests -->
<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'"
>
{{ t('web5.peers') }}
<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'"
>
{{ t('web5.messages') }}
<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>
<button
@click="switchToRequestsTab"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
:class="nodesContainerTab === 'requests' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.requests') }}
<span v-if="connectionRequests.length > 0" class="ml-1.5 text-xs text-orange-400">({{ connectionRequests.length }})</span>
<span v-if="connectionRequests.length > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button>
</div>
<!-- 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">
{{ t('web5.noPeers') }}
</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">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
</div>
</div>
<button
@click="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"
>
{{ t('web5.message') }}
</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">
{{ t('common.loading') }}
</div>
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noMessages') }}
</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">
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ (m.from_pubkey || '').slice(0, 16) }}...</p>
<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>
<!-- 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">
{{ t('common.loading') }}
</div>
<div v-else-if="connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noRequests') }}
</div>
<div
v-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"
>
{{ t('web5.accept') }}
</button>
<button
@click="rejectRequest(req.id)"
:disabled="processingRequestId === req.id"
class="px-3 py-1.5 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{{ t('web5.reject') }}
</button>
</div>
</div>
</div>
</div>
<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"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
<button
v-else-if="nodesContainerTab === 'messages'"
@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"
>
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
</button>
<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"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
<!-- Shared Content -->
<div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
</div>
</div>
<div v-if="contentTab === 'mine'" class="flex items-center gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.contentDesc') }}</p>
<div v-if="contentTab === 'mine'" class="grid grid-cols-2 gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Tabs: My Content | Browse Peers -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="contentTab = 'mine'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'mine' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.myContent') }}
<span v-if="contentItems.length > 0" class="ml-1.5 text-xs text-white/50">({{ contentItems.length }})</span>
</button>
<button
@click="contentTab = 'browse'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'browse' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.browsePeers') }}
</button>
</div>
<!-- My Content tab -->
<div v-show="contentTab === 'mine'">
<!-- 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>
<p class="text-white/50 text-sm">{{ t('common.loading') }}</p>
</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>
<p class="text-white/60 text-sm mb-1">{{ t('web5.noSharedContent') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.addFilesToShare') }}</p>
</div>
<!-- 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>
</div>
<button
@click="removeContentItem(item.id)"
:disabled="removingContentId === item.id"
class="p-2 rounded-lg text-white/40 hover:text-red-400 hover:bg-white/10 transition-colors shrink-0"
title="Remove"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- 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>
</div>
<!-- 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>
</div>
<p class="text-xs text-orange-400/80">Peers will pay {{ getItemPrice(item) || 0 }} sats to access this</p>
</div>
<!-- Free label -->
<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>
</div>
</div>
</div>
<!-- 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"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- 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>
<p class="text-white/50 text-sm">{{ t('web5.connectingToPeer') }}</p>
</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>
<p class="text-white/60 text-sm mb-1">{{ t('web5.selectPeerToBrowse') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.choosePeerDesc') }}</p>
</div>
<!-- Peer has no content -->
<div v-else-if="peerContentItems.length === 0 && browsePeerOnion && !browsingPeerContent" class="py-6 text-center">
<p class="text-white/60 text-sm">{{ t('web5.peerNoContent') }}</p>
</div>
<!-- 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" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ pItem.filename }}</p>
<p v-if="pItem.description" class="text-xs text-white/50 truncate">{{ pItem.description }}</p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-white/40">{{ pItem.mime_type }}</span>
<span class="text-xs text-white/30">&middot;</span>
<span class="text-xs text-white/40">{{ formatBytes(pItem.size_bytes) }}</span>
<span v-if="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"
>
{{ t('web5.stream') }}
</button>
<button
v-else
@click="downloadPeerContent(pItem)"
class="px-3 py-1.5 text-xs rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors shrink-0"
>
{{ t('web5.download') }}
</button>
</div>
</div>
</div>
</div>
<!-- Content Streaming Player -->
<Teleport to="body">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
<div class="glass-card p-0 w-full max-w-2xl overflow-hidden" role="dialog" aria-modal="true">
<!-- 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>
</div>
</div>
<!-- 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>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal -->
<Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="add-content-title">
<h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Filename</label>
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full 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">
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button 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>
</div>
</Teleport>
<!-- Identity Management -->
<div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.identitiesDesc') }}</p>
</div>
</div>
<button @click="showCreateIdentityModal = true" class="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>
<!-- 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>
<!-- 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>
<p class="text-white/50 text-sm">{{ t('common.loading') }}</p>
</div>
<!-- Empty State -->
<div v-else-if="managedIdentities.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.noIdentities') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.createFirstIdentity') }}</p>
</div>
<!-- Identity List -->
<div v-else class="space-y-3">
<div
v-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>
</div>
<!-- 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>
</div>
<p class="text-white/50 text-xs font-mono truncate mt-0.5" :title="identity.did">{{ identity.did }}</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<button @click="copyIdentityDid(identity.did)" class="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>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Create Identity Modal -->
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Name</label>
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full 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>
</div>
</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">
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30">
{{ creatingIdentity ? t('web5.creatingDid') : t('web5.createIdentity') }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
<div class="flex gap-3">
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30">
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
</button>
</div>
</div>
</div>
<!-- Unified Send Modal -->
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<!-- 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; 1k sats, Lightning 1k500k, on-chain &gt; 500k</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>
<!-- 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>
<p class="text-sm text-white">{{ t('web5.signWithHwWallet') }}</p>
<p class="text-xs text-white/40">{{ t('web5.createsPsbt') }}</p>
</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>
<!-- 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">
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button 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') }}
</button>
</div>
</div>
</div>
<!-- Unified Receive Modal -->
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<!-- 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">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
</div>
</div>
<!-- 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">
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button 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>
<h2 class="text-lg font-semibold text-white">{{ t('web5.decentralizedWebNode') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.dwnDescription') }}</p>
</div>
</div>
<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">
{{ t('web5.manageDwn') }}
</router-link>
</div>
<!-- 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">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">{{ t('common.status') }}</div>
<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>
<span class="text-sm text-white font-medium">{{ dwnStatus?.running ? t('common.running') : t('common.stopped') }}</span>
</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>
<span class="text-sm text-white font-medium">{{ dwnStatus?.message_count ?? 0 }}</span>
</div>
</div>
<!-- Protocols -->
<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>
</div>
<!-- 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>
</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>
<!-- 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">
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
<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>
<!-- 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>
{{ syncingDWNs ? t('web5.syncing') : t('web5.syncNow') }}
</button>
</div>
</template>
</div>
<!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.verifiableCredentialsDesc') }}</p>
</div>
</div>
<router-link to="/dashboard/web5/credentials" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
Manage
</router-link>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.verifiableCredentialsDesc') }}</p>
<router-link to="/dashboard/web5/credentials" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
Manage
</router-link>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Total</div>
<span class="text-sm text-white font-medium">{{ vcCredentials.length }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Active</div>
<span class="text-sm text-green-400 font-medium">{{ vcCredentials.filter(c => c.status === 'active').length }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Identities</div>
<span class="text-sm text-white font-medium">{{ managedIdentities.length }}</span>
</div>
</div>
<!-- Credentials List (summary) -->
<div v-if="vcCredentials.length" class="space-y-2">
<div v-for="vc in vcCredentials.slice(0, 3)" :key="vc.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="min-w-0 flex-1">
<div class="text-sm text-white font-medium">{{ vc.type }}</div>
<div class="text-xs text-white/50 truncate">To: {{ (vc.subject || '').slice(0, 30) }}...</div>
</div>
<span :class="{
'text-green-400': vc.status === 'active',
'text-red-400': vc.status === 'revoked',
'text-yellow-400': vc.status === 'expired'
}" class="text-xs font-medium capitalize">{{ vc.status }}</span>
</div>
<router-link v-if="vcCredentials.length > 3" to="/dashboard/web5/credentials" class="block text-center text-xs text-white/50 hover:text-white/70 py-2 transition-colors">
View all {{ vcCredentials.length }} credentials
</router-link>
</div>
<div v-else class="text-center text-white/40 text-sm py-4">
{{ t('web5.noCredentials') }}
</div>
</div>
<!-- Domains Management Modal -->
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
<div class="flex items-center justify-between mb-4">
<h2 id="domains-title" class="text-lg font-bold text-white">{{ t('web5.domainsTitle') }}</h2>
<button @click="showDomainsModal = false" class="text-white/40 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Registered Names List -->
<div v-if="registeredNames.length" class="space-y-2 mb-4">
<div v-for="n in registeredNames" :key="n.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div>
<div class="text-sm text-white font-medium font-mono">{{ n.nip05 }}</div>
<div class="text-xs text-white/50 truncate max-w-[200px]">DID: {{ n.did }}</div>
</div>
<div class="flex items-center gap-2">
<span :class="{
'text-green-400': n.status === 'active',
'text-yellow-400': n.status === 'pending',
'text-red-400': n.status === 'expired' || n.status === 'failed'
}" class="text-xs font-medium capitalize">{{ n.status }}</span>
<button @click="removeName(n.id)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-white/40 text-sm py-4 mb-4">{{ t('web5.noDomains') }}</div>
<!-- Register New Name -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.registerNewName') }}</h3>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="text-white/60 text-xs block mb-1">Username</label>
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full 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>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
</select>
</div>
<div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div>
<button @click="registerNewName" :disabled="domainRegistering || !newDomainName.trim() || !newDomainDomain.trim() || !newDomainIdentityId" class="w-full glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ domainRegistering ? 'Registering...' : 'Register Name' }}
</button>
</div>
<!-- Verify NIP-05 -->
<div class="border-t border-white/10 pt-4 mt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
<div class="flex gap-2">
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 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 -->
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
<div class="flex items-center justify-between mb-4">
<h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2>
<button @click="showRelaysModal = false" class="text-white/40 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Relay List -->
<div v-if="nostrRelays.length" class="space-y-2 mb-4">
<div v-for="relay in nostrRelays" :key="relay.url" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-2 h-2 rounded-full flex-shrink-0" :class="relay.connected ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white font-mono truncate">{{ relay.url }}</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button @click="toggleNostrRelay(relay.url, !relay.enabled)" class="text-xs px-2 py-1 rounded" :class="relay.enabled ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-white/40'">
{{ relay.enabled ? 'On' : 'Off' }}
</button>
<button @click="removeNostrRelay(relay.url)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-white/40 text-sm py-4 mb-4">{{ t('web5.noRelays') }}</div>
<!-- Add Relay -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
<div class="flex gap-2">
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 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" />
<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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
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) {
domainError.value = e instanceof Error ? e.message : t('web5.registrationFailed')
} finally {
domainRegistering.value = false
}
}
async function removeName(id: string) {
try {
await rpcClient.call({ method: 'identity.remove-name', params: { id } })
await loadDomainNames()
} catch (e: unknown) {
domainError.value = e instanceof Error ? e.message : t('web5.removeFailed')
}
}
async function verifyNip05() {
if (!verifyNip05Input.value.trim()) return
nip05Verifying.value = true
nip05Result.value = null
try {
const res = await rpcClient.call<Nip05Result>({ method: 'identity.resolve-name', params: { identifier: verifyNip05Input.value.trim() } })
nip05Result.value = res
} catch {
nip05Result.value = { name: '', domain: '', nostr_pubkey: null, relays: [], verified: false }
} finally {
nip05Verifying.value = false
}
}
// --- 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) {
relayError.value = e instanceof Error ? e.message : t('web5.failedToAddRelay')
}
}
async function removeNostrRelay(url: string) {
try {
await rpcClient.call({ method: 'nostr.remove-relay', params: { url } })
await loadNostrRelays()
} catch (e: unknown) {
relayError.value = e instanceof Error ? e.message : t('web5.failedToRemoveRelay')
}
}
async function toggleNostrRelay(url: string, enabled: boolean) {
try {
await rpcClient.call({ method: 'nostr.toggle-relay', params: { url, enabled } })
await loadNostrRelays()
} catch (e: unknown) {
relayError.value = e instanceof Error ? e.message : t('web5.failedToToggleRelay')
}
}
const storedDid = ref<string | null>(null)
try {
storedDid.value = localStorage.getItem('neode_did') || null
} catch { /* noop */ }
const userDid = computed(() => storedDid.value)
const didStatus = computed<'active' | 'inactive' | 'pending'>(() =>
userDid.value ? 'active' : 'inactive'
)
const creatingDid = ref(false)
const didCopied = ref(false)
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
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)
}
} finally {
creatingDid.value = false
}
}
// 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)
} 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)
}
} finally {
publishingDht.value = false
}
}
async function refreshDhtDid() {
publishingDht.value = true
try {
const identities = await rpcClient.call<{ identities: Array<{ id: string; is_default: boolean }> }>({ method: 'identity.list' })
const defaultId = identities.identities?.find((i: { is_default: boolean }) => i.is_default)
if (!defaultId) return
await rpcClient.call({ method: 'identity.refresh-dht-did', params: { identity_id: defaultId.id } })
} catch {
// identity.refresh-dht-did not yet implemented — silently ignore
} finally {
publishingDht.value = false
}
}
async function copyDhtDid() {
if (!dhtDid.value) return
await safeClipboardWrite(dhtDid.value)
dhtDidCopied.value = true
setTimeout(() => { dhtDidCopied.value = false }, 2000)
}
// Load saved dht_did on mount
try {
dhtDid.value = localStorage.getItem('neode_dht_did') || null
} catch { /* noop */ }
async function copyDid() {
if (!userDid.value) return
await safeClipboardWrite(userDid.value)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
// DID Document modal
const showDidDocModal = ref(false)
const loadingDidDoc = ref(false)
const didDocumentData = ref<Record<string, unknown> | null>(null)
const didDocVerified = ref<boolean | null>(null)
const didDocCopied = ref(false)
const didDocumentFormatted = computed(() =>
didDocumentData.value ? JSON.stringify(didDocumentData.value, null, 2) : ''
)
async function showDidDocument() {
showDidDocModal.value = true
loadingDidDoc.value = true
didDocVerified.value = null
try {
const doc = await rpcClient.resolveDid()
didDocumentData.value = doc
// 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
await safeClipboardWrite(didDocumentFormatted.value)
didDocCopied.value = true
setTimeout(() => { didDocCopied.value = false }, 2000)
}
// DWN Status & Sync
interface DwnStatusData {
running: boolean
version: string
sync_status: string
last_sync: string | null
messages_synced: number
storage_bytes: number
message_count: number
protocol_count: number
registered_protocols: string[]
peer_sync_targets: string[]
}
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
}
const dwnStatus = ref<DwnStatusData | null>(null)
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error' | 'idle'>('idle')
const dwnInstalled = computed(() => !!appStore.packages['dwn'])
const dwnRunning = computed(() => appStore.packages['dwn']?.state === PackageState.Running)
const syncingDWNs = ref(false)
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)
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)
const connectingWallet = ref(false)
const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0)
// Nostr Relays
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('')
// Connected Nodes (peers)
const peers = ref<Array<{ onion: string; pubkey: string; name?: string }>>([])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>({})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const connectedNodesCount = computed(() => peers.value.length)
// 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 = []
}
}
// Send Message modal
const showSendMessageModal = ref(false)
const sendMessageModalRef = ref<HTMLElement | null>(null)
const sendMessageRestoreFocusRef = ref<HTMLElement | null>(null)
function closeSendMessageModal() {
sendMessageRestoreFocusRef.value?.focus?.()
showSendMessageModal.value = false
}
useModalKeyboard(sendMessageModalRef, showSendMessageModal, closeSendMessageModal, { restoreFocusRef: sendMessageRestoreFocusRef })
const sendMessageTo = ref('')
const sendMessageText = ref('')
const sendingMessage = ref(false)
const sendMessageError = ref('')
const sendMessageSuccess = ref('')
const discovering = ref(false)
// Connected Nodes container: tabs + messages (uses shared composable for polling from Dashboard)
const nodesContainerRef = ref<HTMLElement | null>(null)
const nodesContainerTab = ref<'peers' | 'messages' | 'requests'>('peers')
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()
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
for (const p of peers.value) {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable
} catch {
peerReachableLocal.value[p.onion] = false
}
}
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
} finally {
loadingPeers.value = false
}
}
async function sendMessage() {
if (!sendMessageTo.value || !sendMessageText.value.trim()) return
sendingMessage.value = true
sendMessageError.value = ''
sendMessageSuccess.value = ''
try {
await rpcClient.sendMessageToPeer(sendMessageTo.value, sendMessageText.value.trim())
sendMessageSuccess.value = t('web5.messageSent')
sendMessageText.value = ''
setTimeout(() => {
showSendMessageModal.value = false
sendMessageSuccess.value = ''
}, 1500)
} catch (e) {
sendMessageError.value = e instanceof Error ? e.message : t('web5.failedToSend')
} finally {
sendingMessage.value = false
}
}
async function discoverAndAddPeers() {
discovering.value = true
try {
const res = await rpcClient.discoverNodes()
const nodes = res.nodes || []
for (const n of nodes) {
if (n.onion && n.pubkey) {
try {
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
} catch (e) {
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
}
}
}
await loadPeers()
} catch (e) {
if (import.meta.env.DEV) console.error('Discover failed:', e)
} finally {
discovering.value = false
}
}
// --- 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('')
const useHardwareWallet = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
// 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 = ''
psbtData.value = ''
psbtStep.value = 'idle'
signedPsbtInput.value = ''
}
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()) {
unifiedSendError.value = t('web5.pasteInvoice')
return
}
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: unifiedSendDest.value.trim() },
})
sendResultHash.value = res.payment_hash
} else {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.enterBitcoinAddress')
return
}
if (useHardwareWallet.value) {
// 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
}
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) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
unifiedSendProcessing.value = false
}
}
async function finalizePsbt() {
if (!signedPsbtInput.value.trim() || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
try {
await rpcClient.finalizePsbt(signedPsbtInput.value.trim())
psbtStep.value = 'idle'
psbtData.value = ''
signedPsbtInput.value = ''
sendResultTxid.value = t('web5.broadcastViaHwWallet')
await loadLndBalances()
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
} finally {
unifiedSendProcessing.value = false
}
}
function copyPsbt() {
if (!psbtData.value) return
safeClipboardWrite(psbtData.value)
unifiedSendError.value = t('web5.psbtCopied')
}
function downloadPsbt() {
if (!psbtData.value) return
const blob = new Blob([psbtData.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transaction.psbt'
a.click()
URL.revokeObjectURL(url)
}
function handlePsbtFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
signedPsbtInput.value = (e.target?.result as string) || ''
}
reader.readAsText(file)
input.value = ''
}
async function unifiedReceive() {
if (unifiedReceiveProcessing.value) return
unifiedReceiveProcessing.value = true
unifiedReceiveError.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) {
unifiedReceiveError.value = t('web5.enterAmount')
return
}
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: receiveInvoiceAmount.value, memo: receiveInvoiceMemo.value },
})
receiveInvoiceResult.value = res.payment_request
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
receiveOnchainAddress.value = res.address
} else {
if (!ecashReceiveToken.value.trim()) {
unifiedReceiveError.value = t('web5.pasteEcashToken')
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) {
unifiedReceiveError.value = err instanceof Error ? err.message : t('web5.receiveFailed')
} finally {
unifiedReceiveProcessing.value = false
}
}
function copyEcashToken(token: string) {
safeClipboardWrite(token)
showIdentityToast(t('web5.ecashTokenCopied'))
}
async function safeClipboardWrite(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} 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)
}
}
function copyToClipboard(text: string, msg: string) {
safeClipboardWrite(text)
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()
showIdentityToast(t('web5.contentAdded'))
} catch (err: unknown) {
addContentError.value = err instanceof Error ? err.message : t('web5.failedToAddContent')
} finally {
addingContent.value = false
}
}
async function removeContentItem(id: string) {
removingContentId.value = id
try {
await rpcClient.call({ method: 'content.remove', params: { id } })
contentItems.value = contentItems.value.filter(i => i.id !== id)
showIdentityToast(t('web5.contentRemoved'))
} catch {
showIdentityToast(t('web5.failedToRemoveContent'))
} finally {
removingContentId.value = null
}
}
async function setContentPricing(item: ContentItemData, access: 'free' | 'peers_only' | 'paid') {
updatingPricingId.value = item.id
try {
const params: Record<string, unknown> = { id: item.id, access }
if (access === 'paid') {
params.price_sats = getItemPrice(item) || 100
}
await rpcClient.call({ method: 'content.set-pricing', params })
await loadContentItems()
} catch {
showIdentityToast(t('web5.failedToUpdatePricing'))
} finally {
updatingPricingId.value = null
}
}
async function updateItemPrice(item: ContentItemData, value: string) {
const price = parseInt(value, 10)
if (!price || price <= 0) return
updatingPricingId.value = item.id
try {
await rpcClient.call({
method: 'content.set-pricing',
params: { id: item.id, access: 'paid', price_sats: price },
})
await loadContentItems()
} catch {
showIdentityToast(t('web5.failedToUpdatePrice'))
} 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) {
browsePeerError.value = err instanceof Error ? err.message : t('web5.failedToConnectPeer')
} 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}`
showIdentityToast(t('web5.downloadUrlCopied'))
safeClipboardWrite(url)
}
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() {
playerError.value = t('web5.playerError')
}
function copyStreamUrl() {
if (streamUrl.value) {
safeClipboardWrite(streamUrl.value)
showIdentityToast(t('web5.streamUrlCopied'))
}
}
// --- 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()
showIdentityToast(t('web5.connectionAccepted'))
} catch {
showIdentityToast(t('web5.failedToAcceptRequest'))
} 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
showIdentityToast(t('web5.requestRejected'))
} catch {
showIdentityToast(t('web5.failedToRejectRequest'))
} 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' },
{ 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' },
]
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
showIdentityToast(t('web5.visibilitySetTo', { level }))
} catch {
showIdentityToast(t('web5.failedToUpdateVisibility'))
} finally {
settingVisibility.value = false
}
}
function copyOnionAddress() {
if (!nodeOnionAddress.value) return
safeClipboardWrite(nodeOnionAddress.value)
showIdentityToast(t('web5.onionAddressCopied'))
}
// --- 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()
showIdentityToast(t('web5.identityCreated'))
} catch (err: unknown) {
createIdentityError.value = err instanceof Error ? err.message : t('web5.failedToCreateIdentity')
} finally {
creatingIdentity.value = false
}
}
function copyIdentityDid(did: string) {
safeClipboardWrite(did)
showIdentityToast(t('web5.didCopied'))
}
async function setDefaultIdentity(id: string) {
try {
await rpcClient.call({ method: 'identity.set-default', params: { id } })
await loadIdentities()
showIdentityToast(t('web5.defaultIdentityUpdated'))
} catch {
showIdentityToast(t('web5.failedToSetDefault'))
}
}
function confirmDeleteIdentity(identity: ManagedIdentity) {
deleteIdentityTarget.value = identity
}
async function deleteIdentity() {
if (!deleteIdentityTarget.value || deletingIdentity.value) return
deletingIdentity.value = true
try {
await rpcClient.call({ method: 'identity.delete', params: { id: deleteIdentityTarget.value.id } })
deleteIdentityTarget.value = null
await loadIdentities()
showIdentityToast(t('web5.identityDeleted'))
} catch {
showIdentityToast(t('web5.failedToDeleteIdentity'))
} finally {
deletingIdentity.value = false
}
}
onMounted(() => {
loadPeers()
loadReceivedMessages()
loadIdentities()
loadVisibility()
loadConnectionRequests()
loadEcashBalance()
loadContentItems()
loadNetworkingProfits()
loadDwnStatus()
loadDwnProtocols()
loadDomainNames()
loadNostrRelays()
loadCredentials()
loadLndBalances()
detectHardwareWallets()
// 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' })
})
}
})
async function syncDWNs() {
syncingDWNs.value = true
dwnSyncStatus.value = 'syncing'
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 {
syncingDWNs.value = false
}
}
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
}
}
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() {
if (walletConnected.value) {
walletConnected.value = false
} else {
connectingWallet.value = true
await loadLndBalances()
connectingWallet.value = false
}
}
function manageRelays() {
showRelaysModal.value = true
}
</script>