fix: prevent tokio runtime deadlock in credential issue/verify

The credential issuance and verification handlers used
Handle::block_on() directly inside the tokio runtime, causing a
deadlock. Wrapped with block_in_place() to properly yield the
runtime thread.

Also completed full feature verification across all 25 test groups
(~175 checks) on live server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 07:43:12 +00:00
co-authored by Claude Opus 4.6
parent 5ce8b7965c
commit e3aa95a103
81 changed files with 11492 additions and 649 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.nrq8o7pr84g"
"revision": "0.20p42r6c33g"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#1a1a2e"/>
<circle cx="32" cy="22" r="6" stroke="#4fc3f7" stroke-width="2.5" fill="none"/>
<circle cx="18" cy="42" r="6" stroke="#4fc3f7" stroke-width="2.5" fill="none"/>
<circle cx="46" cy="42" r="6" stroke="#4fc3f7" stroke-width="2.5" fill="none"/>
<line x1="28" y1="27" x2="21" y2="37" stroke="#4fc3f7" stroke-width="2" stroke-linecap="round"/>
<line x1="36" y1="27" x2="43" y2="37" stroke="#4fc3f7" stroke-width="2" stroke-linecap="round"/>
<line x1="24" y1="42" x2="40" y2="42" stroke="#4fc3f7" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<rect width="100" height="100" rx="20" fill="#7B2DBC"/>
<circle cx="50" cy="40" r="12" stroke="white" stroke-width="3" fill="none"/>
<circle cx="28" cy="68" r="8" stroke="white" stroke-width="2.5" fill="none"/>
<circle cx="72" cy="68" r="8" stroke="white" stroke-width="2.5" fill="none"/>
<line x1="42" y1="49" x2="33" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="58" y1="49" x2="67" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="50" cy="40" r="4" fill="white"/>
<circle cx="28" cy="68" r="3" fill="white"/>
<circle cx="72" cy="68" r="3" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 718 B

+4
View File
@@ -15,6 +15,9 @@
<!-- App launcher overlay (iframe popup) -->
<AppLauncherOverlay />
<!-- Global toast notifications -->
<ToastStack />
<!-- Screensaver -->
<Screensaver />
@@ -68,6 +71,7 @@ import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import SpotlightSearch from './components/SpotlightSearch.vue'
import CLIPopup from './components/CLIPopup.vue'
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import { useControllerNav } from '@/composables/useControllerNav'
+272 -13
View File
@@ -3,20 +3,20 @@
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
class="fixed inset-0 z-[2400] flex items-center justify-center p-0 md:p-10"
@click.self="store.close()"
>
<!-- Backdrop - blur like spotlight -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div class="app-launcher-backdrop absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Panel - inset with margins, glass style like spotlight -->
<div
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl"
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none md:rounded-2xl shadow-2xl"
:class="panelClasses"
>
<!-- Header bar - drag handle + title + close -->
<div class="flex items-center gap-3 border-b border-white/10 px-4 py-3">
<div class="flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
<!-- Header bar - sticky on mobile -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
<div class="hidden md:flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
@@ -66,6 +66,15 @@
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<!-- Loading indicator -->
<Transition name="content-fade">
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<svg class="animate-spin h-8 w-8 text-blue-400" 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>
</div>
</Transition>
<iframe
ref="iframeRef"
v-if="store.url"
@@ -75,6 +84,53 @@
title="App content"
@load="onIframeLoad"
/>
<!-- Payment Confirmation Dialog -->
<Transition name="content-fade">
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h3 class="text-white font-semibold text-sm">Payment Request</h3>
<p class="text-white/50 text-xs">{{ store.title || 'App' }} wants to make a payment</p>
</div>
</div>
<div class="space-y-2 mb-4">
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Amount</span>
<span class="text-orange-400 font-bold text-lg">{{ pendingPayment.amount_sats.toLocaleString() }} sats</span>
</div>
<div v-if="pendingPayment.memo" class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Memo</span>
<span class="text-white/80 text-sm truncate ml-2">{{ pendingPayment.memo }}</span>
</div>
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Method</span>
<span class="text-white/80 text-sm capitalize">{{ pendingPayment.method || 'auto' }}</span>
</div>
</div>
<div v-if="paymentError" class="mb-3 p-2 bg-red-500/15 border border-red-500/20 rounded-lg">
<p class="text-red-400 text-xs">{{ paymentError }}</p>
</div>
<div class="flex gap-3">
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
Deny
</button>
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-50">
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
@@ -85,15 +141,33 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest {
request_id: string
amount_sats: number
memo?: string
method?: 'lightning' | 'ecash' | 'onchain' | 'auto'
invoice?: string
address?: string
}
const store = useAppLauncherStore()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
const iframeLoading = ref(true)
// Wallet connect — payment request state
const pendingPayment = ref<PaymentRequest | null>(null)
const paymentProcessing = ref(false)
const paymentError = ref('')
const paymentOrigin = ref('')
function refreshIframe() {
isRefreshing.value = true
iframeLoading.value = true
iframeRefreshKey.value++
}
@@ -106,6 +180,42 @@ function openInNewTab() {
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
iframeLoading.value = false
sendIdentityIfSupported()
}
/** Apps that support the Archipelago identity protocol (postMessage) */
function isIdentityAwareApp(url: string): boolean {
return url.includes('indeehub')
}
/** Send the user's default identity to the iframe via postMessage */
async function sendIdentityIfSupported() {
if (!store.url || !isIdentityAwareApp(store.url)) return
try {
const res = await rpcClient.call<{ identities: Array<{ id: string; name: string; did: string; pubkey: string; is_default: boolean; nostr_pubkey?: string }> }>({ method: 'identity.list' })
const defaultId = res.identities?.find(i => i.is_default) || res.identities?.[0]
if (!defaultId) return
// Sign a timestamp challenge to prove ownership
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({
method: 'identity.sign',
params: { id: defaultId.id, message: challenge }
})
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:identity',
did: defaultId.did,
name: defaultId.name,
pubkey: defaultId.pubkey,
nostr_pubkey: defaultId.nostr_pubkey || null,
challenge,
signature: sigRes.signature
}, '*')
} catch {
// Identity not available — continue without it
}
}
function injectScrollbarHideIfSameOrigin() {
@@ -132,7 +242,7 @@ function injectScrollbarHideIfSameOrigin() {
const panelClasses = [
'glass-card',
'w-full h-full max-w-[calc(100vw-3rem)] max-h-[calc(100vh-5rem)]',
'w-full h-full',
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
]
@@ -148,15 +258,135 @@ function onMessage(e: MessageEvent) {
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
store.close()
}
// Iframe app requests identity on demand
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
sendIdentityIfSupported()
}
// Wallet connect — app requests a payment
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
handlePaymentRequest(e)
}
}
/** Handle incoming payment request from iframe app */
function handlePaymentRequest(e: MessageEvent) {
const data = e.data
if (!data.amount_sats || typeof data.amount_sats !== 'number' || data.amount_sats <= 0) {
sendPaymentResponse(e.origin, data.request_id, false, 'Invalid amount')
return
}
pendingPayment.value = {
request_id: data.request_id || `pay-${Date.now()}`,
amount_sats: data.amount_sats,
memo: data.memo,
method: data.method || 'auto',
invoice: data.invoice,
address: data.address,
}
paymentOrigin.value = e.origin
paymentError.value = ''
paymentProcessing.value = false
}
/** Send payment response back to the iframe */
function sendPaymentResponse(origin: string, requestId: string, success: boolean, error?: string, receipt?: Record<string, unknown>) {
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:payment-response',
request_id: requestId,
success,
error: error || null,
receipt: receipt || null,
}, origin || '*')
}
/** User approves the payment */
async function approvePayment() {
if (!pendingPayment.value || paymentProcessing.value) return
paymentProcessing.value = true
paymentError.value = ''
const pay = pendingPayment.value
const method = resolvePaymentMethod(pay)
try {
let receipt: Record<string, unknown> = {}
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string; amount_sats: number }>({
method: 'wallet.ecash-send',
params: { amount_sats: pay.amount_sats },
})
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
} else if (method === 'lightning') {
if (pay.invoice) {
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: pay.invoice },
})
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
} else {
// Create and immediately return an invoice for the requester to display
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: pay.amount_sats, memo: pay.memo || '' },
})
receipt = { method: 'lightning', payment_request: res.payment_request, amount_sats: pay.amount_sats }
}
} else {
if (!pay.address) {
paymentError.value = 'No Bitcoin address provided for on-chain payment'
return
}
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: pay.address, amount: pay.amount_sats },
})
receipt = { method: 'onchain', txid: res.txid, amount_sats: pay.amount_sats }
}
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
pendingPayment.value = null
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Payment failed'
paymentError.value = msg
} finally {
paymentProcessing.value = false
}
}
/** User rejects the payment */
function rejectPayment() {
if (pendingPayment.value) {
sendPaymentResponse(paymentOrigin.value, pendingPayment.value.request_id, false, 'Payment denied by user')
pendingPayment.value = null
}
}
/** Resolve auto method based on amount */
function resolvePaymentMethod(pay: PaymentRequest): 'ecash' | 'lightning' | 'onchain' {
if (pay.method && pay.method !== 'auto') return pay.method
if (pay.invoice) return 'lightning'
if (pay.address) return 'onchain'
if (pay.amount_sats < 1000) return 'ecash'
if (pay.amount_sats > 500000) return 'onchain'
return 'lightning'
}
watch(
() => store.isOpen,
(open) => {
if (open) {
iframeLoading.value = true
closeBtnRef.value?.focus()
} else {
isRefreshing.value = false
iframeLoading.value = true
// Clear any pending payment when closing
if (pendingPayment.value) {
rejectPayment()
}
}
}
)
@@ -173,21 +403,50 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.app-launcher-panel {
will-change: transform, opacity;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.25s ease;
transition: opacity 0.3s ease;
}
.app-launcher-enter-active .app-launcher-backdrop {
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
}
.app-launcher-leave-active .app-launcher-backdrop {
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
}
.app-launcher-enter-active .app-launcher-panel {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.app-launcher-enter-active .app-launcher-panel,
.app-launcher-leave-active .app-launcher-panel {
transition: transform 0.25s ease, opacity 0.25s ease;
transition: transform 0.25s cubic-bezier(0.55, 0, 1, 0.45), opacity 0.2s ease;
}
.app-launcher-enter-from,
.app-launcher-enter-from {
opacity: 0;
}
.app-launcher-enter-from .app-launcher-backdrop {
opacity: 0;
backdrop-filter: blur(0);
}
.app-launcher-enter-from .app-launcher-panel {
transform: translateY(40px);
opacity: 0;
}
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-enter-from .app-launcher-panel,
.app-launcher-leave-to .app-launcher-backdrop {
opacity: 0;
backdrop-filter: blur(0);
}
.app-launcher-leave-to .app-launcher-panel {
transform: scale(0.96);
transform: translateY(30px);
opacity: 0;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<span class="text-3xl">{{ icon }}</span>
</div>
<h3 class="text-lg font-semibold text-white/80 mb-2">{{ title }}</h3>
<p class="text-sm text-white/50 max-w-sm mb-6">{{ description }}</p>
<button
v-if="actionLabel"
@click="$emit('action')"
class="glass-button px-5 py-2.5 rounded-lg text-sm font-medium"
>
{{ actionLabel }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
icon?: string
title: string
description: string
actionLabel?: string
}>()
defineEmits<{
action: []
}>()
</script>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="relative" ref="pickerRef">
<button
@click="isOpen = !isOpen"
class="w-full flex items-center gap-3 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white hover:bg-white/10 transition-colors"
>
<!-- Selected Identity -->
<div v-if="selectedIdentity" class="flex items-center gap-2 flex-1 min-w-0">
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(selectedIdentity.purpose)">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span class="truncate">{{ selectedIdentity.name }}</span>
<span class="text-white/40 text-xs font-mono truncate">{{ truncateDid(selectedIdentity.did) }}</span>
</div>
<div v-else class="flex-1 text-white/50">Select identity...</div>
<!-- Chevron -->
<svg class="w-4 h-4 text-white/40 shrink-0 transition-transform" :class="{ 'rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="content-fade">
<div v-if="isOpen" class="absolute left-0 right-0 mt-1 z-20 glass-card p-1 rounded-lg max-h-48 overflow-y-auto">
<div v-if="loading" class="p-3 text-center text-white/50 text-sm">Loading...</div>
<div v-else-if="identities.length === 0" class="p-3 text-center text-white/50 text-sm">No identities</div>
<button
v-for="id in identities"
:key="id.id"
@click="selectIdentity(id)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/80 hover:bg-white/10 transition-colors"
:class="{ 'bg-white/10': modelValue === id.id }"
>
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(id.purpose)">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-1">
<span class="truncate">{{ id.name }}</span>
<span v-if="id.is_default" class="text-yellow-400 text-xs">&#9733;</span>
</div>
<p class="text-white/40 text-xs font-mono truncate">{{ truncateDid(id.did) }}</p>
</div>
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { rpcClient } from '@/api/rpc-client'
interface Identity {
id: string
name: string
purpose: string
pubkey: string
did: string
is_default: boolean
}
const props = defineProps<{
modelValue?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', id: string): void
(e: 'select', identity: Identity): void
}>()
const isOpen = ref(false)
const loading = ref(false)
const identities = ref<Identity[]>([])
const pickerRef = ref<HTMLElement | null>(null)
const selectedIdentity = computed(() =>
identities.value.find(i => i.id === props.modelValue)
)
function purposeColor(purpose: string): string {
switch (purpose) {
case 'personal': return 'bg-blue-500/20 text-blue-400'
case 'business': return 'bg-orange-500/20 text-orange-400'
case 'anonymous': return 'bg-purple-500/20 text-purple-400'
default: return 'bg-white/10 text-white/60'
}
}
function truncateDid(did: string): string {
if (did.length <= 30) return did
return did.slice(0, 18) + '...' + did.slice(-8)
}
function selectIdentity(id: Identity) {
emit('update:modelValue', id.id)
emit('select', id)
isOpen.value = false
}
function onClickOutside(e: MouseEvent) {
if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
async function loadIdentities() {
loading.value = true
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
// Auto-select default if no value set
if (!props.modelValue) {
const defaultId = identities.value.find(i => i.is_default)
if (defaultId) {
emit('update:modelValue', defaultId.id)
emit('select', defaultId)
}
}
} catch {
identities.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadIdentities()
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="glass-card p-6 animate-pulse" :class="className">
<!-- Header skeleton -->
<div v-if="showHeader" class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-lg bg-white/10 shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-white/10 rounded w-2/3"></div>
<div class="h-3 bg-white/5 rounded w-1/3"></div>
</div>
</div>
<!-- Content skeleton lines -->
<div class="space-y-3">
<div v-for="i in lines" :key="i" class="h-3 bg-white/8 rounded" :style="{ width: lineWidth(i) }"></div>
</div>
<!-- Stats grid skeleton -->
<div v-if="showStats" class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
<div v-for="s in 4" :key="s" class="bg-white/5 rounded-lg p-3">
<div class="h-2 bg-white/8 rounded w-1/2 mb-2"></div>
<div class="h-5 bg-white/10 rounded w-3/4"></div>
</div>
</div>
<!-- Action buttons skeleton -->
<div v-if="showActions" class="flex gap-3 mt-4">
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
lines?: number
showHeader?: boolean
showStats?: boolean
showActions?: boolean
className?: string
}>(), {
lines: 3,
showHeader: true,
showStats: false,
showActions: false,
className: '',
})
function lineWidth(index: number): string {
const widths = ['100%', '85%', '70%', '90%', '60%']
return widths[(index - 1) % widths.length] ?? '100%'
}
</script>
+66
View File
@@ -0,0 +1,66 @@
<template>
<Teleport to="body">
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none max-w-sm w-full">
<TransitionGroup name="toast-stack">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast-stack-item pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer"
:class="variantClass(toast.variant)"
@click="dismiss(toast.id)"
>
<div class="w-5 h-5 shrink-0 flex items-center justify-center">
<!-- Success -->
<svg v-if="toast.variant === 'success'" 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="M5 13l4 4L19 7" />
</svg>
<!-- Error -->
<svg v-else-if="toast.variant === 'error'" class="w-5 h-5 text-red-400" 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>
<!-- Info -->
<svg v-else class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
import type { ToastVariant } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
function variantClass(variant: ToastVariant): string {
switch (variant) {
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
case 'error': return 'bg-black/70 border-red-500/30 backdrop-blur-md'
default: return 'bg-black/70 border-blue-500/30 backdrop-blur-md'
}
}
</script>
<style scoped>
.toast-stack-enter-active {
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-stack-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-stack-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-stack-leave-to {
opacity: 0;
transform: translateX(50%);
}
.toast-stack-move {
transition: transform 0.3s ease;
}
</style>
@@ -13,6 +13,7 @@ export interface MarketplaceAppInfo {
url: string
repoUrl: string
s9pkUrl: string
dockerImage: string
}
// Simple in-memory store for the current marketplace app
@@ -34,6 +35,7 @@ export function useMarketplaceApp() {
url: app.url || app.s9pkUrl || app.manifestUrl || '',
repoUrl: app.repoUrl ?? '',
s9pkUrl: app.s9pkUrl ?? '',
dockerImage: app.dockerImage ?? '',
}
}
+47
View File
@@ -0,0 +1,47 @@
import { ref, readonly } from 'vue'
export type ToastVariant = 'success' | 'error' | 'info'
export interface ToastItem {
id: number
message: string
variant: ToastVariant
dismissing: boolean
}
const toasts = ref<ToastItem[]>([])
let nextId = 0
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
const id = nextId++
toasts.value.push({ id, message, variant, dismissing: false })
// Auto-dismiss
if (duration > 0) {
setTimeout(() => dismissToast(id), duration)
}
// Cap at 5 visible toasts
if (toasts.value.length > 5) {
toasts.value.shift()
}
}
function dismissToast(id: number) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx === -1) return
toasts.value[idx]!.dismissing = true
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 300)
}
export function useToast() {
return {
toasts: readonly(toasts),
success: (msg: string) => addToast(msg, 'success'),
error: (msg: string) => addToast(msg, 'error'),
info: (msg: string) => addToast(msg, 'info'),
dismiss: dismissToast,
}
}
+39 -3
View File
@@ -40,24 +40,60 @@ export const helpTree: HelpSection[] = [
id: 'learn',
label: 'Learn',
items: [
{
id: 'getting-started',
label: 'Getting Started',
content: 'Welcome to Archipelago! To get started: 1) Flash the Archipelago ISO to a USB drive using Balena Etcher or dd. 2) Boot your hardware from the USB. 3) The installer partitions your disk, installs the OS, and reboots automatically. 4) On first boot, open a browser and navigate to your node\'s IP address. 5) Complete the onboarding wizard — set a password, create your first identity (DID), and choose your setup path. Your node is now ready to install apps, connect to Bitcoin, and join the sovereign web.',
relatedPath: '/dashboard',
},
{
id: 'bitcoin-basics',
label: 'Bitcoin Basics',
content: 'Bitcoin is a decentralized digital currency. Your node validates transactions and maintains the blockchain locally.',
content: 'Bitcoin is a decentralized digital currency. Your node validates transactions and maintains the blockchain locally. Install Bitcoin Knots from the App Store to run a full node. Initial sync takes 2-3 days depending on hardware and internet speed. Once synced, your node independently verifies every transaction without trusting any third party.',
relatedPath: '/dashboard/server',
},
{
id: 'lightning-network',
label: 'Lightning Network',
content: 'Lightning enables instant, low-fee payments. Open channels with other nodes to send and receive payments off-chain.',
content: 'Lightning enables instant, low-fee payments. Install LND from the App Store (requires Bitcoin Knots). After installation, open channels with other nodes to send and receive payments. Use the Lightning Channels view to manage channels, check inbound/outbound liquidity, and monitor your routing fees. Channels require an on-chain transaction to open and close.',
relatedPath: '/dashboard/apps',
},
{
id: 'app-store-guide',
label: 'Installing & Managing Apps',
content: 'Open the App Store (marketplace icon) to browse available apps. Click Install to download and start an app. Some apps have dependencies — Electrs requires Bitcoin, BTCPay requires LND, Mempool requires both Bitcoin and Electrs. The system handles these automatically. After installation, apps appear in My Apps. Click an app to open it in an overlay or new tab. Use the app detail page to start, stop, restart, or uninstall apps.',
relatedPath: '/dashboard/marketplace',
},
{
id: 'identity-guide',
label: 'Your Digital Identity (DIDs)',
content: 'Archipelago creates a sovereign digital identity (DID) during onboarding. DIDs are cryptographic keypairs that prove your identity without any company in the middle. You can create multiple identities for different purposes — Personal, Business, or Anonymous. Each identity can sign messages, issue verifiable credentials, and authenticate with services like Indeehub. Manage identities in the Web5 view.',
relatedPath: '/dashboard/web5',
},
{
id: 'networking-guide',
label: 'Connecting with Peers',
content: 'Archipelago nodes can discover and connect with each other over Tor. In Web5, set your node visibility to "Discoverable" to let other nodes find you via Nostr. Accept connection requests from peers you trust. Once connected, you can message peers, share content, and exchange ecash payments — all over encrypted Tor connections. Your .onion address is shown in Settings.',
relatedPath: '/dashboard/web5',
},
{
id: 'content-sharing',
label: 'Sharing Content',
content: 'Share files and media with connected peers through the Content section in Web5. Add content from your Cloud storage, set it as free or paid (ecash-gated), and connected peers can browse and access your catalog. For paid content, peers pay with ecash micropayments — the sats appear in your wallet instantly.',
relatedPath: '/dashboard/web5',
},
{
id: 'self-hosting',
label: 'Self-Hosting',
content: 'Archipelago runs your services locally. Your data stays on your hardware, giving you full control and privacy.',
content: 'Archipelago runs your services locally. Your data stays on your hardware, giving you full control and privacy. No cloud subscriptions, no data harvesting, no service shutdowns. You own your node, your data, and your identity. Back up your node regularly using the backup feature in Settings.',
relatedPath: '/dashboard',
},
{
id: 'troubleshooting',
label: 'Troubleshooting FAQ',
content: 'Common issues: 1) App won\'t start — check disk space in Settings > Server. 2) Bitcoin not syncing — ensure port 8333 is reachable; check network diagnostics. 3) Can\'t connect to peers — verify Tor is running (Settings > Network). 4) UI is slow — some views load data from multiple sources; check server resources. 5) Lost password — use the backup recovery key created during onboarding. 6) Container errors — try stopping and restarting the app, or uninstall and reinstall.',
relatedPath: '/dashboard/settings',
},
],
},
{
+10
View File
@@ -39,6 +39,11 @@ const router = createRouter({
name: 'onboarding-did',
component: () => import('../views/OnboardingDid.vue'),
},
{
path: 'onboarding/identity',
name: 'onboarding-identity',
component: () => import('../views/OnboardingIdentity.vue'),
},
{
path: 'onboarding/backup',
name: 'onboarding-backup',
@@ -75,6 +80,11 @@ const router = createRouter({
name: 'app-details',
component: () => import('../views/AppDetails.vue'),
},
{
path: 'apps/lnd/channels',
name: 'lightning-channels',
component: () => import('../views/apps/LightningChannels.vue'),
},
{
path: 'marketplace',
name: 'marketplace',
+2
View File
@@ -20,6 +20,7 @@ export const useAppStore = defineStore('app', () => {
// Computed
const serverInfo = computed(() => data.value?.['server-info'])
const packages = computed(() => data.value?.['package-data'] || {})
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
const uiData = computed(() => data.value?.ui)
const serverName = computed(() => serverInfo.value?.name || 'Archipelago')
const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false)
@@ -292,6 +293,7 @@ export const useAppStore = defineStore('app', () => {
// Computed
serverInfo,
packages,
peerHealth,
uiData,
serverName,
isRestarting,
+3
View File
@@ -60,6 +60,9 @@ const PORT_TO_PROXY: Record<string, string> = {
'4080': '/app/mempool/',
'50002': '/app/electrs/',
'8175': '/app/fedimint/',
'8176': '/app/fedimint-gateway/',
'3100': '/app/dwn/',
'18081': '/app/nostr-rs-relay/',
}
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
export const useWeb5BadgeStore = defineStore('web5Badge', () => {
const pendingRequestCount = ref(0)
async function refresh() {
try {
const res = await rpcClient.call<{ requests: Array<{ id: string }> }>({ method: 'network.list-requests' })
pendingRequestCount.value = res.requests?.length ?? 0
} catch {
// ignore — badge is best-effort
}
}
return { pendingRequestCount, refresh }
})
+91 -15
View File
@@ -24,6 +24,31 @@
transition: box-shadow 0.2s ease;
}
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]) {
min-height: 44px;
}
}
/* Haptic-like press feedback for all interactive elements */
button:active:not(:disabled),
[role="button"]:active,
a.glass-card:active,
a.goal-card:active,
.info-card-button:active,
.path-action-button:active {
transform: scale(0.97) !important;
transition: transform 0.1s ease !important;
}
/* Toggle switches — subtle press */
input[type="checkbox"]:active + *,
input[type="radio"]:active + * {
transform: scale(0.95);
transition: transform 0.1s ease;
}
/* Containers: base scale for smooth grow animation */
[data-controller-container] {
transition: transform 0.2s ease, box-shadow 0.2s ease;
@@ -84,7 +109,7 @@
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
transition: all 0.25s ease;
transition: color 0.2s ease, background-color 0.2s ease;
cursor: pointer;
text-align: center;
border: none;
@@ -138,7 +163,7 @@
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.25s ease;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
user-select: none;
}
@@ -220,13 +245,17 @@
/* Goal cards */
.goal-card {
cursor: pointer;
transition: all 0.3s ease;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
}
.goal-card:hover {
transform: translateY(-2px);
}
.goal-card:active {
transform: translateY(1px);
}
.goal-status-badge {
display: inline-flex;
align-items: center;
@@ -256,7 +285,7 @@
.goal-step {
padding: 1rem 1.25rem;
border-left: 3px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
transition: border-color 0.3s ease, background-color 0.3s ease;
}
.goal-step-active {
@@ -278,6 +307,7 @@
align-items: center;
justify-content: center;
padding-inline: 1.25rem;
min-height: 44px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
@@ -287,7 +317,11 @@
border-radius: 0.75rem;
border: none;
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, box-shadow 0.3s ease;
}
.glass-button:active {
transform: translateY(1px);
}
.glass-button::before {
@@ -321,6 +355,7 @@
padding-block: 0.375rem;
padding-inline: 0.75rem;
font-size: 0.875rem;
min-height: 36px;
}
/* Toast - glassmorphic, top-right */
@@ -336,7 +371,7 @@
/* Toast transition */
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-enter-from,
.toast-leave-to {
@@ -428,10 +463,14 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, box-shadow 0.3s ease;
border: none;
}
.path-option-card:active {
transform: translateY(1px);
}
/* Gradient border effect using CSS mask - default is subtle */
.path-option-card::before {
content: '';
@@ -451,7 +490,7 @@
/* Icon styling with black glass effect */
.path-option-card svg {
color: rgba(255, 255, 255, 0.85);
transition: all 0.3s ease;
transition: color 0.2s ease, filter 0.3s ease;
filter:
drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3))
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))
@@ -521,12 +560,16 @@
border: none;
position: relative;
cursor: pointer;
transition: all 0.3s ease;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, box-shadow 0.3s ease;
min-width: 0;
white-space: nowrap;
letter-spacing: 0.02em;
}
.path-action-button:active {
transform: translateY(1px);
}
.path-action-button--skip {
padding: 12px 40px;
font-size: 16px;
@@ -885,7 +928,7 @@ html:has(body.video-background-active)::before {
gap: 0.75rem;
padding: 0.5rem;
border-radius: 0.75rem;
transition: all 0.2s ease;
transition: background-color 0.2s ease;
text-align: left;
width: 100%;
cursor: pointer;
@@ -936,7 +979,7 @@ html:has(body.video-background-active)::before {
color: rgba(255, 255, 255, 0.6);
border: none;
cursor: pointer;
transition: all 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
text-decoration: none;
}
.cloud-file-action-btn:hover {
@@ -988,7 +1031,7 @@ html:has(body.video-background-active)::before {
border: none;
cursor: pointer;
border-radius: 0.25rem;
transition: all 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
white-space: nowrap;
}
.cloud-breadcrumb-item:hover:not(.cloud-breadcrumb-active) {
@@ -1027,7 +1070,7 @@ html:has(body.video-background-active)::before {
cursor: pointer;
background: transparent;
color: rgba(255, 255, 255, 0.35);
transition: all 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
.cloud-view-toggle-btn:hover {
color: rgba(255, 255, 255, 0.7);
@@ -1067,7 +1110,7 @@ html:has(body.video-background-active)::before {
background: rgba(255, 255, 255, 0.04);
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.2s ease;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
color: inherit;
padding: 0;
}
@@ -1220,7 +1263,7 @@ html:has(body.video-background-active)::before {
color: white;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
padding: 0;
}
.cloud-audio-player-btn:hover {
@@ -1267,3 +1310,36 @@ html:has(body.video-background-active)::before {
from { opacity: 0; }
to { opacity: 1; }
}
/* Smooth loading → content transition */
.content-fade-enter-active,
.content-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.content-fade-enter-from {
opacity: 0;
transform: translateY(6px);
}
.content-fade-leave-to {
opacity: 0;
}
/* Staggered card entrance animation */
.card-stagger {
opacity: 0;
animation: card-stagger-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: calc(var(--stagger-index, 0) * 50ms);
}
@keyframes card-stagger-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+1
View File
@@ -3,6 +3,7 @@
export interface DataModel {
'server-info': ServerInfo
'package-data': { [id: string]: PackageDataEntry }
'peer-health'?: { [onion: string]: boolean }
ui: UIData
}
+45
View File
@@ -52,6 +52,16 @@
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
v-if="packageKey === 'lnd'"
@click="router.push('/dashboard/apps/lnd/channels')"
class="glass-button glass-button-sm px-4 py-2.5 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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Channels
</button>
<button
v-if="canLaunch"
@click="launchApp"
@@ -266,6 +276,27 @@
</div>
</div>
<!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Services</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 py-2 border-b border-white/10">
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">Guardian</p>
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
</div>
</div>
<div class="flex items-center gap-3 py-2">
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">Gateway</p>
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
</div>
</div>
</div>
</div>
<!-- Access (LAN + Tor) Card -->
<div v-if="interfaceAddresses" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
@@ -444,6 +475,7 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
btcpay: 'btcpay-server',
'btcpay-server': 'btcpay-server',
fedimint: 'fedimint',
'fedimint-gateway': 'fedimint-gateway',
lnd: 'lnd',
'lnd-ui': 'lnd',
bitcoin: 'bitcoin-knots',
@@ -532,6 +564,15 @@ const torUrl = computed(() => {
return addr.startsWith('http') ? addr : `http://${addr}`
})
/** Resolved package key for the current route */
const packageKey = computed(() => resolvePackageKey(appId.value))
/** Fedimint Gateway companion container state */
const gatewayState = computed(() => {
const gw = store.packages['fedimint-gateway']
return gw ? gw.state : 'not installed'
})
const uninstallModal = ref({
show: false,
appTitle: ''
@@ -679,6 +720,10 @@ function launchApp() {
dev: 'http://localhost:8175',
prod: 'http://192.168.1.228:8175'
},
'fedimint-gateway': {
dev: 'http://localhost:8176',
prod: 'http://192.168.1.228:8176'
},
'morphos-server': {
dev: 'http://localhost:8081',
prod: 'http://localhost:8081'
+3 -2
View File
@@ -40,12 +40,13 @@
<!-- Apps Grid (alphabetically by title, stable across run state) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div
v-for="[id, pkg] in filteredPackageEntries"
v-for="([id, pkg], index) in filteredPackageEntries"
:key="id"
data-controller-container
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
class="glass-card card-stagger p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
:style="{ '--stagger-index': index }"
@click="goToApp(id as string)"
>
<!-- Uninstall Icon -->
+5 -3
View File
@@ -24,11 +24,12 @@
</div>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<Transition name="content-fade" mode="out-in">
<div v-if="loading" key="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
</div>
<div v-else-if="error" class="glass-card p-6">
<div v-else-if="error" key="error" class="glass-card p-6">
<div class="flex items-center gap-3 text-red-400">
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -37,7 +38,7 @@
</div>
</div>
<div v-else-if="container" class="space-y-6">
<div v-else-if="container" key="content" class="space-y-6">
<!-- Container Info Card -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
@@ -125,6 +126,7 @@
</div>
</div>
</div>
</Transition>
</div>
</template>
+23 -6
View File
@@ -94,6 +94,10 @@
/>
</svg>
<span>{{ item.label }}</span>
<span
v-if="item.path === '/dashboard/web5' && web5Badge.pendingRequestCount > 0"
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
>{{ web5Badge.pendingRequestCount }}</span>
</RouterLink>
<!-- Chat launcher button -->
@@ -298,7 +302,7 @@
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
class="flex items-center justify-center w-full py-3 rounded-lg text-white/70 transition-all duration-300 relative z-10"
class="flex flex-col items-center justify-center w-full py-1.5 rounded-lg text-white/70 transition-all duration-300 relative z-10 gap-0.5"
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
@@ -308,7 +312,7 @@
}"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg class="w-7 h-7 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
@@ -318,15 +322,17 @@
:d="path"
/>
</svg>
<span class="text-[10px] leading-tight">{{ item.label }}</span>
</RouterLink>
<!-- Chat launcher -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn-mobile flex items-center justify-center w-full py-3 rounded-lg transition-all duration-300 relative z-10"
class="chat-launcher-btn-mobile flex flex-col items-center justify-center w-full py-1.5 rounded-lg transition-all duration-300 relative z-10 gap-0.5"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span class="text-[10px] leading-tight">Chat</span>
</button>
</div>
</nav>
@@ -344,6 +350,7 @@ import ControllerIndicator from '@/components/ControllerIndicator.vue'
import ModeSwitcher from '@/components/ModeSwitcher.vue'
import { useUIModeStore } from '@/stores/uiMode'
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
const uiMode = useUIModeStore()
@@ -355,6 +362,7 @@ const router = useRouter()
const route = useRoute()
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const web5Badge = useWeb5BadgeStore()
const showZoomIn = ref(false)
const pendingTimers: ReturnType<typeof setTimeout>[] = []
@@ -537,6 +545,7 @@ onMounted(() => {
onResize()
window.addEventListener('resize', onResize)
web5Badge.refresh()
})
onBeforeUnmount(() => {
@@ -1329,45 +1338,53 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
/* Mobile: Slide left transition (Apps -> Marketplace) */
.slide-left-enter-active.view-wrapper,
.slide-left-leave-active.view-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.slide-left-enter-from.view-wrapper {
transform: translateX(100%);
opacity: 0;
}
.slide-left-enter-to.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-from.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-to.view-wrapper {
transform: translateX(-100%);
opacity: 0;
}
/* Mobile: Slide right transition (Marketplace -> Apps) */
.slide-right-enter-active.view-wrapper,
.slide-right-leave-active.view-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.slide-right-enter-from.view-wrapper {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-to.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-from.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-to.view-wrapper {
transform: translateX(100%);
opacity: 0;
}
/* Slide down: Moving down the menu (content slides up like a scroll) */
+2 -2
View File
@@ -65,7 +65,7 @@
</svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Installed</p>
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
@@ -108,7 +108,7 @@
</svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Storage Used</p>
<p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p>
+26 -3
View File
@@ -122,12 +122,13 @@
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="app in filteredApps"
v-for="(app, index) in filteredApps"
:key="app.id"
data-controller-container
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex="0"
class="glass-card p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
class="glass-card card-stagger p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
:style="{ '--stagger-index': index }"
@click="viewAppDetails(app)"
>
<div class="flex items-start gap-4 mb-4">
@@ -238,7 +239,7 @@
</div>
<!-- Category Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@@ -790,6 +791,28 @@ function getCuratedAppList() {
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: null,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},
{
id: 'dwn',
title: 'Decentralized Web Node',
version: '0.4.0',
description: 'Store and sync your personal data across devices using decentralized web node protocols. Own your data with DID-based access control.',
icon: '/assets/img/app-icons/dwn.svg',
author: 'TBD',
dockerImage: 'ghcr.io/tbd54566975/dwn-server:main',
manifestUrl: null,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
id: 'nostr-rs-relay',
title: 'Nostr Relay',
version: '0.9.0',
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
author: 'scsiblade',
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:latest',
manifestUrl: null,
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
}
]
}
+124 -27
View File
@@ -23,8 +23,9 @@
<span>Back to App Store</span>
</button>
<Transition name="content-fade" mode="out-in">
<!-- Loading State -->
<div v-if="loading" class="glass-card p-12 text-center">
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-12 w-12 text-blue-400 mx-auto mb-4" 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>
@@ -33,7 +34,7 @@
</div>
<!-- App Details -->
<div v-else-if="app">
<div v-else-if="app" key="content">
<!-- Compact Hero Section -->
<div class="glass-card p-6 mb-6">
<!-- Desktop: Single Row Layout -->
@@ -83,7 +84,7 @@
<button
v-else
@click="installApp"
:disabled="installing || !app.manifestUrl"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -115,7 +116,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<!-- App Info -->
<div class="min-w-0">
<h1 class="text-xl font-bold text-white mb-1">{{ app.title }}</h1>
@@ -148,7 +149,7 @@
<button
v-else
@click="installApp"
:disabled="installing || !app.manifestUrl"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -269,6 +270,47 @@
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
<div class="space-y-3">
<!-- App Dependencies -->
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
<div
v-for="dep in dependencies"
:key="dep.id"
class="flex items-center gap-3 py-2 border-b border-white/10"
>
<!-- Status indicator -->
<svg v-if="dep.status === 'running'" class="w-5 h-5 text-green-400 flex-shrink-0" 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>
<svg v-else-if="dep.status === 'stopped'" class="w-5 h-5 text-yellow-400 flex-shrink-0" 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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<svg v-else class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
<p class="text-white/50 text-xs">
{{ dep.status === 'running' ? 'Running' : dep.status === 'stopped' ? 'Installed but stopped' : 'Not installed' }}
</p>
</div>
</div>
<!-- Install missing dependencies button -->
<button
v-if="dependencies.some(d => d.status === 'missing')"
@click="installDependencies"
:disabled="installingDeps"
class="glass-button w-full mt-3 px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<svg v-if="installingDeps" class="animate-spin h-4 w-4" 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>
{{ installingDeps ? 'Installing...' : 'Install Requirements' }}
</button>
</div>
<div v-else class="py-2 border-b border-white/10">
<p class="text-white/60 text-sm">No additional dependencies required</p>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
@@ -312,13 +354,14 @@
</div>
<!-- App Not Found -->
<div v-else class="glass-card p-12 text-center">
<div v-else key="not-found" class="glass-card p-12 text-center">
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
<p class="text-white/70">The requested application could not be found in the marketplace</p>
</div>
</Transition>
</div>
</template>
@@ -340,6 +383,7 @@ const { getCurrentApp } = useMarketplaceApp()
const app = ref<MarketplaceAppInfo | null>(null)
const installing = ref(false)
const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
@@ -390,6 +434,33 @@ const features = computed(() => {
]
})
/** App dependency definitions */
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
'electrs': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'mempool': [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' },
{ id: 'electrs', title: 'Electrs', dockerImage: 'docker.io/mempool/electrs:latest' },
],
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
}
/** Check dependency status against installed packages */
const dependencies = computed(() => {
if (!app.value) return []
const deps = APP_DEPENDENCIES[app.value.id]
if (!deps) return []
return deps.map(dep => {
const pkg = store.packages[dep.id]
let status: 'running' | 'stopped' | 'missing' = 'missing'
if (pkg) {
status = pkg.state === 'running' ? 'running' : 'stopped'
}
return { ...dep, status }
})
})
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
@@ -436,9 +507,40 @@ function goToInstalledApp() {
}).catch(() => {})
}
async function installDependencies() {
if (installingDeps.value) return
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
if (!missingDeps.length) return
installingDeps.value = true
installError.value = null
try {
// Install dependencies sequentially (order matters: bitcoin before electrs)
for (const dep of missingDeps) {
await rpcClient.call({
method: 'package.install',
params: {
id: dep.id,
dockerImage: dep.dockerImage,
},
timeout: 180000,
})
// Wait for package to register before installing next
await new Promise(resolve => setTimeout(resolve, 2000))
}
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : 'Failed to install dependencies.'
console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
} finally {
installingDeps.value = false
}
}
async function installApp() {
if (installing.value || !app.value?.manifestUrl) {
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl:', app.value)
if (installing.value || !app.value) return
if (!app.value.manifestUrl && !app.value.dockerImage) {
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
return
}
@@ -446,15 +548,20 @@ async function installApp() {
installError.value = null
try {
const installUrl = app.value.url || app.value.manifestUrl
console.log('[MarketplaceAppDetails] Installing app:', {
id: app.value.id,
url: installUrl,
version: app.value.version,
source: app.value.source
})
if (app.value.source === 'local') {
if (app.value.dockerImage) {
// Docker-based app installation
await rpcClient.call({
method: 'package.install',
params: {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
},
timeout: 180000,
})
} else {
// Package-based installation
const installUrl = app.value.url || app.value.manifestUrl
await rpcClient.call({
method: 'package.install',
params: {
@@ -463,16 +570,6 @@ async function installApp() {
version: app.value.version,
},
})
} else {
// Community marketplace app
await rpcClient.call({
method: 'package.install',
params: {
id: app.value.id,
url: installUrl,
version: app.value.version || 'latest',
},
})
}
// Wait a moment for the package to be registered
+44 -13
View File
@@ -14,15 +14,16 @@
<!-- Content Area -->
<div class="flex flex-col items-center gap-6 mb-6">
<!-- Connecting state -->
<!-- Generating state spinning lock -->
<div v-if="!generatedDid && isGenerating" class="text-center">
<div class="flex items-center justify-center gap-3 mb-4">
<svg class="animate-spin h-6 w-6 text-white/80" 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>
<span class="text-lg text-white/80">Connecting to your server...</span>
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
<p class="text-lg text-white/80">Generating your identity key...</p>
</div>
<!-- Connection failed - retry -->
@@ -57,13 +58,25 @@
<div class="path-option-card cursor-default px-6 py-6">
<div class="text-left">
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10">
<p class="text-white/95 font-mono text-sm break-all leading-relaxed">
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10 flex items-start gap-3">
<p class="text-white/95 font-mono text-sm break-all leading-relaxed flex-1">
{{ generatedDid }}
</p>
<button
@click="copyDid"
class="shrink-0 p-1.5 rounded hover:bg-white/10 transition-colors text-white/50 hover:text-white/90"
:title="didCopied ? 'Copied!' : 'Copy DID'"
>
<svg v-if="!didCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<p class="text-base text-white/60">
This identifier is stored securely on your node
This is your sovereign digital identity. It proves you are you, without any company in the middle.
</p>
</div>
</div>
@@ -101,6 +114,7 @@ const isGenerating = ref(false)
const connectionFailed = ref(false)
const autoAdvancing = ref(false)
const errorMessage = ref<string>('')
const didCopied = ref(false)
function storeDidState(did: string, pubkey: string) {
localStorage.setItem('neode_did', did)
@@ -135,7 +149,7 @@ async function fetchDid() {
function autoAdvanceAfterDelay() {
autoAdvancing.value = true
setTimeout(() => {
router.push('/onboarding/backup').catch(() => {})
router.push('/onboarding/identity').catch(() => {})
}, 2000)
}
@@ -149,10 +163,27 @@ onMounted(() => {
})
function proceed() {
router.push('/onboarding/backup').catch(() => {})
router.push('/onboarding/identity').catch(() => {})
}
function skipForNow() {
router.push('/onboarding/backup').catch(() => {})
router.push('/onboarding/identity').catch(() => {})
}
function copyDid() {
if (!generatedDid.value) return
navigator.clipboard.writeText(generatedDid.value).catch(() => {})
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
</script>
<style scoped>
.onb-lock-spin {
animation: onb-lock-pulse 1.2s ease-in-out infinite;
}
@keyframes onb-lock-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.7; }
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-[800px] w-full relative z-10 path-glass-container">
<!-- Header -->
<div class="text-center flex-shrink-0">
<h1 class="text-[26px] font-semibold text-white/96 mb-6 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
Name your identity
</h1>
<p class="text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-6">
Give your first identity a name and choose how you'll use it. You can create more identities later.
</p>
</div>
<!-- Content -->
<div class="flex flex-col items-center gap-6 mb-6">
<div class="w-full max-w-[600px] space-y-6">
<!-- Name Input -->
<div class="path-option-card cursor-default px-6 py-6">
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Identity Name</label>
<input
v-model="identityName"
type="text"
placeholder="Personal"
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
/>
</div>
<!-- Purpose Selection -->
<div class="path-option-card cursor-default px-6 py-6">
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Purpose</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
v-for="p in purposes"
:key="p.value"
@click="selectedPurpose = p.value"
class="px-4 py-3 rounded-lg border text-left transition-all"
:class="selectedPurpose === p.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-black/20 border-white/10 text-white/60 hover:bg-white/10 hover:text-white/80'"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0" :class="p.color">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span class="font-medium text-sm">{{ p.label }}</span>
</div>
<p class="text-xs text-white/50 ml-7">{{ p.desc }}</p>
</button>
</div>
</div>
</div>
</div>
<!-- Error -->
<p v-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
<!-- Action Buttons -->
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
<button
@click="skip"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<button
@click="createIdentity"
:disabled="isCreating"
class="path-action-button path-action-button--continue"
>
<span v-if="isCreating">Creating...</span>
<span v-else>Continue</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const router = useRouter()
const identityName = ref('Personal')
const selectedPurpose = ref('personal')
const isCreating = ref(false)
const errorMessage = ref('')
const purposes = [
{ value: 'personal', label: 'Personal', desc: 'Everyday use', color: 'bg-blue-500/30 text-blue-400' },
{ value: 'business', label: 'Business', desc: 'Professional', color: 'bg-orange-500/30 text-orange-400' },
{ value: 'anonymous', label: 'Anonymous', desc: 'Private', color: 'bg-purple-500/30 text-purple-400' },
]
async function createIdentity() {
isCreating.value = true
errorMessage.value = ''
try {
await rpcClient.call({
method: 'identity.create',
params: {
name: identityName.value || 'Personal',
purpose: selectedPurpose.value
}
})
router.push('/onboarding/backup').catch(() => {})
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Failed to create identity'
} finally {
isCreating.value = false
}
}
function skip() {
router.push('/onboarding/backup').catch(() => {})
}
</script>
+48 -8
View File
@@ -1,25 +1,25 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full">
<div class="glass-card p-12 pt-20 text-center animate-fade-up relative overflow-visible">
<div class="glass-card p-12 pt-20 text-center relative overflow-visible onb-card">
<!-- Logo - half in, half out of container -->
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10 onb-logo">
<div class="logo-gradient-border w-20 h-20">
<AnimatedLogo no-border fit />
</div>
</div>
<h1 class="text-4xl font-bold text-white mb-4">
<h1 class="text-4xl font-bold text-white mb-4 onb-title">
Welcome to Archipelago
</h1>
<p class="text-xl text-white/80 mb-12 max-w-2xl mx-auto">
<p class="text-xl text-white/80 mb-12 max-w-2xl mx-auto onb-tagline">
Your personal server for a sovereign digital life
</p>
<button
@click="goToOptions"
class="glass-button px-8 py-4 rounded-lg text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
class="glass-button px-8 py-4 rounded-lg text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
>
Unlock your sovereignty
</button>
@@ -39,3 +39,43 @@ function goToOptions() {
}
</script>
<style scoped>
.onb-card {
opacity: 0;
animation: onb-card-in 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s forwards;
}
.onb-logo {
opacity: 0;
animation: onb-scale-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.3s forwards;
}
.onb-title {
opacity: 0;
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.7s forwards;
}
.onb-tagline {
opacity: 0;
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1.0s forwards;
}
.onb-cta {
opacity: 0;
animation: onb-fade-in 0.6s ease 1.4s forwards;
}
@keyframes onb-card-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes onb-scale-in {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
@keyframes onb-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes onb-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
+44 -2
View File
@@ -49,7 +49,7 @@
<div class="perspective-container-wrapper">
<div class="perspective-container">
<RouterView v-slot="{ Component, route }">
<Transition name="depth-forward">
<Transition :name="transitionName">
<div :key="route.path" class="view-wrapper">
<component :is="Component" class="view-container" />
</div>
@@ -70,6 +70,14 @@ const currentBackground = ref('bg-intro.jpg')
const isGlitching = ref(false)
const isTransitioning = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
const transitionName = ref('depth-forward')
// Ordered onboarding steps for direction detection
const onboardingOrder = [
'/onboarding/intro', '/onboarding/path', '/onboarding/options',
'/onboarding/did', '/onboarding/identity', '/onboarding/backup',
'/onboarding/verify', '/onboarding/done', '/login'
]
// Routes that should use video background (smooth transition from splash, loops through login)
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
@@ -89,6 +97,7 @@ const routeBackgrounds: Record<string, string> = {
'/onboarding/options': 'bg-intro-4.jpg',
'/onboarding/path': 'bg-intro-3.jpg',
'/onboarding/did': 'bg-intro-5.jpg',
'/onboarding/identity': 'bg-intro-5.jpg',
'/onboarding/backup': 'bg-intro-6.jpg',
'/onboarding/verify': 'bg-intro-2.jpg',
'/onboarding/done': 'bg-intro-1.jpg',
@@ -221,8 +230,16 @@ watch(videoElement, (element) => {
}
})
// Watch route changes for background swaps, zoom, and glitch
// Watch route changes for background swaps, zoom, glitch, and transition direction
watch(() => route.path, (newPath, oldPath) => {
// Determine slide direction based on route order
const oldIdx = onboardingOrder.indexOf(oldPath || '')
const newIdx = onboardingOrder.indexOf(newPath)
if (oldIdx >= 0 && newIdx >= 0) {
transitionName.value = newIdx >= oldIdx ? 'slide-left' : 'slide-right'
} else {
transitionName.value = 'depth-forward'
}
const newBg = routeBackgrounds[newPath]
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
@@ -398,6 +415,31 @@ onMounted(() => {
filter: blur(10px);
}
/* Horizontal slide transitions (direction-aware onboarding steps) */
.slide-left-enter-active.view-wrapper,
.slide-left-leave-active.view-wrapper,
.slide-right-enter-active.view-wrapper,
.slide-right-leave-active.view-wrapper {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s ease;
}
.slide-left-enter-from.view-wrapper {
opacity: 0;
transform: translateX(60px);
}
.slide-left-leave-to.view-wrapper {
opacity: 0;
transform: translateX(-60px);
}
.slide-right-enter-from.view-wrapper {
opacity: 0;
transform: translateX(-60px);
}
.slide-right-leave-to.view-wrapper {
opacity: 0;
transform: translateX(60px);
}
/* Background zoom - 2advanced fluid */
.bg-zoom {
transition: transform 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+178
View File
@@ -541,6 +541,112 @@
</div>
</div>
</div>
<!-- Network & Connectivity Section -->
<div class="path-option-card cursor-default px-6 py-6 mb-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white/96">Network</h2>
<p class="text-sm text-white/60 mt-1">Network connectivity, UPnP, and diagnostics</p>
</div>
<!-- Loading -->
<div v-if="networkLoading" class="py-4 text-center">
<p class="text-white/50 text-sm">Running diagnostics...</p>
</div>
<template v-else-if="networkDiag">
<!-- Status Grid -->
<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">WAN IP</div>
<span class="text-sm text-white font-mono">{{ networkDiag.wan_ip || 'Unknown' }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">NAT Type</div>
<span class="text-sm text-white">{{ networkDiag.nat_type }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">UPnP</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="networkDiag.upnp_available ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white">{{ networkDiag.upnp_available ? 'Available' : 'Unavailable' }}</span>
</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Tor</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="networkDiag.tor_connected ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white">{{ networkDiag.tor_connected ? 'Connected' : 'Offline' }}</span>
</div>
</div>
</div>
<!-- Recommendations -->
<div v-if="networkDiag.recommendations?.length" class="mb-4">
<div class="text-xs text-white/50 mb-2">Recommendations</div>
<div class="space-y-1">
<div v-for="(rec, idx) in networkDiag.recommendations" :key="idx" class="flex items-start gap-2 text-xs text-yellow-400/80 bg-yellow-500/10 rounded-lg px-3 py-2">
<svg class="w-3 h-3 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>{{ rec }}</span>
</div>
</div>
</div>
<!-- Port Forwards -->
<div class="border-t border-white/10 pt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-white">Port Forwards</h3>
<button @click="showAddForwardModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-xs">Add</button>
</div>
<div v-if="portForwards.length" class="space-y-2">
<div v-for="fwd in portForwards" :key="fwd.id" class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="text-sm text-white">
<span class="font-medium">{{ fwd.service_name }}</span>
<span class="text-white/40 text-xs ml-2">:{{ fwd.internal_port }} :{{ fwd.external_port }} ({{ fwd.protocol }})</span>
</div>
<button @click="removePortForward(fwd.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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<div v-else class="text-center text-white/40 text-xs py-3">No port forwards configured</div>
</div>
</template>
<div class="flex justify-end pt-3">
<button @click="runNetworkDiag" :disabled="networkLoading" class="glass-button glass-button-sm px-4 rounded-lg text-sm disabled:opacity-50">
{{ networkLoading ? 'Running...' : 'Run Diagnostics' }}
</button>
</div>
</div>
<!-- Add Port Forward Modal -->
<div v-if="showAddForwardModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showAddForwardModal = false">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-4">Add Port Forward</h2>
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Service Name</label>
<input v-model="newFwdService" type="text" placeholder="Bitcoin RPC" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">Internal Port</label>
<input v-model.number="newFwdInternal" type="number" placeholder="8332" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">External Port</label>
<input v-model.number="newFwdExternal" type="number" placeholder="8332" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
</div>
</div>
<div v-if="fwdError" class="text-xs text-red-400 mt-2">{{ fwdError }}</div>
<div class="flex gap-3 mt-4">
<button @click="showAddForwardModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="addPortForward" :disabled="!newFwdService.trim() || !newFwdInternal || !newFwdExternal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30 disabled:opacity-50">Add</button>
</div>
</div>
</div>
</div>
</template>
@@ -850,9 +956,81 @@ function closeChangePasswordModal() {
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
}
// --- Network & Connectivity ---
interface NetworkDiagData {
wan_ip: string | null
nat_type: string
upnp_available: boolean
tor_connected: boolean
dns_working: boolean
recommendations: string[]
}
interface PortForwardData {
id: string
service_name: string
internal_port: number
external_port: number
protocol: string
enabled: boolean
}
const networkDiag = ref<NetworkDiagData | null>(null)
const networkLoading = ref(false)
const portForwards = ref<PortForwardData[]>([])
const showAddForwardModal = ref(false)
const newFwdService = ref('')
const newFwdInternal = ref(0)
const newFwdExternal = ref(0)
const fwdError = ref('')
async function runNetworkDiag() {
networkLoading.value = true
try {
const [diagRes, fwdRes] = await Promise.all([
rpcClient.call<NetworkDiagData>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: PortForwardData[] }>({ method: 'router.list-forwards' }),
])
networkDiag.value = diagRes
portForwards.value = fwdRes.forwards || []
} catch {
networkDiag.value = null
} finally {
networkLoading.value = false
}
}
async function addPortForward() {
if (!newFwdService.value.trim() || !newFwdInternal.value || !newFwdExternal.value) return
fwdError.value = ''
try {
await rpcClient.call({ method: 'router.add-forward', params: {
service_name: newFwdService.value.trim(),
internal_port: newFwdInternal.value,
external_port: newFwdExternal.value,
protocol: 'TCP',
}})
showAddForwardModal.value = false
newFwdService.value = ''
newFwdInternal.value = 0
newFwdExternal.value = 0
await runNetworkDiag()
} catch (e: unknown) {
fwdError.value = e instanceof Error ? e.message : 'Failed to add forward'
}
}
async function removePortForward(id: string) {
try {
await rpcClient.call({ method: 'router.remove-forward', params: { id } })
await runNetworkDiag()
} catch {
// Silent
}
}
onMounted(async () => {
checkClaudeStatus()
loadTotpStatus()
runNetworkDiag()
if (!serverTorAddressFromStore.value) {
try {
const res = await rpcClient.getTorAddress()
+2163 -253
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
<template>
<div class="pb-16 md:pb-4">
<!-- Back Button -->
<button @click="router.push('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white 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="M15 19l-7-7 7-7" />
</svg>
Back to LND
</button>
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
<!-- Liquidity Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Total Outbound</p>
<p class="text-white text-xl font-bold">{{ formatSats(summary.total_outbound) }}</p>
</div>
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Total Inbound</p>
<p class="text-white text-xl font-bold">{{ formatSats(summary.total_inbound) }}</p>
</div>
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Channels</p>
<p class="text-white text-xl font-bold">{{ channels.length }}</p>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 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>
Open Channel
</button>
</div>
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" 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/70">Loading channels...</p>
</div>
<!-- Error -->
<div v-else-if="error" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" 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>
<p class="text-white/70 mb-2">No channels yet</p>
<p class="text-white/50 text-sm">Open a channel to start sending and receiving Lightning payments.</p>
</div>
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
Close
</button>
</div>
<!-- Peer -->
<p class="text-white/50 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<!-- Capacity Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-white/60 mb-1">
<span>Local: {{ formatSats(ch.local_balance) }}</span>
<span>Remote: {{ formatSats(ch.remote_balance) }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full overflow-hidden flex">
<div
class="bg-blue-400 h-full transition-all"
:style="{ width: capacityPercent(ch.local_balance, ch.capacity) + '%' }"
></div>
<div
class="bg-orange-400 h-full transition-all"
:style="{ width: capacityPercent(ch.remote_balance, ch.capacity) + '%' }"
></div>
</div>
<p class="text-white/40 text-xs mt-1 text-center">
Capacity: {{ formatSats(ch.capacity) }}
</p>
</div>
</div>
</div>
</Transition>
<!-- Open Channel Modal -->
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Peer URI</label>
<input
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input
v-model.number="openForm.amount"
type="number"
min="20000"
placeholder="100000"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
</div>
<div v-if="openError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ openError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showOpenModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
<!-- Close Confirmation Modal -->
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '../../api/rpc-client'
const router = useRouter()
interface Channel {
chan_id: string
remote_pubkey: string
capacity: number
local_balance: number
remote_balance: number
active: boolean
status: string
channel_point: string
}
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
const showOpenModal = ref(false)
const openForm = ref({ peerUri: '', amount: 100000 })
const openingChannel = ref(false)
const openError = ref<string | null>(null)
const closeTarget = ref<Channel | null>(null)
const closingChannel = ref(false)
const closeError = ref<string | null>(null)
function formatSats(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M sats`
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k sats`
return `${sats} sats`
}
function capacityPercent(amount: number, capacity: number): number {
if (capacity <= 0) return 0
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
} finally {
loading.value = false
}
}
async function openChannel() {
if (openingChannel.value) return
openError.value = null
const uri = openForm.value.peerUri.trim()
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
const parts = uri.split('@')
const pubkey = parts[0]
const address = parts[1] || undefined
openingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount },
timeout: 30000,
})
showOpenModal.value = false
openForm.value = { peerUri: '', amount: 100000 }
await loadChannels()
} catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel'
} finally {
openingChannel.value = false
}
}
function confirmClose(ch: Channel) {
closeTarget.value = ch
closeError.value = null
}
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
})
closeTarget.value = null
await loadChannels()
} catch (err: unknown) {
closeError.value = err instanceof Error ? err.message : 'Failed to close channel'
} finally {
closingChannel.value = false
}
}
onMounted(loadChannels)
</script>