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:
co-authored by
Claude Opus 4.6
parent
5ce8b7965c
commit
e3aa95a103
@@ -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 |
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">★</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>
|
||||
@@ -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>
|
||||
@@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export interface DataModel {
|
||||
'server-info': ServerInfo
|
||||
'package-data': { [id: string]: PackageDataEntry }
|
||||
'peer-health'?: { [onion: string]: boolean }
|
||||
ui: UIData
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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>
|
||||
Reference in New Issue
Block a user