refactor: split Web5.vue, Settings.vue, and Mesh.vue into focused subcomponents

- F25: Split Web5.vue (3940 lines) into 14 files under views/web5/
- F26: Split Mesh.vue (2106→840 lines) extracting Bitcoin and Deadman panels
- F27: Dashboard.vue assessed — layout shell, no split needed
- F28: Split Settings.vue (1792 lines) into AccountSection + SystemSection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-21 02:43:28 +00:00
parent 77f550fb5e
commit ea1b1f826b
21 changed files with 6325 additions and 3201 deletions

View File

@ -157,7 +157,7 @@ const router = createRouter({
{
path: 'web5',
name: 'web5',
component: () => import('../views/Web5.vue'),
component: () => import('../views/web5/Web5.vue'),
},
{
path: 'web5/credentials',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { rpcClient } from '@/api/rpc-client'
const mesh = useMeshStore()
const txHexInput = ref('')
const bolt11Input = ref('')
const bolt11AmountInput = ref('')
const relayingTx = ref(false)
const relayingLn = ref(false)
const relayResult = ref('')
const meshSendAddr = ref('')
const meshSendAmount = ref('')
const relayMode = ref<'archy' | 'broadcast'>('archy')
const sendTab = ref<'onchain' | 'lightning'>('onchain')
function pollRelayStatus(requestId: number) {
let attempts = 0
const maxAttempts = 30
const interval = setInterval(async () => {
attempts++
try {
const res = await mesh.relayStatus(requestId)
if (res.status === 'confirmed' && res.txid) {
relayResult.value = `TX broadcast! txid: ${res.txid.slice(0, 8)}...${res.txid.slice(-8)}`
clearInterval(interval)
} else if (res.status === 'failed') {
const code = res.error_code ? ` [${res.error_code}]` : ''
relayResult.value = `Relay failed${code}: ${res.error || 'unknown error'}`
clearInterval(interval)
} else if (attempts >= maxAttempts) {
relayResult.value += ' (timed out waiting for confirmation)'
clearInterval(interval)
}
} catch {
if (attempts >= maxAttempts) clearInterval(interval)
}
}, 3000)
}
async function handleMeshSendBitcoin() {
if (!meshSendAddr.value.trim() || !meshSendAmount.value) return
relayingTx.value = true
relayResult.value = ''
try {
relayResult.value = 'Creating signed transaction...'
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
method: 'lnd.create-raw-tx',
params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) },
})
relayResult.value = relayMode.value === 'broadcast'
? 'Broadcasting via mesh network...'
: 'Sending to Archy peers (encrypted)...'
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex, relayMode.value)
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for relay peer to broadcast...`
meshSendAddr.value = ''
meshSendAmount.value = ''
pollRelayStatus(relayRes.request_id)
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Send failed'
} finally {
relayingTx.value = false
}
}
async function handleRelayTx() {
if (!txHexInput.value.trim()) return
relayingTx.value = true
relayResult.value = ''
try {
const res = await mesh.relayTransaction(txHexInput.value.trim())
relayResult.value = `TX queued (request #${res.request_id})`
txHexInput.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
} finally {
relayingTx.value = false
}
}
async function handleRelayLightning() {
if (!bolt11Input.value.trim() || !bolt11AmountInput.value) return
relayingLn.value = true
relayResult.value = ''
try {
const res = await mesh.relayLightning(bolt11Input.value.trim(), parseInt(bolt11AmountInput.value))
relayResult.value = `Lightning relay queued (request #${res.request_id})`
bolt11Input.value = ''
bolt11AmountInput.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
} finally {
relayingLn.value = false
}
}
</script>
<template>
<div class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const mesh = useMeshStore()
const deadmanConfiguring = ref(false)
const deadmanInterval = ref('21600')
const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('')
// Sync from store on creation
if (mesh.deadmanStatus) {
deadmanEnabled.value = mesh.deadmanStatus.dead_man_enabled
deadmanInterval.value = String(mesh.deadmanStatus.dead_man_interval_secs)
}
function formatTimeRemaining(secs: number): string {
if (secs >= 86400) return `${Math.floor(secs / 3600)}h`
if (secs >= 3600) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`
if (secs >= 60) return `${Math.floor(secs / 60)}m ${secs % 60}s`
return `${secs}s`
}
async function handleDeadmanToggle() {
deadmanConfiguring.value = true
try {
await mesh.configureDeadman({ enabled: deadmanEnabled.value })
await mesh.fetchDeadmanStatus()
} finally {
deadmanConfiguring.value = false
}
}
async function handleDeadmanConfigure() {
deadmanConfiguring.value = true
try {
await mesh.configureDeadman({
enabled: deadmanEnabled.value,
interval_secs: parseInt(deadmanInterval.value) || 21600,
custom_message: deadmanCustomMsg.value || undefined,
})
await mesh.fetchDeadmanStatus()
} finally {
deadmanConfiguring.value = false
}
}
async function handleDeadmanCheckin() {
await mesh.deadmanCheckin()
await mesh.fetchDeadmanStatus()
}
</script>
<template>
<div class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<button
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
:class="deadmanEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
</div>
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
</button>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,867 @@
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import DOMPurify from 'dompurify'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
// Server name
const serverName = computed(() => store.serverName)
const editingServerName = ref(false)
const serverNameDraft = ref('')
const serverNameInput = ref<HTMLInputElement | null>(null)
function startEditServerName() {
serverNameDraft.value = serverName.value
editingServerName.value = true
nextTick(() => serverNameInput.value?.select())
}
async function saveServerName() {
const name = serverNameDraft.value.trim()
if (!name || name === serverName.value) {
editingServerName.value = false
return
}
try {
await rpcClient.call({ method: 'server.set-name', params: { name } })
store.updateServerName(name)
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
}
editingServerName.value = false
}
// Version & release notes
const version = computed(() => store.serverInfo?.version || '0.0.0')
const showReleaseNotes = ref(false)
// Identity
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
const torAddressFromRpc = ref<string | null>(null)
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)
const userDid = computed(() => {
try {
return localStorage.getItem('neode_did') || null
} catch {
return null
}
})
const copiedOnion = ref(false)
const copiedDid = ref(false)
let copiedTimer: ReturnType<typeof setTimeout> | null = null
async function copyOnionAddress() {
const addr = serverTorAddress.value
if (!addr) return
try {
await navigator.clipboard.writeText(addr)
} catch {
const ta = document.createElement('textarea')
ta.value = addr
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
copiedOnion.value = true
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => { copiedOnion.value = false }, 2000)
}
async function copyDid() {
if (!userDid.value) return
try {
await navigator.clipboard.writeText(userDid.value)
} catch {
const ta = document.createElement('textarea')
ta.value = userDid.value
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
copiedDid.value = true
setTimeout(() => { copiedDid.value = false }, 2000)
}
// Change password
const showChangePasswordModal = ref(false)
const changePasswordModalRef = ref<HTMLElement | null>(null)
const changePasswordRestoreFocusRef = ref<HTMLElement | null>(null)
useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePasswordModal, { restoreFocusRef: changePasswordRestoreFocusRef })
const changingPassword = ref(false)
const changePasswordError = ref('')
const changePasswordSuccess = ref('')
const changePasswordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
alsoChangeSsh: true,
})
function validatePasswordStrength(pw: string): string | null {
if (pw.length < 12) return t('settings.passwordMinLength')
if (!/[A-Z]/.test(pw)) return t('settings.passwordNeedUppercase')
if (!/[a-z]/.test(pw)) return t('settings.passwordNeedLowercase')
if (!/\d/.test(pw)) return t('settings.passwordNeedDigit')
if (!/[^A-Za-z0-9]/.test(pw)) return t('settings.passwordNeedSpecial')
return null
}
async function handleChangePassword() {
changePasswordError.value = ''
changePasswordSuccess.value = ''
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
if (!currentPassword || !newPassword || !confirmPassword) {
changePasswordError.value = t('settings.passwordAllFieldsRequired')
return
}
if (newPassword !== confirmPassword) {
changePasswordError.value = t('settings.passwordMismatch')
return
}
const strengthError = validatePasswordStrength(newPassword)
if (strengthError) {
changePasswordError.value = strengthError
return
}
changingPassword.value = true
try {
await rpcClient.changePassword({
currentPassword,
newPassword,
alsoChangeSsh,
})
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
setTimeout(() => {
closeChangePasswordModal()
}, 2000)
} catch (e) {
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
} finally {
changingPassword.value = false
}
}
function closeChangePasswordModal() {
changePasswordRestoreFocusRef.value?.focus?.()
showChangePasswordModal.value = false
changePasswordError.value = ''
changePasswordSuccess.value = ''
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
}
// 2FA / TOTP
const totpEnabled = ref(false)
const showTotpSetupModal = ref(false)
const showTotpDisableModal = ref(false)
const totpSetupStep = ref(1)
const totpSetupPassword = ref('')
const totpSetupCode = ref('')
const totpSetupError = ref('')
const totpSetupLoading = ref(false)
const totpQrSvg = ref('')
const sanitizedQrSvg = computed(() => DOMPurify.sanitize(totpQrSvg.value, { USE_PROFILES: { svg: true } }))
const totpSecretBase32 = ref('')
const showTotpSecret = ref(false)
const totpPendingToken = ref('')
const totpBackupCodes = ref<string[]>([])
const backupCodesCopied = ref(false)
const totpDisablePassword = ref('')
const totpDisableCode = ref('')
const totpDisableError = ref('')
const totpDisableLoading = ref(false)
async function loadTotpStatus() {
try {
const res = await rpcClient.totpStatus()
totpEnabled.value = res.enabled
} catch (e) {
if (import.meta.env.DEV) console.warn('TOTP status may not be available', e)
}
}
async function beginTotpSetup() {
totpSetupError.value = ''
totpSetupLoading.value = true
try {
const res = await rpcClient.totpSetupBegin(totpSetupPassword.value)
totpQrSvg.value = res.qr_svg
totpSecretBase32.value = res.secret_base32
totpPendingToken.value = res.pending_token
totpSetupStep.value = 2
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : t('settings.setupFailed')
} finally {
totpSetupLoading.value = false
}
}
async function confirmTotpSetup() {
totpSetupError.value = ''
totpSetupLoading.value = true
try {
const res = await rpcClient.totpSetupConfirm({
code: totpSetupCode.value,
password: totpSetupPassword.value,
pendingToken: totpPendingToken.value,
})
totpBackupCodes.value = res.backup_codes
totpEnabled.value = true
totpSetupStep.value = 3
} catch (e) {
totpSetupError.value = e instanceof Error ? e.message : t('settings.verificationFailed')
} finally {
totpSetupLoading.value = false
}
}
function closeTotpSetup() {
showTotpSetupModal.value = false
totpSetupStep.value = 1
totpSetupPassword.value = ''
totpSetupCode.value = ''
totpSetupError.value = ''
totpQrSvg.value = ''
totpSecretBase32.value = ''
totpPendingToken.value = ''
totpBackupCodes.value = []
backupCodesCopied.value = false
}
async function disableTotp() {
totpDisableError.value = ''
totpDisableLoading.value = true
try {
await rpcClient.totpDisable(totpDisablePassword.value, totpDisableCode.value)
totpEnabled.value = false
closeTotpDisable()
} catch (e) {
totpDisableError.value = e instanceof Error ? e.message : t('settings.disableFailed')
} finally {
totpDisableLoading.value = false
}
}
function closeTotpDisable() {
showTotpDisableModal.value = false
totpDisablePassword.value = ''
totpDisableCode.value = ''
totpDisableError.value = ''
}
async function copyBackupCodes() {
const text = totpBackupCodes.value.join('\n')
try {
await navigator.clipboard.writeText(text)
} catch {
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)
}
backupCodesCopied.value = true
setTimeout(() => { backupCodesCopied.value = false }, 2000)
}
// Logout
async function handleLogout() {
try { await store.logout() } catch (e) { if (import.meta.env.DEV) console.warn('Logout failed, proceeding anyway', e) }
router.push('/login').catch(() => { window.location.href = '/login' })
}
// Load on mount
async function init() {
loadTotpStatus()
if (!serverTorAddressFromStore.value) {
try {
const res = await rpcClient.getTorAddress()
torAddressFromRpc.value = res.tor_address ?? null
} catch (e) {
if (import.meta.env.DEV) console.warn('Tor address may not be available yet', e)
}
}
}
init()
</script>
<template>
<!-- Controller indicator - Mobile only -->
<div class="md:hidden mb-4">
<ControllerIndicator />
</div>
<!-- Account Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-6">{{ t('settings.account') }}</h2>
<!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Server Name Card (editable) -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-white/70" 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>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.serverName') }}</p>
</div>
<div v-if="editingServerName" class="flex items-center gap-2">
<input
ref="serverNameInput"
v-model="serverNameDraft"
type="text"
maxlength="64"
class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-lg text-white text-lg font-semibold focus:outline-none focus:border-white/40 transition-colors"
@keydown.enter="saveServerName"
@keydown.escape="editingServerName = false"
/>
<button
class="px-3 py-1.5 bg-white/10 border border-white/20 rounded-lg text-white/70 hover:text-white hover:bg-white/15 transition-colors text-sm"
@click="saveServerName"
>Save</button>
<button
class="px-3 py-1.5 text-white/50 hover:text-white/70 transition-colors text-sm"
@click="editingServerName = false"
>Cancel</button>
</div>
<div v-else class="flex items-center gap-2 group cursor-pointer" @click="startEditServerName">
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
<svg class="w-4 h-4 text-white/30 group-hover:text-white/60 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
</div>
<!-- Version Card -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('common.version') }}</p>
</div>
<div class="flex items-center justify-between">
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
<button
@click="showReleaseNotes = true"
class="glass-button px-3 py-1.5 text-xs"
>What's New</button>
</div>
</div>
<!-- Release Notes Modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showReleaseNotes" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="showReleaseNotes = false">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div @click.stop class="glass-card p-6 max-w-lg w-full relative z-10 flex flex-col" style="max-height: 85vh">
<div class="flex items-start justify-between gap-4 mb-5 shrink-0">
<h3 class="text-xl font-semibold text-white">What's New</h3>
<button @click="showReleaseNotes = false" class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors" aria-label="Close">
<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 class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.3.0 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.3.0</span>
<span class="text-xs text-white/40">Mar 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Full Security Audit</h4>
<p>33 security findings from a comprehensive penetration test all fixed. Backend now only accessible through nginx. Path traversal, SSRF, and XSS vulnerabilities eliminated. Federation requires cryptographic signatures. Session tokens rotate after 2FA. Destructive operations now require password confirmation.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Container Reliability</h4>
<p>Memory limits on every container prevent one app from crashing the whole system. Crashed apps now show a red "crashed" badge with a restart button instead of disappearing. Smart health status shows "starting up", "healthy", or "unhealthy" in real time. Apps you stop stay stopped no more auto-restart fighting.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Wallet on Home</h4>
<p>The Home dashboard now shows your Bitcoin wallet with on-chain, Lightning, and ecash balances. Send, receive, and view transaction history right from the home screen. New Transactions modal shows your full history with confirmations.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">LND Connect Fixed</h4>
<p>Connect Your Wallet (Zeus, Zap, BlueWallet) now works over both local network and Tor. QR codes generate correctly with REST API access.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">UI Polish</h4>
<p>Mesh view redesigned. New glass button styles throughout. Restart button on running apps. Improved app status badges. Cleaner navigation on the Apps page.</p>
</div>
</div>
</div>
<!-- alpha.9 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-white/10 text-white/60">v1.2.0-alpha.9</span>
<span class="text-xs text-white/40">Mar 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Security Hardening Complete</h4>
<p>All 12 pentest findings fixed. CSRF tokens now survive restarts. Password hashing upgraded to Argon2id. Bitcoin RPC gets a unique random password on every install. Federation messages require ed25519 signatures.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">7 Bugs Squashed</h4>
<p>Random logouts fixed (P0). Uninstall dialog is now a proper full-screen modal with an "Uninstalling..." overlay. App cards no longer flicker between Start/Launch during container scans. ElectrumX index estimate corrected.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Bitcoin Sync on Dashboard</h4>
<p>Homepage System card now shows Bitcoin Core sync progress, block height, and green/orange status indicator when Bitcoin is running.</p>
</div>
</div>
</div>
<!-- alpha.8 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-white/10 text-white/60">v1.2.0-alpha.8</span>
<span class="text-xs text-white/40">Mar 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Pentest Remediation (9/12)</h4>
<p>Fixed 9 of 12 security findings: session auth on LND connect info, DEV_MODE removed from production, ed25519 signature verification on node messages, path traversal protection, NIP-07 origin validation, AIUI session checks, strict onion validation.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">UI Polish Batch</h4>
<p>Fedimint renamed to "Fedimint Guardian". Tab-launch icons. Marketplace sorts installed apps to end. Mesh mobile layout fixed. On-Chain first in receive modals. Federation shows names instead of DIDs. Cleaner iframe error screens.</p>
</div>
</div>
</div>
<!-- alpha.7 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-white/10 text-white/60">v1.2.0-alpha.7</span>
<span class="text-xs text-white/40">Mar 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Marketplace & Credentials</h4>
<p>29 containers running rootless. Marketplace app aliases working. Credential injection for inter-container authentication.</p>
</div>
</div>
</div>
<!-- alpha.4-6 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-white/10 text-white/60">v1.2.0-alpha.4-6</span>
<span class="text-xs text-white/40">Mar 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Rootless Podman Migration</h4>
<p>Migrated all containers from root to rootless Podman. UID namespace mapping, volume ownership fixes, sysctl tuning. Bitcoin RPC verified, all web services confirmed healthy. 29 containers up and running.</p>
</div>
</div>
</div>
<!-- alpha.2-3 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-white/10 text-white/60">v1.2.0-alpha.2-3</span>
<span class="text-xs text-white/40">Mar 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Systemd Hardening Restored</h4>
<p>Full systemd security sandbox restored now that containers run rootless. NoNewPrivileges, restricted namespaces, and system call filtering re-enabled. Session persistence and boot sequence fixes.</p>
</div>
</div>
</div>
<!-- alpha.1 -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-white/10 text-white/60">v1.2.0-alpha.1</span>
<span class="text-xs text-white/40">Mar 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<div>
<h4 class="text-white font-medium mb-1">Mesh Radio & Container Stability</h4>
<p>LoRa mesh radio auto-detects USB port changes with a new Connect button. Fixed container crash loops all apps start cleanly and stay stable. Apps starting up show progress instead of re-appearing in the store. Tor routing enabled by default for Bitcoin and Lightning.</p>
</div>
<div>
<h4 class="text-white font-medium mb-1">Off-Grid Bitcoin</h4>
<p>Receive Bitcoin block headers over mesh radio. Dead man's switch broadcasts location to trusted contacts if you go silent. GPS sharing is opt-in only.</p>
</div>
</div>
</div>
</div>
<button @click="showReleaseNotes = false" class="glass-button w-full mt-4 py-2 text-sm shrink-0">Close</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Session Card -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.sessionStatus') }}</p>
</div>
<p class="text-base font-medium text-white/90">{{ t('settings.loggedIn') }}</p>
</div>
<!-- Identity Card: DID + Tor Address -->
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
<div v-if="userDid">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.yourDid') }}</p>
</div>
<button
@click="copyDid"
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
>
<svg v-if="!copiedDid" 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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
<span v-if="!copiedDid">{{ t('common.copy') }}</span>
</button>
</div>
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
<p class="text-xs text-white/50 mt-1">{{ t('settings.didHelper') }}</p>
</div>
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
</div>
<p class="text-sm font-mono text-amber-400/90 break-all mb-1" :title="serverTorAddress">{{ serverTorAddress }}</p>
<p class="text-xs text-white/50 mb-3">{{ t('settings.onionHelper') }}</p>
<button
@click="copyOnionAddress"
class="w-full min-h-[44px] rounded-lg glass-button text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<svg v-if="!copiedOnion" 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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span v-if="!copiedOnion">{{ t('common.copy') }}</span>
<span v-else class="text-green-400">{{ t('common.copied') }}</span>
</button>
</div>
</div>
</div>
<!-- Change Password -->
<div data-controller-container tabindex="0" class="mb-6">
<button
@click="showChangePasswordModal = true"
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
>
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span>{{ t('settings.changePassword') }}</span>
</button>
</div>
<!-- Change Password Modal -->
<Teleport to="body">
<div
v-if="showChangePasswordModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="closeChangePasswordModal()"
>
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('settings.changePasswordTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">{{ t('settings.changePasswordDesc') }}</p>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.currentPassword') }}</label>
<input
v-model="changePasswordForm.currentPassword"
type="password"
required
autocomplete="current-password"
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('login.enterPasswordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.newPassword') }}</label>
<input
v-model="changePasswordForm.newPassword"
type="password"
required
autocomplete="new-password"
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('settings.passwordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.confirmNewPassword') }}</label>
<input
v-model="changePasswordForm.confirmPassword"
type="password"
required
autocomplete="new-password"
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('settings.confirmNewPassword')"
/>
</div>
<label class="flex items-center gap-2 text-sm text-white/80">
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
{{ t('settings.updateSshCheckbox') }}
</label>
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
<div class="flex gap-3 pt-2">
<button
type="submit"
:disabled="changingPassword"
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"
>
{{ changingPassword ? t('settings.updatingPassword') : t('settings.updatePassword') }}
</button>
<button
type="button"
@click="closeChangePasswordModal"
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>
</form>
</div>
</div>
</Teleport>
<!-- Two-Factor Authentication -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/70" 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>
<p class="text-sm font-medium text-white/90">{{ t('settings.twoFactorAuth') }}</p>
<p class="text-xs text-white/50">{{ t('settings.twoFaProtect') }}</p>
</div>
</div>
<span
class="text-xs font-semibold px-2 py-1 rounded-full"
:class="totpEnabled ? 'status-success' : 'bg-white/10 text-white/50'"
>
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<button
v-if="!totpEnabled"
@click="showTotpSetupModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium"
>
<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="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>
<span>{{ t('settings.enable2fa') }}</span>
</button>
<button
v-else
@click="showTotpDisableModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-danger font-medium"
>
<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="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span>{{ t('settings.disable2fa') }}</span>
</button>
</div>
<!-- TOTP Setup Modal -->
<Teleport to="body">
<div
v-if="showTotpSetupModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="closeTotpSetup"
@keydown.escape="closeTotpSetup"
>
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-setup-title">
<template v-if="totpSetupStep === 1">
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.setup2faTitle') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.setup2faPasswordPrompt') }}</p>
<form @submit.prevent="beginTotpSetup" class="space-y-4">
<input
v-model="totpSetupPassword"
type="password"
required
autocomplete="current-password"
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('login.enterPasswordPlaceholder')"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpSetupLoading"
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"
>
{{ totpSetupLoading ? t('common.loading') : t('common.continue') }}
</button>
<button type="button" @click="closeTotpSetup" 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>
</form>
</template>
<template v-else-if="totpSetupStep === 2">
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="sanitizedQrSvg" />
<div v-if="totpSecretBase32" class="bg-black/30 rounded-lg px-3 py-2 mb-4">
<p class="text-xs text-white/50 mb-1">Manual entry key (keep secret!):</p>
<div v-if="showTotpSecret" class="flex items-center gap-2">
<p class="text-sm font-mono text-orange-400 break-all">{{ totpSecretBase32 }}</p>
<button type="button" class="glass-button text-xs px-2 py-1" @click="showTotpSecret = false">Hide</button>
</div>
<button v-else type="button" class="glass-button text-xs px-3 py-1" @click="showTotpSecret = true">
Show manual entry key
</button>
</div>
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
<input
v-model="totpSetupCode"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
:placeholder="t('login.totpPlaceholder')"
/>
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
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"
>
{{ totpSetupLoading ? t('login.verifying') : t('settings.verifyAndEnable') }}
</button>
<button type="button" @click="closeTotpSetup" 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>
</form>
</template>
<template v-else-if="totpSetupStep === 3">
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.saveBackupCodes') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.backupCodesInstruction') }}</p>
<div class="bg-black/30 rounded-xl p-4 mb-4">
<div class="grid grid-cols-2 gap-2">
<div
v-for="(code, i) in totpBackupCodes"
:key="i"
class="text-sm font-mono text-white/90 bg-white/5 rounded px-3 py-2 text-center"
>
{{ code }}
</div>
</div>
</div>
<button
@click="copyBackupCodes"
class="w-full mb-3 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border border-white/20 text-white/80 font-medium hover:bg-white/5 transition-colors"
>
<svg v-if="!backupCodesCopied" 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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>{{ backupCodesCopied ? t('common.copiedBang') : t('settings.copyAllCodes') }}</span>
</button>
<button
@click="closeTotpSetup"
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
>
{{ t('common.done') }}
</button>
</template>
</div>
</div>
</Teleport>
<!-- TOTP Disable Modal -->
<Teleport to="body">
<div
v-if="showTotpDisableModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="closeTotpDisable"
@keydown.escape="closeTotpDisable"
>
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-disable-title">
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.disable2faTitle') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.disable2faDesc') }}</p>
<form @submit.prevent="disableTotp" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('login.password') }}</label>
<input
v-model="totpDisablePassword"
type="password"
required
autocomplete="current-password"
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('login.enterPasswordPlaceholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.authenticatorCode') }}</label>
<input
v-model="totpDisableCode"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
required
autocomplete="one-time-code"
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
:placeholder="t('login.totpPlaceholder')"
/>
</div>
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="totpDisableLoading"
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ totpDisableLoading ? t('common.disabling') : t('settings.disable2fa') }}
</button>
<button type="button" @click="closeTotpDisable" 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>
</form>
</div>
</div>
</Teleport>
<!-- Logout Button -->
<button
@click="handleLogout"
class="w-full path-action-button path-action-button--continue flex items-center justify-center gap-2"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>{{ t('settings.logout') }}</span>
</button>
</div>
</template>

View File

@ -0,0 +1,913 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n'
import { useUIModeStore } from '@/stores/uiMode'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import { rpcClient } from '@/api/rpc-client'
import type { UIMode } from '@/types/api'
const router = useRouter()
const { t, locale } = useI18n()
const uiMode = useUIModeStore()
const aiPermissions = useAIPermissionsStore()
// Interface modes
const interfaceModes = computed<{ id: UIMode; label: string; description: string; iconPaths: string[] }[]>(() => [
{
id: 'easy',
label: t('settings.modeEasy'),
description: t('settings.modeEasyDesc'),
iconPaths: ['M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
},
{
id: 'gamer',
label: t('settings.modePro'),
description: t('settings.modeProDesc'),
iconPaths: ['M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'],
},
{
id: 'chat',
label: t('settings.modeChat'),
description: t('settings.modeChatDesc'),
iconPaths: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
},
])
// Language
const supportedLocales = SUPPORTED_LOCALES
const currentLocale = computed(() => locale.value)
async function changeLocale(code: string) {
await setLocale(code as SupportedLocale)
}
// AI Data Access
const aiCategoryGroups = computed(() => {
const groups: { label: string; items: typeof AI_PERMISSION_CATEGORIES }[] = []
for (const cat of AI_PERMISSION_CATEGORIES) {
const existing = groups.find(g => g.label === cat.group)
if (existing) {
existing.items.push(cat)
} else {
groups.push({ label: cat.group, items: [cat] })
}
}
return groups
})
// Claude Auth
const claudeConnected = ref(false)
const showClaudeLoginModal = ref(false)
function checkClaudeStatus() {
fetch('/aiui/api/claude/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'haiku', messages: [{ role: 'user', content: 'ping' }] }) })
.then(r => {
if (!r.ok) { claudeConnected.value = false; return }
const reader = r.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let text = ''
function read(): Promise<void> {
return reader!.read().then(({ done, value }) => {
if (done) {
claudeConnected.value = !text.includes('Not logged in') && !text.includes('error')
return
}
text += decoder.decode(value, { stream: true })
return read()
})
}
read()
})
.catch(() => { claudeConnected.value = false })
}
function onClaudeIframeLoad() {
window.addEventListener('message', handleClaudeLoginMessage)
}
function handleClaudeLoginMessage(e: MessageEvent) {
if (e.data?.type === 'claude-auth-success') {
claudeConnected.value = true
showClaudeLoginModal.value = false
window.removeEventListener('message', handleClaudeLoginMessage)
}
}
// Telemetry
const telemetryEnabled = ref(false)
const telemetryLoading = ref(false)
async function loadTelemetryStatus() {
try {
const res = await rpcClient.call<{ enabled: boolean }>({ method: 'analytics.get-status' })
telemetryEnabled.value = res.enabled
} catch { /* ignore */ }
}
async function toggleTelemetry() {
telemetryLoading.value = true
try {
const method = telemetryEnabled.value ? 'analytics.disable' : 'analytics.enable'
await rpcClient.call({ method })
telemetryEnabled.value = !telemetryEnabled.value
} catch { /* ignore */ }
telemetryLoading.value = false
}
// Webhook Notifications
interface WebhookConfigData {
enabled: boolean
url: string
secret: string
events: string[]
}
const webhookConfig = ref<WebhookConfigData>({
enabled: false,
url: '',
secret: '',
events: [],
})
const savingWebhook = ref(false)
const testingWebhook = ref(false)
const webhookStatusMsg = ref('')
const webhookStatusType = ref<'success' | 'error'>('success')
const webhookEventTypes = computed(() => [
{ id: 'container_crash', label: t('settings.containerCrash'), description: t('settings.containerCrashDesc') },
{ id: 'update_available', label: t('settings.updateAvailableEvent'), description: t('settings.updateAvailableDesc') },
{ id: 'disk_warning', label: t('settings.diskSpaceWarning'), description: t('settings.diskWarningDesc') },
{ id: 'backup_complete', label: t('settings.backupComplete'), description: t('settings.backupCompleteDesc') },
])
function showWebhookStatus(msg: string, type: 'success' | 'error') {
webhookStatusMsg.value = msg
webhookStatusType.value = type
setTimeout(() => { webhookStatusMsg.value = '' }, 5000)
}
function toggleWebhookEvent(id: string) {
const idx = webhookConfig.value.events.indexOf(id)
if (idx >= 0) {
webhookConfig.value.events.splice(idx, 1)
} else {
webhookConfig.value.events.push(id)
}
}
function toggleWebhookEnabled() {
webhookConfig.value.enabled = !webhookConfig.value.enabled
}
async function loadWebhookConfig() {
try {
const res = await rpcClient.call<{ enabled: boolean; url: string; events: string[]; has_secret: boolean }>({ method: 'webhook.get-config' })
webhookConfig.value.enabled = res.enabled
webhookConfig.value.url = res.url
webhookConfig.value.events = res.events || []
} catch {
// Webhook system may not be available
}
}
async function saveWebhookConfig() {
savingWebhook.value = true
try {
await rpcClient.call({
method: 'webhook.configure',
params: {
enabled: webhookConfig.value.enabled,
url: webhookConfig.value.url,
secret: webhookConfig.value.secret || null,
events: webhookConfig.value.events,
},
})
showWebhookStatus(t('settings.webhookSaved'), 'success')
} catch {
showWebhookStatus(t('settings.webhookSaveFailed'), 'error')
} finally {
savingWebhook.value = false
}
}
async function testWebhook() {
testingWebhook.value = true
try {
const res = await rpcClient.call<{ sent: boolean; url: string }>({ method: 'webhook.test' })
if (res.sent) {
showWebhookStatus(t('settings.webhookTestSent'), 'success')
} else {
showWebhookStatus(t('settings.webhookTestFailed'), 'error')
}
} catch {
showWebhookStatus(t('settings.webhookSendFailed'), 'error')
} finally {
testingWebhook.value = false
}
}
// Backup & Restore
interface BackupEntry {
id: string
created_at: string
size_bytes: number
encrypted: boolean
description: string | null
}
const backupList = ref<BackupEntry[]>([])
const loadingBackups = ref(false)
const showCreateBackupModal = ref(false)
const backupPassphrase = ref('')
const backupDescription = ref('')
const creatingBackup = ref(false)
const showRestoreModal = ref(false)
const restoreBackupId = ref('')
const restorePassphrase = ref('')
const restoringBackup = ref(false)
const verifyingBackupId = ref<string | null>(null)
const deletingBackupId = ref<string | null>(null)
const backupStatusMsg = ref('')
const backupStatusType = ref<'success' | 'error'>('success')
function formatBackupSize(bytes: number): string {
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`
}
function showBackupStatus(msg: string, type: 'success' | 'error') {
backupStatusMsg.value = msg
backupStatusType.value = type
setTimeout(() => { backupStatusMsg.value = '' }, 5000)
}
async function loadBackups() {
loadingBackups.value = true
try {
const res = await rpcClient.call<{ backups: BackupEntry[] }>({ method: 'backup.list' })
backupList.value = res.backups || []
} catch {
backupList.value = []
} finally {
loadingBackups.value = false
}
}
async function createBackup() {
if (creatingBackup.value || !backupPassphrase.value) return
creatingBackup.value = true
try {
await rpcClient.call({ method: 'backup.create', params: { passphrase: backupPassphrase.value, description: backupDescription.value || undefined } })
showCreateBackupModal.value = false
backupPassphrase.value = ''
backupDescription.value = ''
showBackupStatus(t('settings.backupCreatedSuccess'), 'success')
await loadBackups()
} catch {
showBackupStatus(t('settings.backupCreateFailed'), 'error')
} finally {
creatingBackup.value = false
}
}
async function verifyBackup(id: string) {
const passphrase = prompt(t('settings.verifyPassphrasePrompt'))
if (!passphrase) return
verifyingBackupId.value = id
try {
const res = await rpcClient.call<{ valid: boolean; error: string | null }>({ method: 'backup.verify', params: { id, passphrase } })
if (res.valid) {
showBackupStatus(t('settings.backupVerifiedOk'), 'success')
} else {
showBackupStatus(t('settings.backupVerifyFailed', { error: res.error || 'Unknown error' }), 'error')
}
} catch {
showBackupStatus(t('settings.backupVerifyRequestFailed'), 'error')
} finally {
verifyingBackupId.value = null
}
}
function confirmRestoreBackup(id: string) {
restoreBackupId.value = id
restorePassphrase.value = ''
showRestoreModal.value = true
}
async function restoreBackup() {
if (restoringBackup.value || !restorePassphrase.value) return
restoringBackup.value = true
try {
await rpcClient.call({ method: 'backup.restore', params: { id: restoreBackupId.value, passphrase: restorePassphrase.value } })
showRestoreModal.value = false
showBackupStatus(t('settings.backupRestored'), 'success')
} catch {
showBackupStatus(t('settings.backupRestoreFailed'), 'error')
} finally {
restoringBackup.value = false
}
}
async function deleteBackup(id: string) {
if (!confirm(t('settings.deleteBackupConfirm'))) return
deletingBackupId.value = id
try {
await rpcClient.call({ method: 'backup.delete', params: { id } })
showBackupStatus(t('settings.backupDeleted'), 'success')
await loadBackups()
} catch {
showBackupStatus(t('settings.backupDeleteFailed'), 'error')
} finally {
deletingBackupId.value = null
}
}
// USB Drive Backup
interface UsbDriveInfo {
device: string
mount_point: string | null
label: string | null
size_bytes: number
removable: boolean
}
const usbCopyingId = ref<string | null>(null)
async function backupToUsb(backupId: string) {
usbCopyingId.value = backupId
try {
const drivesRes = await rpcClient.call<{ drives: UsbDriveInfo[] }>({ method: 'backup.list-drives' })
const drives = drivesRes.drives || []
const mounted = drives.filter(d => d.mount_point)
const target = mounted[0]
if (!target?.mount_point) {
showBackupStatus(t('settings.noUsbDrives'), 'error')
return
}
const label = target.label || target.device
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
} catch {
showBackupStatus(t('settings.backupUsbFailed'), 'error')
} finally {
usbCopyingId.value = null
}
}
// Lightning channel backup
const exportingChannelBackup = ref(false)
const channelBackupData = ref('')
const channelBackupChannels = ref(0)
const channelBackupTime = ref('')
const channelBackupError = ref('')
const channelBackupCopied = ref(false)
async function exportChannelBackup() {
exportingChannelBackup.value = true
channelBackupError.value = ''
try {
const res = await rpcClient.call<{ backup: string; channel_count: number; timestamp: string }>({
method: 'lnd.export-channel-backup',
timeout: 15000,
})
channelBackupData.value = res.backup
channelBackupChannels.value = res.channel_count
channelBackupTime.value = new Date(res.timestamp).toLocaleString()
} catch (err: unknown) {
channelBackupError.value = err instanceof Error ? err.message : 'Failed to export'
} finally {
exportingChannelBackup.value = false
}
}
function copyChannelBackup() {
if (channelBackupData.value) {
navigator.clipboard.writeText(channelBackupData.value).catch(() => {})
channelBackupCopied.value = true
setTimeout(() => { channelBackupCopied.value = false }, 2000)
}
}
// Reboot
const showRebootConfirm = ref(false)
const rebooting = ref(false)
const rebootPassword = ref('')
const rebootError = ref('')
async function performReboot() {
if (!rebootPassword.value) return
rebooting.value = true
rebootError.value = ''
try {
await rpcClient.call({ method: 'system.reboot', params: { password: rebootPassword.value } })
showRebootConfirm.value = false
rebootPassword.value = ''
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Reboot failed'
rebooting.value = false
}
}
// Factory Reset
const showFactoryResetConfirm = ref(false)
const factoryResetLoading = ref(false)
async function performFactoryReset() {
factoryResetLoading.value = true
try {
await rpcClient.call({ method: 'system.factory-reset', params: { confirm: true } })
localStorage.clear()
showFactoryResetConfirm.value = false
router.push('/onboarding/intro')
} catch {
localStorage.clear()
showFactoryResetConfirm.value = false
router.push('/onboarding/intro')
}
}
// Load on mount
function init() {
checkClaudeStatus()
loadTelemetryStatus()
loadBackups()
loadWebhookConfig()
}
init()
</script>
<template>
<!-- Interface Mode Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
v-for="m in interfaceModes"
:key="m.id"
@click="uiMode.setMode(m.id)"
class="path-option-card text-left p-5"
:class="{ 'path-option-card--selected': uiMode.mode === m.id }"
>
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in m.iconPaths"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<h3 class="text-lg font-semibold text-white/96">{{ m.label }}</h3>
</div>
<p class="text-sm text-white/60 leading-relaxed">{{ m.description }}</p>
</button>
</div>
</div>
<!-- Language Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Language</h2>
<p class="text-sm text-white/60 mb-4">Choose your preferred language</p>
<div class="flex gap-3 flex-wrap">
<button
v-for="loc in supportedLocales"
:key="loc.code"
@click="changeLocale(loc.code)"
class="glass-button px-4 py-2 rounded-lg text-sm font-medium transition-all"
:class="currentLocale === loc.code ? 'ring-2 ring-orange-400/60 bg-white/10' : ''"
>
<span class="mr-2">{{ loc.flag }}</span>{{ loc.name }}
</button>
</div>
</div>
<!-- Claude Authentication Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.connectionStatus') }}</p>
</div>
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
</p>
</div>
<button
@click="showClaudeLoginModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
:class="claudeConnected
? 'border-white/20 text-white/70 hover:bg-white/5'
: 'glass-button-warning font-medium'"
>
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<span>{{ claudeConnected ? t('settings.reAuthenticate') : t('settings.loginWithClaude') }}</span>
</button>
</div>
<!-- Claude Login Modal -->
<Teleport to="body">
<div
v-if="showClaudeLoginModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="showClaudeLoginModal = false"
>
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 class="text-sm font-semibold text-white/80">{{ t('settings.claudeAuth') }}</h3>
<button @click="showClaudeLoginModal = false" class="text-white/50 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>
<iframe
src="/claude-login"
class="w-full border-0"
style="height: calc(100% - 49px)"
@load="onClaudeIframeLoad"
/>
</div>
</div>
</Teleport>
<!-- AI Data Access Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="mb-2">
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.aiDataAccess') }}</h2>
</div>
<p class="text-sm text-white/60 mb-6">{{ t('settings.aiDataAccessDesc') }}</p>
<button
@click="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-6"
:class="aiPermissions.allEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.allEnabled ? 'text-orange-400' : 'text-white/40'" 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 class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
</div>
<ToggleSwitch :model-value="aiPermissions.allEnabled" @update:model-value="aiPermissions.allEnabled ? aiPermissions.disableAll() : aiPermissions.enableAll()" @click.stop />
</button>
<div class="space-y-5">
<div v-for="group in aiCategoryGroups" :key="group.label">
<p class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 px-1">{{ group.label }}</p>
<div class="space-y-2">
<button
v-for="cat in group.items"
:key="cat.id"
@click="aiPermissions.toggle(cat.id)"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="aiPermissions.isEnabled(cat.id)
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.isEnabled(cat.id) ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="cat.icon" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
</div>
<ToggleSwitch :model-value="aiPermissions.isEnabled(cat.id)" @update:model-value="aiPermissions.toggle(cat.id)" @click.stop />
</button>
</div>
</div>
</div>
</div>
<!-- System Updates Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
</div>
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ t('common.manageUpdates') }}
</RouterLink>
</div>
</div>
<!-- Webhook Notifications Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
</div>
<ToggleSwitch :model-value="webhookConfig.enabled" @update:model-value="toggleWebhookEnabled" />
</div>
<div class="space-y-4">
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookUrlLabel') }}</label>
<input
v-model="webhookConfig.url"
type="url"
:placeholder="t('settings.webhookUrlPlaceholder')"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookSecretLabel') }}</label>
<input
v-model="webhookConfig.secret"
type="password"
:placeholder="t('settings.webhookSecretPlaceholderFull')"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div>
<label class="text-xs text-white/50 block mb-2">{{ t('settings.eventsToNotify') }}</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
v-for="evt in webhookEventTypes"
:key="evt.id"
@click="toggleWebhookEvent(evt.id)"
role="checkbox"
:aria-checked="webhookConfig.events.includes(evt.id)"
:aria-label="evt.label"
class="flex items-center gap-3 p-3 rounded-lg border transition-colors text-left"
:class="webhookConfig.events.includes(evt.id)
? 'bg-orange-500/10 border-orange-500/30'
: 'bg-white/5 border-white/10 hover:border-white/20'"
>
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
:class="webhookConfig.events.includes(evt.id)
? 'border-orange-500 bg-orange-500'
: 'border-white/30'"
>
<svg v-if="webhookConfig.events.includes(evt.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="min-w-0">
<p class="text-sm text-white/90 font-medium">{{ evt.label }}</p>
<p class="text-xs text-white/50">{{ evt.description }}</p>
</div>
</button>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2 pt-2">
<button
@click="saveWebhookConfig"
:disabled="savingWebhook"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
>
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
</button>
<button
@click="testWebhook"
:disabled="testingWebhook || !webhookConfig.url"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
>
{{ testingWebhook ? t('common.sending') : t('common.sendTest') }}
</button>
</div>
</div>
<div v-if="webhookStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="webhookStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ webhookStatusMsg }}
</div>
</div>
<!-- Beta Telemetry Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-3">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Beta Telemetry</h2>
<p class="text-sm text-white/60">Help improve Archipelago by sharing anonymous system health data. No wallet data, no keys, no personal info.</p>
</div>
<button
@click="toggleTelemetry"
:disabled="telemetryLoading"
class="shrink-0 ml-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="telemetryEnabled ? 'glass-button glass-button-success' : 'glass-button'"
>
{{ telemetryLoading ? '...' : telemetryEnabled ? 'Enabled' : 'Enable' }}
</button>
</div>
<div v-if="telemetryEnabled" class="mt-3 text-xs text-white/50 space-y-1">
<p>Reporting: version, uptime, container states, CPU/RAM, error alerts.</p>
<p>Not reporting: wallet balances, private keys, DIDs, IP addresses.</p>
</div>
</div>
<!-- Backup & Restore Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('settings.backup') }}</h2>
<p class="text-sm text-white/60 mb-3">{{ t('settings.backupRestoreDesc') }}</p>
<button @click="showCreateBackupModal = 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>
{{ t('settings.createBackup') }}
</button>
</div>
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
<div v-else class="space-y-2">
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
<div class="min-w-0">
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
<div class="text-xs text-white/50">{{ new Date(b.created_at).toLocaleString() }} &middot; {{ formatBackupSize(b.size_bytes) }}</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" :title="t('common.verify')">
{{ verifyingBackupId === b.id ? '...' : t('common.verify') }}
</button>
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
{{ usbCopyingId === b.id ? '...' : 'USB' }}
</button>
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
{{ t('common.restore') }}
</button>
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" :aria-label="t('settings.deleteBackup')" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" :title="t('common.delete')">
&times;
</button>
</div>
</div>
</div>
<div v-if="backupStatusMsg" role="status" aria-live="polite" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'alert-error' : 'alert-success'">
{{ backupStatusMsg }}
</div>
</div>
<!-- Create Backup Modal -->
<Teleport to="body">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showCreateBackupModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
<input v-model="backupPassphrase" type="password" :placeholder="t('settings.enterPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.descriptionOptional') }}</label>
<input v-model="backupDescription" type="text" :placeholder="t('settings.descriptionPlaceholder')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
</div>
<div class="flex gap-3 mt-5">
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Restore Backup Modal -->
<Teleport to="body">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showRestoreModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
<div>
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
<input v-model="restorePassphrase" type="password" :placeholder="t('settings.enterBackupPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<div class="flex gap-3 mt-5">
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button glass-button-danger px-4 py-2 rounded-lg text-sm flex-1 disabled:opacity-50">
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Lightning Channel Backup -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning Channel Backup</h2>
<p class="text-sm text-white/60 mb-3">Export your channel state so you can restore channels on a new node. Does not include on-chain wallet seed.</p>
<div class="flex gap-3">
<button @click="exportChannelBackup" :disabled="exportingChannelBackup" class="glass-button px-4 py-2 rounded-lg text-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ exportingChannelBackup ? 'Exporting...' : 'Export Channel Backup' }}
</button>
</div>
<div v-if="channelBackupData" class="mt-3 bg-black/30 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">{{ channelBackupChannels }} channel{{ channelBackupChannels !== 1 ? 's' : '' }} backed up at {{ channelBackupTime }}</p>
<textarea readonly :value="channelBackupData" rows="3" class="w-full bg-black/20 text-xs font-mono text-white/60 rounded p-2 resize-none border border-white/10"></textarea>
<button @click="copyChannelBackup" class="mt-2 glass-button px-3 py-1.5 rounded text-xs">{{ channelBackupCopied ? 'Copied!' : 'Copy Backup Data' }}</button>
</div>
<p v-if="channelBackupError" class="mt-2 text-xs text-red-400">{{ channelBackupError }}</p>
</div>
<!-- Network Diagnostics Link -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.networkDesc') }}</p>
</div>
<button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm 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="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
{{ t('common.networkDiagnostics') }}
</button>
</div>
</div>
<!-- Reboot Section -->
<div class="path-option-card px-6 py-6 mt-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
<p class="text-sm text-white/60">Restart the machine. All containers will restart automatically.</p>
</div>
<button
class="glass-button px-6 py-2 text-sm"
:disabled="rebooting"
@click="showRebootConfirm = true"
>
{{ rebooting ? 'Rebooting...' : 'Reboot' }}
</button>
</div>
</div>
<!-- Reboot Confirmation Modal -->
<Teleport to="body">
<div v-if="showRebootConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showRebootConfirm = false">
<div class="glass-card px-8 py-8 max-w-md mx-4">
<h3 class="text-lg font-semibold text-white/90 mb-3">Reboot Node</h3>
<p class="text-sm text-white/60 mb-4">Enter your password to confirm reboot. The node will be temporarily unavailable.</p>
<input
v-model="rebootPassword"
type="password"
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 mb-4"
placeholder="Password"
@keydown.enter="performReboot"
/>
<p v-if="rebootError" class="text-sm text-red-400 mb-3">{{ rebootError }}</p>
<div class="flex gap-3 justify-end">
<button class="glass-button" @click="showRebootConfirm = false">Cancel</button>
<button
class="glass-button px-6"
:disabled="rebooting || !rebootPassword"
@click="performReboot"
>
{{ rebooting ? 'Rebooting...' : 'Confirm Reboot' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Factory Reset Section -->
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30">
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
<p class="text-sm text-white/60 mb-4">
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
</p>
<button
class="glass-button glass-button-danger"
@click="showFactoryResetConfirm = true"
>
Factory Reset
</button>
</div>
<!-- Factory Reset Confirmation Modal -->
<Teleport to="body">
<div v-if="showFactoryResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="glass-card px-8 py-8 max-w-md mx-4">
<h3 class="text-lg font-semibold text-white/90 mb-3">Are you sure?</h3>
<p class="text-sm text-white/60 mb-6">
This will delete all identities, credentials, and settings. This cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button class="glass-button" @click="showFactoryResetConfirm = false">Cancel</button>
<button
class="glass-button glass-button-danger"
:disabled="factoryResetLoading"
@click="performFactoryReset"
>
{{ factoryResetLoading ? 'Resetting...' : 'Yes, Reset' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>

View File

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

View File

@ -0,0 +1,449 @@
<template>
<!-- Connected Nodes (P2P over Tor) -->
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
<!-- Desktop: side-by-side layout -->
<div class="hidden md:flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
<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 flex-1 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="router.push('/dashboard/mesh')"
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 flex-1 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">{{ peerNameFromPubkey(m.from_pubkey) }}</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 flex-1 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">{{ peerNameFromPubkey(req.from_did) }}</p>
<p v-if="req.message" class="text-sm text-white/80 mt-1 break-words">{{ req.message }}</p>
<p class="text-xs text-white/40 mt-1">{{ formatMessageTime(req.created_at) }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="acceptRequest(req.id)"
:disabled="processingRequestId === req.id"
class="px-3 py-1.5 text-xs rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 transition-colors disabled:opacity-50"
>
{{ t('web5.accept') }}
</button>
<button
@click="rejectRequest(req.id)"
:disabled="processingRequestId === req.id"
class="px-3 py-1.5 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{{ t('web5.reject') }}
</button>
</div>
</div>
</div>
</div>
<div class="mt-auto pt-4">
<button
v-if="nodesContainerTab === 'peers'"
@click="discoverAndAddPeers"
:disabled="discovering"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
</button>
<button
v-else-if="nodesContainerTab === 'messages'"
@click="loadReceivedMessages"
:disabled="loadingMessages"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
</button>
<button
v-else
@click="loadConnectionRequests"
:disabled="loadingRequests"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
</div>
<!-- Send Message Modal -->
<Teleport to="body">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeSendMessageModal()">
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('web5.sendMessageTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.to') }}</label>
<select
v-model="sendMessageTo"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.message') }}</label>
<textarea
v-model="sendMessageText"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
:placeholder="t('web5.messagePlaceholder')"
></textarea>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="sendMessage"
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ sendingMessage ? t('common.sending') : t('common.send') }}
</button>
<button
@click="closeSendMessageModal()"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
{{ t('common.cancel') }}
</button>
</div>
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { formatMessageTime } from './utils'
import type { Peer, ConnectionRequest } from './types'
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore()
const nodesContainerRef = ref<HTMLElement | null>(null)
const nodesContainerTab = ref<'peers' | 'messages' | 'requests'>('peers')
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
const peers = ref<Peer[]>([])
const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>({})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false)
// Send message modal
const showSendMessageModal = ref(false)
const sendMessageModalRef = ref<HTMLElement | null>(null)
const sendMessageRestoreFocusRef = ref<HTMLElement | null>(null)
function closeSendMessageModal() {
sendMessageRestoreFocusRef.value?.focus?.()
showSendMessageModal.value = false
}
useModalKeyboard(sendMessageModalRef, showSendMessageModal, closeSendMessageModal, { restoreFocusRef: sendMessageRestoreFocusRef })
const sendMessageTo = ref('')
const sendMessageText = ref('')
const sendingMessage = ref(false)
const sendMessageError = ref('')
const sendMessageSuccess = ref('')
// Connection requests
const connectionRequests = ref<ConnectionRequest[]>([])
const loadingRequests = ref(false)
const processingRequestId = ref<string | null>(null)
const emit = defineEmits<{
toast: [text: string]
}>()
function peerNameFromPubkey(pubkey: string): string {
const peer = peers.value.find(p => p.pubkey === pubkey || p.onion === pubkey)
if (peer?.name) return peer.name
return (pubkey || '').slice(0, 16) + '...'
}
function switchToMessagesTab() {
nodesContainerTab.value = 'messages'
markAsRead()
}
function switchToRequestsTab() {
nodesContainerTab.value = 'requests'
if (connectionRequests.value.length === 0 && !loadingRequests.value) {
loadConnectionRequests()
}
}
async function loadPeers() {
loadingPeers.value = true
try {
const res = await rpcClient.listPeers()
const peerList = res.peers || []
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
}
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
}
}
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
}
}
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()
emit('toast', t('web5.connectionAccepted'))
} catch {
emit('toast', t('web5.failedToAcceptRequest'))
} finally {
processingRequestId.value = null
}
}
async function rejectRequest(requestId: string) {
processingRequestId.value = requestId
try {
await rpcClient.call({ method: 'network.reject-request', params: { request_id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length
emit('toast', t('web5.requestRejected'))
} catch {
emit('toast', t('web5.failedToRejectRequest'))
} finally {
processingRequestId.value = null
}
}
function scrollToMessages() {
nodesContainerTab.value = 'messages'
markAsRead()
nextTick(() => {
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, scrollToMessages })
</script>

View File

@ -0,0 +1,100 @@
<template>
<!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.verifiableCredentialsDesc') }}</p>
</div>
</div>
<router-link to="/dashboard/web5/credentials" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
Manage &rarr;
</router-link>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.verifiableCredentialsDesc') }}</p>
<router-link to="/dashboard/web5/credentials" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
Manage &rarr;
</router-link>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Total</div>
<span class="text-sm text-white font-medium">{{ vcCredentials.length }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Active</div>
<span class="text-sm text-green-400 font-medium">{{ vcCredentials.filter(c => c.status === 'active').length }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Identities</div>
<span class="text-sm text-white font-medium">{{ identityCount }}</span>
</div>
</div>
<!-- Credentials List (summary) -->
<div v-if="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 &rarr;
</router-link>
</div>
<div v-else class="text-center text-white/40 text-sm py-4">
{{ t('web5.noCredentials') }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import type { VCData } from './types'
const { t } = useI18n()
defineProps<{
identityCount: number
}>()
const vcCredentials = ref<VCData[]>([])
async function loadCredentials() {
try {
const res = await rpcClient.call<{ credentials: VCData[] }>({ method: 'identity.list-credentials' })
vcCredentials.value = res.credentials || []
} catch {
vcCredentials.value = []
}
}
defineExpose({ loadCredentials })
</script>

View File

@ -0,0 +1,276 @@
<template>
<!-- Decentralized Web Node (DWN) -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
<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>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
import type { DwnStatusData, DwnProtocol, DwnMessageEntry } from './types'
const { t } = useI18n()
const appStore = useAppStore()
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'
}
}
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
}
}
defineExpose({ loadDwnStatus, loadDwnProtocols })
</script>

View File

@ -0,0 +1,219 @@
<template>
<!-- Bitcoin Domain Name Portfolio -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.bitcoinDomains') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.domainsSubtitle') }}</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.namesRegistered') }}</span>
</div>
<span class="text-white/60 text-sm">{{ registeredNames.length }} {{ registeredNames.length === 1 ? 'name' : 'names' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="text-white/80 text-sm">{{ t('common.status') }}</span>
</div>
<span :class="activeNamesCount > 0 ? 'text-green-400' : 'text-white/60'" class="text-sm font-medium">
{{ activeNamesCount > 0 ? `${activeNamesCount} Active` : 'None' }}
</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.expiringSoon') }}</span>
</div>
<span class="text-white/60 text-sm">{{ expiringNamesCount }} {{ expiringNamesCount === 1 ? 'name' : 'names' }}</span>
</div>
</div>
<button @click="showDomainsModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.manageDomains') }}
</button>
</div>
<!-- Domains Management Modal -->
<Teleport to="body">
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
<div class="flex items-center justify-between mb-4">
<h2 id="domains-title" class="text-lg font-bold text-white">{{ t('web5.domainsTitle') }}</h2>
<button @click="showDomainsModal = false" class="text-white/40 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Registered Names List -->
<div v-if="registeredNames.length" class="space-y-2 mb-4">
<div v-for="n in registeredNames" :key="n.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div>
<div class="text-sm text-white font-medium font-mono">{{ n.nip05 }}</div>
<div class="text-xs text-white/50 truncate max-w-[200px]">DID: {{ n.did }}</div>
</div>
<div class="flex items-center gap-2">
<span :class="{
'text-green-400': n.status === 'active',
'text-yellow-400': n.status === 'pending',
'text-red-400': n.status === 'expired' || n.status === 'failed'
}" class="text-xs font-medium capitalize">{{ n.status }}</span>
<button @click="removeName(n.id)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-white/40 text-sm py-4 mb-4">{{ t('web5.noDomains') }}</div>
<!-- Register New Name -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.registerNewName') }}</h3>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="text-white/60 text-xs block mb-1">Username</label>
<input v-model="newDomainName" type="text" placeholder="satoshi" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Domain</label>
<input v-model="newDomainDomain" type="text" placeholder="example.com" class="w-full input-glass" />
</div>
</div>
<div class="mb-3">
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
<select v-model="newDomainIdentityId" class="w-full input-glass">
<option value="" disabled>Select identity...</option>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
</select>
</div>
<div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div>
<button @click="registerNewName" :disabled="domainRegistering || !newDomainName.trim() || !newDomainDomain.trim() || !newDomainIdentityId" class="w-full glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ domainRegistering ? 'Registering...' : 'Register Name' }}
</button>
</div>
<!-- Verify NIP-05 -->
<div class="border-t border-white/10 pt-4 mt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.verifyNip05') }}</h3>
<div class="flex gap-2">
<input v-model="verifyNip05Input" type="text" placeholder="user@domain.com" class="flex-1 input-glass" />
<button @click="verifyNip05" :disabled="nip05Verifying || !verifyNip05Input.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ nip05Verifying ? '...' : 'Verify' }}
</button>
</div>
<div v-if="nip05Result" class="mt-2 p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" :class="nip05Result.verified ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white font-medium">{{ nip05Result.verified ? 'Verified' : 'Not Found' }}</span>
</div>
<div v-if="nip05Result.nostr_pubkey" class="text-xs text-white/50 font-mono truncate">Pubkey: {{ nip05Result.nostr_pubkey }}</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import type { RegisteredNameData, Nip05Result, ManagedIdentity } from './types'
const { t } = useI18n()
const props = defineProps<{
showStagger: boolean
managedIdentities: ManagedIdentity[]
}>()
const registeredNames = ref<RegisteredNameData[]>([])
const showDomainsModal = ref(false)
const newDomainName = ref('')
const newDomainDomain = ref('')
const newDomainIdentityId = ref('')
const domainError = ref('')
const domainRegistering = ref(false)
const 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 = props.managedIdentities.find(i => i.id === newDomainIdentityId.value)
await rpcClient.call({ method: 'identity.register-name', params: {
name: newDomainName.value.trim(),
domain: newDomainDomain.value.trim(),
identity_id: newDomainIdentityId.value,
did: identity?.did || '',
}})
newDomainName.value = ''
newDomainDomain.value = ''
newDomainIdentityId.value = ''
await loadDomainNames()
} catch (e: unknown) {
domainError.value = e instanceof Error ? e.message : t('web5.registrationFailed')
} finally {
domainRegistering.value = false
}
}
async function removeName(id: string) {
try {
await rpcClient.call({ method: 'identity.remove-name', params: { id } })
await loadDomainNames()
} catch (e: unknown) {
domainError.value = e instanceof Error ? e.message : t('web5.removeFailed')
}
}
async function verifyNip05() {
if (!verifyNip05Input.value.trim()) return
nip05Verifying.value = true
nip05Result.value = null
try {
const res = await rpcClient.call<Nip05Result>({ method: 'identity.resolve-name', params: { identifier: verifyNip05Input.value.trim() } })
nip05Result.value = res
} catch {
nip05Result.value = { name: '', domain: '', nostr_pubkey: null, relays: [], verified: false }
} finally {
nip05Verifying.value = false
}
}
// Expose for parent to call on mount
defineExpose({ loadDomainNames, registeredNames })
</script>

View File

@ -0,0 +1,573 @@
<template>
<!-- Identity Management -->
<div class="glass-card p-6">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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': showStagger }" class="flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-sm font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-medium text-sm">{{ identity.name }}</span>
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">&#9733;</span>
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<div class="flex items-center gap-1 mt-0.5">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Create Identity Modal -->
<Teleport to="body">
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Name</label>
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Purpose</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="p in ['personal', 'business', 'anonymous']"
:key="p"
@click="newIdentityPurpose = p"
class="px-3 py-2 rounded-lg text-sm capitalize transition-colors border"
:class="newIdentityPurpose === p ? 'bg-white/15 border-white/30 text-white' : 'bg-white/5 border-white/10 text-white/60 hover:bg-white/10'"
>{{ p }}</button>
</div>
</div>
</div>
<div v-if="createIdentityError" class="mt-3 alert-error">
<p class="text-xs">{{ createIdentityError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button 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>
</Teleport>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
<div class="flex gap-3">
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium">
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Key Viewer Modal -->
<Teleport to="body">
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
}">
<svg class="w-5 h-5" :class="{
'text-blue-400': keyViewerIdentity.purpose === 'personal',
'text-orange-400': keyViewerIdentity.purpose === 'business',
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
</div>
</div>
<!-- Public Keys -->
<div class="space-y-3 mb-5">
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
Public Keys
</h3>
<div class="space-y-2">
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">DID (Ed25519)</span>
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
</div>
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
</div>
</div>
</div>
<!-- Private Keys Section -->
<div class="border-t border-white/10 pt-5">
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
Private Keys
</h3>
<div v-if="!keyViewerPrivateKeys">
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
<div class="flex gap-2">
<input v-model="keyViewerPassword" type="password" placeholder="Password" class="flex-1 input-glass" @keydown.enter="unlockPrivateKeys" />
<button @click="unlockPrivateKeys" :disabled="!keyViewerPassword || keyViewerUnlocking" class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50">
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
</button>
</div>
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
</div>
<div v-else class="space-y-2">
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
</div>
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">Lock private keys</button>
</div>
</div>
<div class="flex justify-end mt-5">
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Profile Editor Modal -->
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-else class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
<div>
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} &middot; {{ profileEditorIdentity.purpose }}</p>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Display Name</label>
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Website</label>
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full input-glass" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full input-glass" />
</div>
</div>
</div>
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">{{ profileSaving ? 'Saving...' : 'Save' }}</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { ManagedIdentity, IdentityProfile } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
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)
// Key viewer
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
const keyViewerPassword = ref('')
const keyViewerUnlocking = ref(false)
const keyViewerError = ref('')
const keyViewerCopied = ref<string | null>(null)
// Profile editor
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
const profileSaving = ref(false)
const profilePublishing = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
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()
emit('toast', t('web5.identityCreated'))
} catch (err: unknown) {
createIdentityError.value = err instanceof Error ? err.message : t('web5.failedToCreateIdentity')
} finally {
creatingIdentity.value = false
}
}
function copyIdentityDid(did: string) {
safeClipboardWrite(did)
emit('toast', t('web5.didCopied'))
}
async function setDefaultIdentity(id: string) {
try {
await rpcClient.call({ method: 'identity.set-default', params: { id } })
await loadIdentities()
emit('toast', t('web5.defaultIdentityUpdated'))
} catch {
emit('toast', t('web5.failedToSetDefault'))
}
}
function confirmDeleteIdentity(identity: ManagedIdentity) {
deleteIdentityTarget.value = identity
}
async function deleteIdentity() {
if (!deleteIdentityTarget.value || deletingIdentity.value) return
deletingIdentity.value = true
try {
await rpcClient.call({ method: 'identity.delete', params: { id: deleteIdentityTarget.value.id } })
deleteIdentityTarget.value = null
await loadIdentities()
emit('toast', t('web5.identityDeleted'))
} catch {
emit('toast', t('web5.failedToDeleteIdentity'))
} finally {
deletingIdentity.value = false
}
}
function openKeyViewer(identity: ManagedIdentity) {
keyViewerIdentity.value = identity
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
}
function closeKeyViewer() {
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
keyViewerIdentity.value = null
}
async function unlockPrivateKeys() {
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
keyViewerUnlocking.value = true
keyViewerError.value = ''
try {
const res = await rpcClient.call<{
ed25519_secret_hex: string
nostr_secret_hex: string | null
nostr_nsec: string | null
}>({
method: 'identity.export-keys',
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
})
keyViewerPrivateKeys.value = {
ed25519_secret_hex: res.ed25519_secret_hex,
nostr_secret_hex: res.nostr_secret_hex || '',
nostr_nsec: res.nostr_nsec || '',
}
keyViewerPassword.value = ''
} catch (err: unknown) {
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
} finally {
keyViewerUnlocking.value = false
}
}
function copyKeyValue(label: string, value: string) {
safeClipboardWrite(value)
keyViewerCopied.value = label
setTimeout(() => { keyViewerCopied.value = null }, 2000)
}
function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
}
async function saveProfile() {
if (!profileEditorIdentity.value || profileSaving.value) return
profileSaving.value = true
profileError.value = ''
profileSuccess.value = ''
try {
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
await loadIdentities()
profileSuccess.value = 'Profile saved'
setTimeout(() => { profileSuccess.value = '' }, 3000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to save'
} finally {
profileSaving.value = false
}
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
try {
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
const res = await rpcClient.call<{ event_id: string }>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
})
await loadIdentities()
profileSuccess.value = `Published to relay (${res.event_id.slice(0, 12)}...)`
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
} finally {
profilePublishing.value = false
}
}
defineExpose({ loadIdentities, managedIdentities })
</script>

View File

@ -0,0 +1,135 @@
<template>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 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>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { VisibilityLevel } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
const nodeVisibility = ref<VisibilityLevel>('hidden')
const nodeOnionAddress = ref<string | null>(null)
const visibilityLoading = ref(false)
const settingVisibility = ref(false)
const 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
emit('toast', t('web5.visibilitySetTo', { level }))
} catch {
emit('toast', t('web5.failedToUpdateVisibility'))
} finally {
settingVisibility.value = false
}
}
function copyOnionAddress() {
if (!nodeOnionAddress.value) return
safeClipboardWrite(nodeOnionAddress.value)
emit('toast', t('web5.onionAddressCopied'))
}
defineExpose({ loadVisibility })
</script>

View File

@ -0,0 +1,168 @@
<template>
<!-- Nostr Relays -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 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>
<!-- Relay Management Modal -->
<Teleport to="body">
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
<div class="flex items-center justify-between mb-4">
<h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2>
<button @click="showRelaysModal = false" class="text-white/40 hover:text-white/80 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Relay List -->
<div v-if="nostrRelays.length" class="space-y-2 mb-4">
<div v-for="relay in nostrRelays" :key="relay.url" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-2 h-2 rounded-full flex-shrink-0" :class="relay.connected ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm text-white font-mono truncate">{{ relay.url }}</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button @click="toggleNostrRelay(relay.url, !relay.enabled)" class="text-xs px-2 py-1 rounded" :class="relay.enabled ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-white/40'">
{{ relay.enabled ? 'On' : 'Off' }}
</button>
<button @click="removeNostrRelay(relay.url)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-white/40 text-sm py-4 mb-4">{{ t('web5.noRelays') }}</div>
<!-- Add Relay -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-semibold text-white mb-3">{{ t('web5.addRelay') }}</h3>
<div class="flex gap-2">
<input v-model="newRelayUrl" type="text" :placeholder="t('web5.relayUrlPlaceholder')" class="flex-1 input-glass" @keyup.enter="addNostrRelay" />
<button @click="addNostrRelay" :disabled="!newRelayUrl.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
Add
</button>
</div>
<div v-if="relayError" class="text-xs text-red-400 mt-2">{{ relayError }}</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import type { NostrRelayData, NostrRelayStatsData } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
}>()
const nostrRelays = ref<NostrRelayData[]>([])
const nostrRelayStats = ref<NostrRelayStatsData | null>(null)
const showRelaysModal = ref(false)
const newRelayUrl = ref('')
const relayError = ref('')
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')
}
}
function openRelaysModal() {
showRelaysModal.value = true
}
defineExpose({ loadNostrRelays, nostrRelayStats, openRelaysModal })
</script>

View File

@ -0,0 +1,219 @@
<template>
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<span class="text-2xl text-orange-500 font-bold">&bitcoin;</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': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">{{ t('web5.didStatus') }}</p>
<p v-if="userDid" class="text-xs text-white/60 font-mono truncate" :title="userDid">{{ userDid }}</p>
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
</div>
</div>
<div v-if="userDid" class="flex gap-2 mt-auto">
<button
@click="$emit('copyDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button>
<button
@click="$emit('showDidDocument')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.viewDidDocument') }}
</button>
</div>
<button
v-else
@click="$emit('createDid')"
:disabled="creatingDid"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ creatingDid ? t('web5.creatingDid') : t('web5.createDid') }}
</button>
</div>
<!-- did:dht Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="dhtDid ? 'bg-blue-400' : 'bg-gray-500'"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">DHT Identity</p>
<p v-if="dhtDid" class="text-xs text-white/60 font-mono truncate" :title="dhtDid">{{ dhtDid }}</p>
<p v-else class="text-xs text-white/60">Not published</p>
</div>
</div>
<div v-if="dhtDid" class="flex gap-2 mt-auto">
<button
@click="$emit('copyDhtDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button>
<button
@click="$emit('refreshDhtDid')"
:disabled="publishingDht"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button>
</div>
<button
v-else-if="userDid"
@click="$emit('publishDhtDid')"
:disabled="publishingDht"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
</button>
</div>
<!-- Wallet Connection -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.wallet') }}</p>
<p class="text-xs text-white/60">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
</div>
</div>
<button
@click="$emit('connectWallet')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="connectingWallet"
>
{{ connectingWallet ? t('common.connecting') : walletConnected ? t('common.disconnect') : t('common.connect') }}
</button>
</div>
<!-- Nostr Relay Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="(nostrRelayStats?.connected_count ?? 0) > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="(nostrRelayStats?.connected_count ?? 0) > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('web5.nostrRelays') }}</p>
<p class="text-xs text-white/60">{{ t('web5.relaysConnected', { count: nostrRelayStats?.connected_count ?? 0 }) }}</p>
</div>
</div>
<button
@click="$emit('manageRelays')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('common.manage') }}
</button>
</div>
<!-- Connected Nodes -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 4">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
<div v-if="connectedNodesCount > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-pulse opacity-75"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">{{ t('web5.connectedNodes') }}</p>
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</p>
</div>
</div>
<div class="flex gap-2 mt-auto">
<button
@click="router.push('/dashboard/server/federation')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Nodes
</button>
<button
@click="router.push('/dashboard/mesh')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Message
</button>
</div>
</div>
</div>
</div>
<!-- Hardware Wallet Detected Banner -->
<div v-if="detectedHwWallets.length > 0" class="mb-6 alert-warning flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-orange-400">{{ t('web5.hardwareWalletDetected') }}</p>
<p class="text-xs text-white/60">
{{ detectedHwWallets.map(d => `${d.type}${d.product ? ' (' + d.product + ')' : ''}`).join(', ') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
const router = useRouter()
const { t } = useI18n()
defineProps<{
showStagger: boolean
profitsBreakdown: ProfitsData | null
networkingProfitsDisplay: string
userDid: string | null
didStatus: 'active' | 'inactive' | 'pending'
didCopied: boolean
creatingDid: boolean
dhtDid: string | null
dhtDidCopied: boolean
publishingDht: boolean
walletConnected: boolean
connectingWallet: boolean
nostrRelayStats: NostrRelayStatsData | null
connectedNodesCount: number
detectedHwWallets: HwWalletDevice[]
}>()
defineEmits<{
copyDid: []
showDidDocument: []
createDid: []
copyDhtDid: []
refreshDhtDid: []
publishDhtDid: []
connectWallet: []
manageRelays: []
}>()
</script>

View File

@ -0,0 +1,519 @@
<template>
<!-- Unified Send Modal -->
<Teleport to="body">
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash &lt; 1k sats, Lightning 1k-500k, on-chain &gt; 500k</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="unifiedSendAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div v-if="effectiveSendMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveSendMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
</label>
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashSendToken && effectiveSendMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashSendToken }}</p>
<button @click="copyEcashToken(ecashSendToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="effectiveSendMethod === 'onchain'" class="mb-3 flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="useHardwareWallet" class="sr-only peer" />
<div class="w-9 h-5 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:bg-orange-500/40 transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
<div>
<p class="text-sm text-white">{{ t('web5.signWithHwWallet') }}</p>
<p class="text-xs text-white/40">{{ t('web5.createsPsbt') }}</p>
</div>
</div>
<div v-if="psbtStep === 'created' && psbtData" class="mb-3 space-y-2">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/50 mb-1">Unsigned PSBT (copy or download):</p>
<textarea readonly :value="psbtData" rows="3" class="w-full bg-black/20 border border-white/10 rounded px-2 py-1 text-xs font-mono text-white/80 focus:outline-none"></textarea>
<div class="flex gap-2 mt-2">
<button @click="copyPsbt" class="text-xs text-orange-400 hover:text-orange-300">Copy PSBT</button>
<button @click="downloadPsbt" class="text-xs text-orange-400 hover:text-orange-300">Download .psbt</button>
</div>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/50 mb-1">Paste signed PSBT or upload file:</p>
<textarea v-model="signedPsbtInput" rows="3" placeholder="Paste signed PSBT base64 here..." class="w-full bg-black/20 border border-white/10 rounded px-2 py-1 text-xs font-mono text-white/80 focus:outline-none focus:border-white/30"></textarea>
<div class="flex gap-2 mt-2">
<label class="text-xs text-orange-400 hover:text-orange-300 cursor-pointer">
Upload .psbt
<input type="file" accept=".psbt,.txt" class="hidden" @change="handlePsbtFileUpload" />
</label>
</div>
</div>
</div>
<div v-if="showMeshRelayPrompt" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">&#x1F4E1;</span>
<p class="text-orange-300 text-sm font-medium">You are offline</p>
</div>
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
<div class="flex gap-2">
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
<button @click="handleMeshRelaySend" class="flex-1 glass-button glass-button-warning px-3 py-2 rounded-lg text-xs font-medium">Send via Mesh</button>
</div>
</div>
<div v-if="meshRelayActive" class="mb-3 alert-warning">
<div class="flex items-center gap-2 mb-1">
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-orange-300 text-xs font-medium">Mesh Relay</p>
</div>
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
</div>
<div v-if="sendResultTxid" class="mb-3 alert-success"><p class="text-xs">Sent! TX: {{ sendResultTxid }}</p></div>
<div v-if="sendResultHash" class="mb-3 alert-success"><p class="text-xs">Paid! Hash: {{ sendResultHash }}</p></div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3">
<button @click="closeUnifiedSendModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button v-if="psbtStep === 'created'" @click="finalizePsbt" :disabled="unifiedSendProcessing || !signedPsbtInput.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Broadcasting...' : 'Broadcast' }}
</button>
<button v-else @click="unifiedSend" :disabled="unifiedSendProcessing || !unifiedSendAmount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Unified Receive Modal -->
<Teleport to="body">
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
:key="m"
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="receiveInvoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="receiveInvoiceMemo" type="text" placeholder="Payment for..." class="w-full input-glass" />
</div>
<div v-if="receiveInvoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ receiveInvoiceResult }}</p>
<button @click="copyToClipboard(receiveInvoiceResult, 'Invoice copied')" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
</div>
<div v-if="receiveMethod === 'onchain'">
<div v-if="receiveOnchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
<p class="text-sm font-mono text-white/90 break-all">{{ receiveOnchainAddress }}</p>
<button @click="copyToClipboard(receiveOnchainAddress, 'Address copied')" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
</div>
</div>
<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 input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
<div v-if="unifiedReceiveError" class="mb-3 text-xs text-red-400">{{ unifiedReceiveError }}</div>
<div class="flex gap-3">
<button @click="closeUnifiedReceiveModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="unifiedReceive" :disabled="unifiedReceiveProcessing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ unifiedReceiveProcessing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useTransportStore } from '@/stores/transport'
import { useMeshStore } from '@/stores/mesh'
import { safeClipboardWrite } from './utils'
const { t } = useI18n()
const transportStore = useTransportStore()
const meshStore = useMeshStore()
const emit = defineEmits<{
toast: [text: string]
balancesChanged: []
}>()
// Send state
const showUnifiedSendModal = ref(false)
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
const unifiedSendAmount = ref<number>(0)
const unifiedSendDest = ref('')
const unifiedSendProcessing = ref(false)
const unifiedSendError = ref('')
const sendResultTxid = ref('')
const sendResultHash = ref('')
const useHardwareWallet = ref(false)
const meshRelayActive = ref(false)
const meshRelayStatus = ref('')
const meshRelayRequestId = ref(0)
const showMeshRelayPrompt = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
const ecashSendToken = ref('')
// Receive state
const showUnifiedReceiveModal = ref(false)
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('onchain')
const receiveInvoiceAmount = ref<number>(0)
const receiveInvoiceMemo = ref('')
const receiveInvoiceResult = ref('')
const receiveOnchainAddress = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const unifiedReceiveProcessing = ref(false)
const unifiedReceiveError = ref('')
const ecashReceiveToken = ref('')
const ecashReceiveResult = ref('')
const effectiveSendMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = unifiedSendAmount.value || 0
if (amt <= 0) return 'lightning'
if (amt < 1000) return 'ecash'
if (amt > 500000) return 'onchain'
return 'lightning'
})
function openSend() { showUnifiedSendModal.value = true }
function openReceive() { showUnifiedReceiveModal.value = true }
function closeUnifiedSendModal() {
showUnifiedSendModal.value = false
ecashSendToken.value = ''
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
psbtData.value = ''
psbtStep.value = 'idle'
signedPsbtInput.value = ''
}
function closeUnifiedReceiveModal() {
showUnifiedReceiveModal.value = false
receiveInvoiceResult.value = ''
receiveOnchainAddress.value = ''
ecashReceiveToken.value = ''
ecashReceiveResult.value = ''
unifiedReceiveError.value = ''
}
function copyEcashToken(token: string) {
safeClipboardWrite(token)
emit('toast', t('web5.ecashTokenCopied'))
}
function copyToClipboard(text: string, msg: string) {
safeClipboardWrite(text)
emit('toast', msg)
}
async function unifiedSend() {
if (!unifiedSendAmount.value || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
ecashSendToken.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
meshRelayActive.value = false
meshRelayStatus.value = ''
const method = effectiveSendMethod.value
try {
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
params: { amount_sats: unifiedSendAmount.value },
})
ecashSendToken.value = res.token
} else if (method === 'lightning') {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.pasteInvoice')
return
}
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: unifiedSendDest.value.trim() },
})
sendResultHash.value = res.payment_hash
} else {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.enterBitcoinAddress')
return
}
if (useHardwareWallet.value) {
const res = await rpcClient.createPsbt({
outputs: [{ address: unifiedSendDest.value.trim(), amount_sats: unifiedSendAmount.value }],
})
psbtData.value = res.psbt_base64
psbtStep.value = 'created'
signedPsbtInput.value = ''
unifiedSendProcessing.value = false
return
}
await transportStore.fetchStatus()
if (transportStore.meshOnly) {
showMeshRelayPrompt.value = true
unifiedSendProcessing.value = false
return
}
try {
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
} catch (sendErr: unknown) {
const errMsg = sendErr instanceof Error ? sendErr.message : ''
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
showMeshRelayPrompt.value = true
unifiedSendProcessing.value = false
return
}
throw sendErr
}
}
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
unifiedSendProcessing.value = false
}
}
async function handleMeshRelaySend() {
showMeshRelayPrompt.value = false
unifiedSendProcessing.value = true
meshRelayActive.value = true
meshRelayStatus.value = 'Creating signed transaction...'
unifiedSendError.value = ''
try {
meshRelayStatus.value = 'Signing transaction locally...'
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
method: 'lnd.create-raw-tx',
params: { addr: unifiedSendDest.value.trim(), amount_sats: unifiedSendAmount.value },
})
meshRelayStatus.value = 'Sending via mesh radio to connected peers...'
const relayRes = await meshStore.relayTransaction(rawRes.raw_tx_hex)
meshRelayRequestId.value = relayRes.request_id
meshRelayStatus.value = 'Transaction sent via mesh -- waiting for broadcast confirmation...'
startMeshRelayPolling(relayRes.request_id)
} catch (err: unknown) {
meshRelayActive.value = false
meshRelayStatus.value = ''
unifiedSendError.value = err instanceof Error ? err.message : 'Mesh relay failed'
} finally {
unifiedSendProcessing.value = false
}
}
function dismissMeshRelayPrompt() {
showMeshRelayPrompt.value = false
}
let meshRelayPollTimer: ReturnType<typeof setInterval> | null = null
function startMeshRelayPolling(_requestId: number) {
if (meshRelayPollTimer) clearInterval(meshRelayPollTimer)
meshRelayPollTimer = setInterval(async () => {
await meshStore.fetchMessages()
const msgs = meshStore.messages
for (const msg of msgs) {
if (msg.direction !== 'received') continue
const text = msg.plaintext
if (text.includes(`[tx_relay_response]`) && text.includes('txid:')) {
const match = text.match(/txid:\s*(\w+)/)
if (match && match[1]) {
sendResultTxid.value = match[1]
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... -- waiting for confirmations`
}
}
if (text.includes('[tx_confirmation]')) {
const confMatch = text.match(/(\d)\/3 confirmations/)
if (confMatch && confMatch[1]) {
const confs = parseInt(confMatch[1])
meshRelayStatus.value = `${confs}/3 confirmations${confs >= 3 ? ' -- Transaction confirmed!' : '...'}`
if (confs >= 3) {
meshRelayActive.value = false
if (meshRelayPollTimer) {
clearInterval(meshRelayPollTimer)
meshRelayPollTimer = null
}
emit('balancesChanged')
}
}
}
}
}, 5000)
setTimeout(() => {
if (meshRelayPollTimer) {
clearInterval(meshRelayPollTimer)
meshRelayPollTimer = null
}
}, 3 * 60 * 60 * 1000)
}
async function finalizePsbt() {
if (!signedPsbtInput.value.trim() || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
try {
await rpcClient.finalizePsbt(signedPsbtInput.value.trim())
psbtStep.value = 'idle'
psbtData.value = ''
signedPsbtInput.value = ''
sendResultTxid.value = t('web5.broadcastViaHwWallet')
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
} finally {
unifiedSendProcessing.value = false
}
}
function copyPsbt() {
if (!psbtData.value) return
safeClipboardWrite(psbtData.value)
unifiedSendError.value = t('web5.psbtCopied')
}
function downloadPsbt() {
if (!psbtData.value) return
const blob = new Blob([psbtData.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transaction.psbt'
a.click()
URL.revokeObjectURL(url)
}
function handlePsbtFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
signedPsbtInput.value = (e.target?.result as string) || ''
}
reader.readAsText(file)
input.value = ''
}
async function unifiedReceive() {
if (unifiedReceiveProcessing.value) return
unifiedReceiveProcessing.value = true
unifiedReceiveError.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!receiveInvoiceAmount.value || receiveInvoiceAmount.value < 1) {
unifiedReceiveError.value = t('web5.enterAmount')
return
}
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: receiveInvoiceAmount.value, memo: receiveInvoiceMemo.value },
})
receiveInvoiceResult.value = res.payment_request
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
receiveOnchainAddress.value = res.address
nextTick(() => renderQrCode(res.address, onchainQrCanvas.value))
} else {
if (!ecashReceiveToken.value.trim()) {
unifiedReceiveError.value = t('web5.pasteEcashToken')
return
}
const res = await rpcClient.call<{ received_sats: number }>({
method: 'wallet.ecash-receive',
params: { token: ecashReceiveToken.value.trim() },
})
ecashReceiveResult.value = `Received ${res.received_sats} sats!`
ecashReceiveToken.value = ''
emit('balancesChanged')
}
} catch (err: unknown) {
unifiedReceiveError.value = err instanceof Error ? err.message : t('web5.receiveFailed')
} finally {
unifiedReceiveProcessing.value = false
}
}
async function renderQrCode(data: string, canvas: HTMLCanvasElement | null) {
if (!canvas || !data) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(canvas, `bitcoin:${data}`, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
} catch { /* QR rendering failed silently */ }
}
defineExpose({
openSend,
openReceive,
meshRelayActive,
meshRelayStatus,
sendResultTxid,
})
</script>

View File

@ -0,0 +1,550 @@
<template>
<!-- Shared Content -->
<div class="glass-card p-6">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
</div>
</div>
<div v-if="contentTab === 'mine'" class="flex items-center gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.contentDesc') }}</p>
<div v-if="contentTab === 'mine'" class="grid grid-cols-2 gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Browse Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Tabs: My Content | Browse Peers -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="contentTab = 'mine'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'mine' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.myContent') }}
<span v-if="contentItems.length > 0" class="ml-1.5 text-xs text-white/50">({{ contentItems.length }})</span>
</button>
<button
@click="contentTab = 'browse'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'browse' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.browsePeers') }}
</button>
</div>
<!-- My Content tab -->
<div v-show="contentTab === 'mine'">
<div v-if="contentLoading && contentItems.length === 0" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('common.loading') }}</p>
</div>
<div v-else-if="contentItems.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.noSharedContent') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.addFilesToShare') }}</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(item, idx) in contentItems"
:key="item.id"
:class="{ 'card-stagger': showStagger }" class="p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p v-if="item.description" class="text-xs text-white/50 mt-0.5">{{ item.description }}</p>
<p class="text-xs text-white/40 mt-0.5">{{ item.mime_type }} &middot; {{ formatBytes(item.size_bytes) }}</p>
</div>
<button
@click="removeContentItem(item.id)"
:disabled="removingContentId === item.id"
class="p-2 rounded-lg text-white/40 hover:text-red-400 hover:bg-white/10 transition-colors shrink-0"
title="Remove"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<button
v-for="opt in accessOptions"
:key="opt.value"
@click="setContentPricing(item, opt.value)"
:disabled="updatingPricingId === item.id"
class="px-3 py-1 text-xs rounded-lg border transition-colors"
:class="getAccessType(item.access) === opt.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white/70'"
>
{{ opt.label }}
</button>
</div>
<div v-if="getAccessType(item.access) === 'paid'" class="flex items-center gap-3 mt-2">
<div class="flex items-center gap-2 flex-1">
<input
:value="getItemPrice(item.access)"
@change="updateItemPrice(item, ($event.target as HTMLInputElement).value)"
type="number"
min="1"
placeholder="100"
class="w-24 px-2 py-1 text-xs rounded-lg bg-white/5 border border-white/10 text-white focus:outline-none focus:border-white/30"
/>
<span class="text-xs text-white/50">sats</span>
</div>
<p class="text-xs text-orange-400/80">Peers will pay {{ getItemPrice(item.access) || 0 }} sats to access this</p>
</div>
<p v-else-if="getAccessType(item.access) === 'free'" class="text-xs text-green-400/70 mt-1">{{ t('web5.freeAccessDesc') }}</p>
<p v-else-if="getAccessType(item.access) === 'peers_only'" class="text-xs text-blue-400/70 mt-1">{{ t('web5.peersOnlyAccessDesc') }}</p>
</div>
</div>
</div>
<!-- Browse Peers tab -->
<div v-show="contentTab === 'browse'">
<div v-if="browsingPeerContent" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('web5.connectingToPeer') }}</p>
</div>
<div v-else-if="!browsePeerOnion && peerContentItems.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.selectPeerToBrowse') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.choosePeerDesc') }}</p>
</div>
<div v-else-if="peerContentItems.length === 0 && browsePeerOnion && !browsingPeerContent" class="py-6 text-center">
<p class="text-white/60 text-sm">{{ t('web5.peerNoContent') }}</p>
</div>
<div v-else class="space-y-2">
<div
v-for="(pItem, idx) in peerContentItems"
:key="pItem.id"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center shrink-0">
<svg v-if="isMediaType(pItem.mime_type)" class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ pItem.filename }}</p>
<p v-if="pItem.description" class="text-xs text-white/50 truncate">{{ pItem.description }}</p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-white/40">{{ pItem.mime_type }}</span>
<span class="text-xs text-white/30">&middot;</span>
<span class="text-xs text-white/40">{{ formatBytes(pItem.size_bytes) }}</span>
<span v-if="getItemPrice(pItem.access) > 0" class="text-xs text-orange-400 ml-1">{{ getItemPrice(pItem.access) }} sats</span>
<span v-else class="text-xs text-green-400/70 ml-1">Free</span>
</div>
</div>
<button
v-if="isMediaType(pItem.mime_type)"
@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">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ streamingItem.filename }}</p>
<p class="text-xs text-white/50">{{ streamingItem.mime_type }}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div v-if="streamCostSats > 0" class="flex items-center gap-1 px-2 py-1 rounded bg-orange-500/20">
<span class="text-xs text-orange-400 font-medium">{{ streamCostSats }} sats</span>
</div>
<button @click="closePlayer" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="p-4">
<div v-if="streamingItem.mime_type.startsWith('audio/')">
<audio ref="audioPlayerRef" :src="streamUrl" controls class="w-full" @timeupdate="onPlayerTimeUpdate" @error="onPlayerError"></audio>
</div>
<div v-else-if="streamingItem.mime_type.startsWith('video/')">
<video ref="videoPlayerRef" :src="streamUrl" controls class="w-full rounded-lg max-h-[60vh]" @timeupdate="onPlayerTimeUpdate" @error="onPlayerError"></video>
</div>
<div v-if="playerError" class="mt-3 alert-error">
<p>{{ playerError }}</p>
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
</div>
<div class="flex items-center justify-between mt-3">
<div class="text-xs text-white/40">
{{ formatBytes(streamingItem.size_bytes) }}
<span v-if="streamProgress > 0"> &middot; {{ Math.round(streamProgress * 100) }}% streamed</span>
</div>
<button @click="copyStreamUrl" class="text-xs text-white/50 hover:text-white transition-colors">Copy URL</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal -->
<Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="add-content-title">
<h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Filename</label>
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">MIME Type</label>
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Description (optional)</label>
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-2">Access</label>
<div class="flex gap-2">
<button
v-for="opt in accessOptions"
:key="opt.value"
@click="newContentAccess = opt.value"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="newContentAccess === opt.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'"
>{{ opt.label }}</button>
</div>
</div>
<div v-if="newContentAccess === 'paid'">
<label class="text-white/60 text-sm block mb-1">Price (sats)</label>
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full input-glass" />
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
</div>
</div>
<div v-if="addContentError" class="mt-3 alert-error">
<p class="text-xs">{{ addContentError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ addingContent ? 'Adding...' : 'Add' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { formatBytes, isMediaType, getAccessType, getItemPrice, safeClipboardWrite } from './utils'
import type { ContentItemData, PeerContentItem, Peer } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
peers: Peer[]
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
const contentItems = ref<ContentItemData[]>([])
const contentLoading = ref(false)
const contentTab = ref<'mine' | 'browse'>('mine')
const showAddContentModal = ref(false)
const newContentFilename = ref('')
const newContentMimeType = ref('application/octet-stream')
const newContentDescription = ref('')
const newContentAccess = ref<'free' | 'peers_only' | 'paid'>('free')
const newContentPrice = ref<number>(100)
const addingContent = ref(false)
const addContentError = ref('')
const removingContentId = ref<string | null>(null)
const updatingPricingId = ref<string | null>(null)
const accessOptions = [
{ value: 'free' as const, label: 'Free' },
{ value: 'peers_only' as const, label: 'Peers Only' },
{ value: 'paid' as const, label: 'Paid' },
]
// Browse peers
const browsePeerOnion = ref('')
const browsingPeerContent = ref(false)
const browsePeerError = ref('')
const peerContentItems = ref<PeerContentItem[]>([])
// Streaming player
const streamingItem = ref<PeerContentItem | null>(null)
const streamUrl = ref('')
const streamCostSats = ref(0)
const streamProgress = ref(0)
const playerError = ref('')
const audioPlayerRef = ref<HTMLAudioElement | null>(null)
const videoPlayerRef = ref<HTMLVideoElement | null>(null)
async function loadContentItems() {
contentLoading.value = true
try {
const res = await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })
contentItems.value = res.items || []
} catch {
contentItems.value = []
} finally {
contentLoading.value = false
}
}
async function addContentItem() {
if (addingContent.value || !newContentFilename.value.trim()) return
addingContent.value = true
addContentError.value = ''
try {
await rpcClient.call({
method: 'content.add',
params: {
filename: newContentFilename.value.trim(),
mime_type: newContentMimeType.value.trim() || 'application/octet-stream',
description: newContentDescription.value.trim(),
},
})
if (newContentAccess.value !== 'free') {
const items = (await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })).items || []
const latest = items[items.length - 1]
if (latest) {
await rpcClient.call({
method: 'content.set-pricing',
params: {
id: latest.id,
access: newContentAccess.value,
...(newContentAccess.value === 'paid' ? { price_sats: newContentPrice.value || 100 } : {}),
},
})
}
}
showAddContentModal.value = false
newContentFilename.value = ''
newContentMimeType.value = 'application/octet-stream'
newContentDescription.value = ''
newContentAccess.value = 'free'
newContentPrice.value = 100
await loadContentItems()
emit('toast', t('web5.contentAdded'))
} catch (err: unknown) {
addContentError.value = err instanceof Error ? err.message : t('web5.failedToAddContent')
} finally {
addingContent.value = false
}
}
async function removeContentItem(id: string) {
removingContentId.value = id
try {
await rpcClient.call({ method: 'content.remove', params: { id } })
contentItems.value = contentItems.value.filter(i => i.id !== id)
emit('toast', t('web5.contentRemoved'))
} catch {
emit('toast', t('web5.failedToRemoveContent'))
} finally {
removingContentId.value = null
}
}
async function setContentPricing(item: ContentItemData, access: 'free' | 'peers_only' | 'paid') {
updatingPricingId.value = item.id
try {
const params: Record<string, unknown> = { id: item.id, access }
if (access === 'paid') {
params.price_sats = getItemPrice(item.access) || 100
}
await rpcClient.call({ method: 'content.set-pricing', params })
await loadContentItems()
} catch {
emit('toast', t('web5.failedToUpdatePricing'))
} finally {
updatingPricingId.value = null
}
}
async function updateItemPrice(item: ContentItemData, value: string) {
const price = parseInt(value, 10)
if (!price || price <= 0) return
updatingPricingId.value = item.id
try {
await rpcClient.call({
method: 'content.set-pricing',
params: { id: item.id, access: 'paid', price_sats: price },
})
await loadContentItems()
} catch {
emit('toast', t('web5.failedToUpdatePrice'))
} finally {
updatingPricingId.value = null
}
}
async function browsePeerContent() {
if (!browsePeerOnion.value || browsingPeerContent.value) return
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 || []
} catch (err: unknown) {
browsePeerError.value = err instanceof Error ? err.message : t('web5.failedToConnectPeer')
} finally {
browsingPeerContent.value = false
}
}
function streamPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
streamingItem.value = item
streamUrl.value = `http://${browsePeerOnion.value}/content/${item.id}`
streamCostSats.value = getItemPrice(item.access)
streamProgress.value = 0
playerError.value = ''
}
function downloadPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
const url = `http://${browsePeerOnion.value}/content/${item.id}`
emit('toast', t('web5.downloadUrlCopied'))
safeClipboardWrite(url)
}
function closePlayer() {
if (audioPlayerRef.value) {
audioPlayerRef.value.pause()
audioPlayerRef.value.src = ''
}
if (videoPlayerRef.value) {
videoPlayerRef.value.pause()
videoPlayerRef.value.src = ''
}
streamingItem.value = null
streamUrl.value = ''
streamProgress.value = 0
playerError.value = ''
}
function onPlayerTimeUpdate() {
const player = audioPlayerRef.value || videoPlayerRef.value
if (player && player.duration > 0) {
streamProgress.value = player.currentTime / player.duration
}
}
function onPlayerError() {
playerError.value = t('web5.playerError')
}
function copyStreamUrl() {
if (streamUrl.value) {
safeClipboardWrite(streamUrl.value)
emit('toast', t('web5.streamUrlCopied'))
}
}
defineExpose({ loadContentItems })
</script>

View File

@ -0,0 +1,184 @@
<template>
<!-- Wallet -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col 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 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
</div>
<!-- Transaction Activity Badge -->
<button
v-if="txActivityCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span v-if="incomingTxCount > 0">Incoming {{ incomingTxCount }}</span>
<span v-if="meshRelayActive" class="ml-1">Mesh TX</span>
<span class="incoming-tx-ping"></span>
</button>
</div>
<!-- Transaction Activity Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && (incomingTransactions.length > 0 || meshRelayActive)" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<!-- Mesh Relay TX (outgoing via mesh) -->
<div v-if="meshRelayActive" class="incoming-tx-row">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" style="background: rgba(251,146,60,0.15);">
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-orange-400">Mesh Relay</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">
{{ sendResultTxid ? 'Broadcast' : 'Sending...' }}
</span>
</div>
<p class="text-[11px] text-white/40 mt-0.5">{{ meshRelayStatus }}</p>
</div>
</div>
</div>
<!-- Incoming TXs -->
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="incoming-tx-row"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" :class="tx.num_confirmations === 0 ? 'incoming-tx-icon-pending' : 'incoming-tx-icon-confirmed'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</div>
</transition>
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
<div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold">&bitcoin;</span>
<span class="text-white/80 text-sm">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ lndOnchainBalance.toLocaleString() }} sats</span>
</div>
<!-- Lightning Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ lndChannelBalance.toLocaleString() }} sats</span>
</div>
<!-- Ecash Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-white/80 text-sm">{{ t('web5.ecash') }}</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ ecashBalance.toLocaleString() }} sats</span>
</div>
</div>
<!-- Action buttons -->
<div class="grid grid-cols-2 gap-2 mt-auto pt-4 shrink-0">
<button
@click="$emit('openSend')"
:disabled="!walletConnected && ecashBalance <= 0"
class="px-3 py-2 glass-button rounded-lg text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ t('common.send') }}
</button>
<button
@click="$emit('openReceive')"
class="px-3 py-2 glass-button rounded-lg text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.receiveBitcoin') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatTxTime } from './utils'
import type { WalletTransaction } from './types'
const router = useRouter()
const { t } = useI18n()
const showIncomingTxPanel = ref(false)
defineProps<{
showStagger: boolean
walletConnected: boolean
walletError: string
lndOnchainBalance: number
lndChannelBalance: number
ecashBalance: number
incomingTransactions: WalletTransaction[]
incomingTxCount: number
txActivityCount: number
meshRelayActive: boolean
meshRelayStatus: string
sendResultTxid: string
}>()
defineEmits<{
openSend: []
openReceive: []
}>()
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
}
</script>

View File

@ -0,0 +1,162 @@
// Shared types for Web5 subcomponents
export interface ProfitsData {
total_sats: number
content_sales_sats: number
routing_fees_sats: number
}
export interface RegisteredNameData {
id: string
name: string
domain: string
nip05: string
identity_id: string
did: string
nostr_pubkey: string | null
status: string
registered_at: string
expires_at: string | null
}
export interface Nip05Result {
name: string
domain: string
nostr_pubkey: string | null
relays: string[]
verified: boolean
}
export interface VCData {
id: string
issuer: string
subject: string
type: string
claims: Record<string, unknown>
issued_at: string
expires_at: string | null
status: string
}
export interface NostrRelayData {
url: string
connected: boolean
enabled: boolean
added_at: string
}
export interface NostrRelayStatsData {
total_relays: number
connected_count: number
enabled_count: number
}
export interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
export interface HwWalletDevice {
type: string
vendor_id: string
product_id: string
manufacturer: string
product: string
}
export interface ContentItemData {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
added_at: string
}
export interface PeerContentItem {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
}
export interface ConnectionRequest {
id: string
from_did: string
from_onion?: string
from_pubkey?: string
message?: string
created_at: string
}
export interface IdentityProfile {
display_name?: string
about?: string
picture?: string
banner?: string
website?: string
nip05?: string
lud16?: string
}
export interface ManagedIdentity {
id: string
name: string
purpose: string
pubkey: string
did: string
created_at: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
profile?: IdentityProfile
}
export interface DwnStatusData {
running: boolean
version: string
sync_status: string
last_sync: string | null
messages_synced: number
storage_bytes: number
message_count: number
protocol_count: number
registered_protocols: string[]
peer_sync_targets: string[]
}
export interface DwnProtocol {
protocol: string
published: boolean
types: Record<string, unknown>
structure: Record<string, unknown>
dateRegistered: string
}
export interface DwnMessageEntry {
record_id: string
author: string
date_created: string
descriptor: {
interface: string
method: string
protocol?: string
schema?: string
dataFormat?: string
}
data?: unknown
}
export type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
export type Peer = { onion: string; pubkey: string; name?: string }

View File

@ -0,0 +1,73 @@
// Shared utility functions for Web5 subcomponents
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
export function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function formatMessageTime(ts: string): string {
try {
const d = new Date(ts)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return d.toLocaleDateString()
} catch {
return ts
}
}
export async function safeClipboardWrite(text: string): Promise<void> {
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)
}
}
export function isMediaType(mime: string): boolean {
return mime.startsWith('audio/') || mime.startsWith('video/')
}
export function getAccessType(access: string | { paid: { price_sats: number } }): 'free' | 'peers_only' | 'paid' {
if (typeof access === 'string') {
if (access === 'peersonly' || access === 'peers_only') return 'peers_only'
if (access === 'paid') return 'paid'
return 'free'
}
if (access && typeof access === 'object' && 'paid' in access) return 'paid'
return 'free'
}
export function getItemPrice(access: string | { paid: { price_sats: number } }): number {
if (typeof access === 'object' && access && 'paid' in access) {
return access.paid.price_sats
}
return 0
}