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:
parent
77f550fb5e
commit
ea1b1f826b
@ -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
180
neode-ui/src/views/mesh/MeshBitcoinPanel.vue
Normal file
180
neode-ui/src/views/mesh/MeshBitcoinPanel.vue
Normal 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>
|
||||
123
neode-ui/src/views/mesh/MeshDeadmanPanel.vue
Normal file
123
neode-ui/src/views/mesh/MeshDeadmanPanel.vue
Normal 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>
|
||||
867
neode-ui/src/views/settings/AccountSection.vue
Normal file
867
neode-ui/src/views/settings/AccountSection.vue
Normal 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>
|
||||
913
neode-ui/src/views/settings/SystemSection.vue
Normal file
913
neode-ui/src/views/settings/SystemSection.vue
Normal 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() }} · {{ 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')">
|
||||
×
|
||||
</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>
|
||||
462
neode-ui/src/views/web5/Web5.vue
Normal file
462
neode-ui/src/views/web5/Web5.vue
Normal 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>
|
||||
449
neode-ui/src/views/web5/Web5ConnectedNodes.vue
Normal file
449
neode-ui/src/views/web5/Web5ConnectedNodes.vue
Normal 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>
|
||||
100
neode-ui/src/views/web5/Web5CredentialsSummary.vue
Normal file
100
neode-ui/src/views/web5/Web5CredentialsSummary.vue
Normal 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 →
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Mobile: stacked -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('web5.verifiableCredentialsDesc') }}</p>
|
||||
<router-link to="/dashboard/web5/credentials" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
Manage →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-4">
|
||||
<div class="bg-white/5 rounded-lg p-3">
|
||||
<div class="text-xs text-white/50 mb-1">Total</div>
|
||||
<span class="text-sm text-white font-medium">{{ vcCredentials.length }}</span>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg p-3">
|
||||
<div class="text-xs text-white/50 mb-1">Active</div>
|
||||
<span class="text-sm text-green-400 font-medium">{{ vcCredentials.filter(c => c.status === 'active').length }}</span>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg p-3">
|
||||
<div class="text-xs text-white/50 mb-1">Identities</div>
|
||||
<span class="text-sm text-white font-medium">{{ 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 →
|
||||
</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>
|
||||
276
neode-ui/src/views/web5/Web5DWN.vue
Normal file
276
neode-ui/src/views/web5/Web5DWN.vue
Normal 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">•</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">
|
||||
×
|
||||
</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>
|
||||
219
neode-ui/src/views/web5/Web5Domains.vue
Normal file
219
neode-ui/src/views/web5/Web5Domains.vue
Normal 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>
|
||||
573
neode-ui/src/views/web5/Web5Identities.vue
Normal file
573
neode-ui/src/views/web5/Web5Identities.vue
Normal 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">★</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 }} · {{ 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>
|
||||
135
neode-ui/src/views/web5/Web5NodeVisibility.vue
Normal file
135
neode-ui/src/views/web5/Web5NodeVisibility.vue
Normal 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>
|
||||
168
neode-ui/src/views/web5/Web5NostrRelays.vue
Normal file
168
neode-ui/src/views/web5/Web5NostrRelays.vue
Normal 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>
|
||||
219
neode-ui/src/views/web5/Web5QuickActions.vue
Normal file
219
neode-ui/src/views/web5/Web5QuickActions.vue
Normal 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>
|
||||
519
neode-ui/src/views/web5/Web5SendReceiveModals.vue
Normal file
519
neode-ui/src/views/web5/Web5SendReceiveModals.vue
Normal 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 < 1k sats, Lightning 1k-500k, on-chain > 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">📡</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>
|
||||
550
neode-ui/src/views/web5/Web5SharedContent.vue
Normal file
550
neode-ui/src/views/web5/Web5SharedContent.vue
Normal 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 }} · {{ 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">·</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"> · {{ 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>
|
||||
184
neode-ui/src/views/web5/Web5Wallet.vue
Normal file
184
neode-ui/src/views/web5/Web5Wallet.vue
Normal 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>
|
||||
162
neode-ui/src/views/web5/types.ts
Normal file
162
neode-ui/src/views/web5/types.ts
Normal 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 }
|
||||
73
neode-ui/src/views/web5/utils.ts
Normal file
73
neode-ui/src/views/web5/utils.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user