feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements
Bitcoin UI: - Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts) - Make all asset paths relative for nginx proxy compatibility - Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely) - Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332) HTTPS mixed content fix: - Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS, iframe loads through nginx proxy instead of direct HTTP port - Prevents browser blocking HTTP iframes inside HTTPS pages - All Tailscale servers use HTTPS, this was breaking all app iframes Deploy & first-boot improvements: - first-boot-containers.sh auto-detects disk size for pruning vs txindex - first-boot-containers.sh checks fallback source path for UI containers - Added mempool-electrs to APP_PORTS mapping - ElectrumX container creation in first-boot - Podman doctor/fix/uptime skills added Also includes: session persistence, identity management, LND transactions, ElectrumX status UI, nostr-provider improvements, Web5 enhancements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,16 @@
|
||||
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<!-- Backdrop — near-black -->
|
||||
<div class="absolute inset-0 bg-black/90 backdrop-blur-xl"></div>
|
||||
<!-- Backdrop — frosted blur -->
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
|
||||
|
||||
<!-- Main panel -->
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`Select identity for ${appName}`"
|
||||
class="relative z-10 w-full max-w-lg"
|
||||
>
|
||||
<!-- Header: screensaver-style glass disc + radial viz ring -->
|
||||
@@ -43,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Identity list -->
|
||||
<div class="glass-card p-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
@@ -61,15 +64,18 @@
|
||||
v-for="identity in identities"
|
||||
:key="identity.id"
|
||||
type="button"
|
||||
class="w-full text-left p-3 rounded-lg border transition-all duration-200"
|
||||
role="radio"
|
||||
:aria-checked="selectedId === identity.id"
|
||||
:aria-label="`Identity: ${identity.name}`"
|
||||
class="w-full text-left p-3 rounded-lg transition-all duration-200"
|
||||
:class="selectedId === identity.id
|
||||
? 'bg-white/8 border-white/25'
|
||||
: 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'"
|
||||
? 'bg-white/10 ring-1 ring-white/20'
|
||||
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
|
||||
@click="selectedId = identity.id"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 border"
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
:class="avatarClasses(identity.purpose)"
|
||||
>
|
||||
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
@@ -85,10 +91,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/20 border border-white/50 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/80"></div>
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
|
||||
</div>
|
||||
<div v-else class="w-5 h-5 rounded-full border border-white/15"></div>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -104,8 +110,8 @@
|
||||
:disabled="!selectedId || !hasNostrKey"
|
||||
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:class="selectedId && hasNostrKey
|
||||
? 'bg-white/10 border border-white/25 text-white hover:bg-white/15'
|
||||
: 'bg-white/3 border border-white/8 text-white/40'"
|
||||
? 'bg-white/10 text-white hover:bg-white/15'
|
||||
: 'bg-white/[0.03] text-white/40'"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
@@ -193,9 +199,9 @@ function truncateNpub(npub: string): string {
|
||||
|
||||
function avatarClasses(purpose: string): string {
|
||||
switch (purpose) {
|
||||
case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25'
|
||||
case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25'
|
||||
default: return 'bg-white/10 text-white/80 border-white/20'
|
||||
case 'business': return 'bg-blue-500/15 text-blue-400'
|
||||
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
|
||||
default: return 'bg-white/10 text-white/80'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -222,16 +222,18 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// If authenticated and visiting /login: show login immediately, validate in background.
|
||||
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
|
||||
if (to.path === '/login' && store.isAuthenticated) {
|
||||
// Redirect back to intended page (from ?redirect= query) or default to home
|
||||
const redirectTo = (to.query.redirect as string) || '/dashboard'
|
||||
if (store.needsSessionValidation()) {
|
||||
next()
|
||||
checkSessionWithTimeout(store).then((valid) => {
|
||||
if (valid) {
|
||||
router.replace({ name: 'home' }).catch(() => {})
|
||||
router.replace(redirectTo).catch(() => {})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
next({ name: 'home' })
|
||||
next(redirectTo)
|
||||
return
|
||||
}
|
||||
next()
|
||||
@@ -245,7 +247,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next()
|
||||
store.checkSession().then((valid) => {
|
||||
if (!valid) {
|
||||
router.replace('/login').catch(() => {})
|
||||
router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {})
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -258,7 +260,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next()
|
||||
return
|
||||
}
|
||||
next('/login')
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,97 +3,59 @@ import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
|
||||
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
|
||||
* These always open in a new tab. Other external sites load directly in the iframe. */
|
||||
/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */
|
||||
const IFRAME_BLOCKED_HOSTS: string[] = []
|
||||
|
||||
/** External site proxy paths — disabled. External URLs load directly in the iframe
|
||||
* via their standard https:// URL. The /ext/ subpath approach broke SPAs. */
|
||||
const EXTERNAL_PROXY_PATH: Record<string, string> = {}
|
||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
'23000', // BTCPay — X-Frame-Options: DENY
|
||||
'3000', // Grafana — X-Frame-Options: deny
|
||||
'2342', // PhotoPrism — X-Frame-Options: DENY
|
||||
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
|
||||
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'9001', // Penpot — not reachable
|
||||
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
|
||||
])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
// External sites that block iframes
|
||||
if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
|
||||
return true
|
||||
}
|
||||
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
|
||||
if (
|
||||
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
|
||||
u.port === '3000' || // Grafana — X-Frame-Options: deny
|
||||
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
|
||||
u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none'
|
||||
u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return NEW_TAB_PORTS.has(u.port)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */
|
||||
const PORT_TO_PROXY: Record<string, string> = {
|
||||
'81': '/app/nginx-proxy-manager/',
|
||||
'3000': '/app/grafana/',
|
||||
'3001': '/app/uptime-kuma/',
|
||||
'8080': '/app/endurain/',
|
||||
'8081': '/app/lnd/',
|
||||
'8082': '/app/vaultwarden/',
|
||||
'8083': '/app/filebrowser/',
|
||||
'8085': '/app/nextcloud/',
|
||||
'8096': '/app/jellyfin/',
|
||||
'8123': '/app/homeassistant/',
|
||||
'8240': '/app/tailscale/',
|
||||
'8334': '/app/bitcoin-ui/',
|
||||
'8888': '/app/searxng/',
|
||||
'9000': '/app/portainer/',
|
||||
'9001': '/app/penpot/',
|
||||
'9980': '/app/onlyoffice/',
|
||||
'11434': '/app/ollama/',
|
||||
'2283': '/app/immich/',
|
||||
'23000': '/app/btcpay/',
|
||||
'2342': '/app/photoprism/',
|
||||
'4080': '/app/mempool/',
|
||||
'8175': '/app/fedimint/',
|
||||
'8176': '/app/fedimint-gateway/',
|
||||
'3100': '/app/dwn/',
|
||||
'18081': '/app/nostr-rs-relay/',
|
||||
'7777': '/app/indeedhub/',
|
||||
/** Port → app ID for resolving URLs to AppSession routes */
|
||||
const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'81': 'nginx-proxy-manager',
|
||||
'3000': 'grafana',
|
||||
'3001': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'8081': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
'8083': 'filebrowser',
|
||||
'8085': 'nextcloud',
|
||||
'8096': 'jellyfin',
|
||||
'8123': 'homeassistant',
|
||||
'8240': 'tailscale',
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'9001': 'penpot',
|
||||
'9980': 'onlyoffice',
|
||||
'11434': 'ollama',
|
||||
'2283': 'immich',
|
||||
'23000': 'btcpay-server',
|
||||
'2342': 'photoprism',
|
||||
'4080': 'mempool',
|
||||
'8175': 'fedimint',
|
||||
'8176': 'fedimint-gateway',
|
||||
'3100': 'dwn',
|
||||
'18081': 'nostr-rs-relay',
|
||||
'7777': 'indeedhub',
|
||||
'50002': 'electrumx',
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
|
||||
* On HTTP, direct port URLs are used — they avoid subpath routing issues
|
||||
* (apps' root-relative asset paths like /static/main.js break under /app/xxx/).
|
||||
* On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options.
|
||||
*/
|
||||
function toEmbeddableUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const origin = window.location.origin
|
||||
|
||||
// External sites proxied through nginx path-based locations
|
||||
const extPath = EXTERNAL_PROXY_PATH[u.hostname]
|
||||
if (extPath) {
|
||||
return `${origin}${extPath}`
|
||||
}
|
||||
|
||||
const proxyPath = PORT_TO_PROXY[u.port]
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
|
||||
if (proxyPath && sameHost && needsProxy) {
|
||||
return `${origin}${proxyPath}`
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||
|
||||
@@ -121,6 +83,8 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
@@ -129,9 +93,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const showConsent = ref(false)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Open app in full-page session view (preferred — no iframe subpath issues) */
|
||||
/** Active app in panel mode (store-based, no route change) */
|
||||
const panelAppId = ref<string | null>(null)
|
||||
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function openSession(appId: string) {
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mode === 'panel') {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelAppId.value = null
|
||||
}
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
@@ -142,13 +119,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
openSession(resolvedId)
|
||||
return
|
||||
}
|
||||
// Apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
const embeddableUrl = toEmbeddableUrl(payload.url)
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = embeddableUrl
|
||||
url.value = payload.url
|
||||
title.value = payload.title
|
||||
isOpen.value = true
|
||||
}
|
||||
@@ -158,11 +135,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
// Check port-based apps
|
||||
for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) {
|
||||
if (u.port === port) {
|
||||
return proxyPath.replace('/app/', '').replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
const appId = PORT_TO_APP_ID[u.port]
|
||||
if (appId) return appId
|
||||
// Check external URLs
|
||||
const EXTERNAL_APP_HOSTS: Record<string, string> = {
|
||||
'botfights.net': 'botfights',
|
||||
@@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
open,
|
||||
openSession,
|
||||
close,
|
||||
closePanel,
|
||||
panelAppId,
|
||||
showConsent,
|
||||
consentRequest,
|
||||
approveConsent,
|
||||
|
||||
@@ -16,6 +16,17 @@ export interface BundledApp {
|
||||
lan_address?: string // Runtime launch URL from backend
|
||||
}
|
||||
|
||||
/** Map bundled app ID to the podman container name(s) used for status matching.
|
||||
* Some apps have a different container name than their app ID, or use a
|
||||
* separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */
|
||||
const CONTAINER_NAME_MAP: Record<string, string[]> = {
|
||||
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
|
||||
'lnd': ['lnd', 'archy-lnd-ui'],
|
||||
'btcpay-server': ['btcpay-server'],
|
||||
'mempool': ['archy-mempool-web'],
|
||||
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
|
||||
}
|
||||
|
||||
export const BUNDLED_APPS: BundledApp[] = [
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
@@ -23,7 +34,7 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'localhost/bitcoinknots/bitcoin:29',
|
||||
description: 'Full Bitcoin node with additional features',
|
||||
icon: '₿',
|
||||
ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }],
|
||||
ports: [{ host: 8334, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
@@ -33,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
|
||||
description: 'Lightning Network Daemon for fast Bitcoin payments',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }],
|
||||
ports: [{ host: 8081, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
|
||||
category: 'lightning',
|
||||
},
|
||||
@@ -48,12 +59,12 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
category: 'home',
|
||||
},
|
||||
{
|
||||
id: 'btcpayserver',
|
||||
id: 'btcpay-server',
|
||||
name: 'BTCPay Server',
|
||||
image: 'docker.io/btcpayserver/btcpayserver:latest',
|
||||
description: 'Self-hosted Bitcoin payment processor',
|
||||
icon: '💳',
|
||||
ports: [{ host: 23000, container: 23000 }],
|
||||
ports: [{ host: 23000, container: 49392 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
@@ -63,30 +74,10 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'docker.io/mempool/frontend:latest',
|
||||
description: 'Bitcoin blockchain and mempool visualizer',
|
||||
icon: '🔍',
|
||||
ports: [{ host: 8080, container: 8080 }],
|
||||
ports: [{ host: 4080, container: 8080 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'nostr-rs-relay',
|
||||
name: 'Nostr Relay (RS)',
|
||||
image: 'docker.io/scsibug/nostr-rs-relay:latest',
|
||||
description: 'Rust-based Nostr relay for decentralized social',
|
||||
icon: '🦩',
|
||||
ports: [{ host: 8008, container: 8080 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/nostr-rs', container: '/usr/src/app/db' }],
|
||||
category: 'other',
|
||||
},
|
||||
{
|
||||
id: 'strfry',
|
||||
name: 'Strfry Relay',
|
||||
image: 'docker.io/hoytech/strfry:latest',
|
||||
description: 'High-performance Nostr relay',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 7777, container: 7777 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/strfry', container: '/app/strfry-db' }],
|
||||
category: 'other',
|
||||
},
|
||||
{
|
||||
id: 'tailscale',
|
||||
name: 'Tailscale VPN',
|
||||
@@ -124,14 +115,18 @@ export const useContainerStore = defineStore('container', () => {
|
||||
healthStatus.value[appId] || 'unknown'
|
||||
)
|
||||
|
||||
// Get container for a bundled app (matches by name)
|
||||
// Get container for a bundled app (matches by explicit name map, then by exact name)
|
||||
const getContainerForApp = computed(() => (appId: string) => {
|
||||
return containers.value.find(c =>
|
||||
c.name === appId ||
|
||||
c.name.includes(appId) ||
|
||||
c.name === `archipelago-${appId}` ||
|
||||
c.name === `archipelago-${appId}-dev`
|
||||
)
|
||||
const nameList = CONTAINER_NAME_MAP[appId]
|
||||
if (nameList) {
|
||||
// Try each known container name in priority order
|
||||
for (const n of nameList) {
|
||||
const found = containers.value.find(c => c.name === n)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
// Fallback: exact match on app ID
|
||||
return containers.value.find(c => c.name === appId)
|
||||
})
|
||||
|
||||
// Check if an app is currently loading (starting/stopping)
|
||||
|
||||
@@ -462,6 +462,106 @@ input[type="radio"]:active + * {
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
/* Incoming Transactions badge */
|
||||
.incoming-tx-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #4ade80;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.incoming-tx-badge:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.incoming-tx-ping {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #4ade80;
|
||||
border-radius: 9999px;
|
||||
animation: incoming-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes incoming-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.5); }
|
||||
}
|
||||
|
||||
/* Incoming transaction row */
|
||||
.incoming-tx-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.incoming-tx-row:hover {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.incoming-tx-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.incoming-tx-icon-pending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.incoming-tx-icon-confirmed {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* Slide-down transition for incoming tx panel */
|
||||
.incoming-tx-slide-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.incoming-tx-slide-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.incoming-tx-slide-enter-from,
|
||||
.incoming-tx-slide-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-8px);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.incoming-tx-slide-enter-to,
|
||||
.incoming-tx-slide-leave-from {
|
||||
opacity: 1;
|
||||
max-height: 500px;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* BANNED: gradient-card, gradient-card-dark, gradient-button
|
||||
Use .glass-card or .path-option-card for containers.
|
||||
Use .glass-button for all buttons.
|
||||
|
||||
@@ -526,8 +526,9 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
indeedhub: 'indeedhub',
|
||||
electrs: 'mempool-electrs',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrumx: 'electrumx',
|
||||
electrs: 'electrumx',
|
||||
'mempool-electrs': 'electrumx',
|
||||
}
|
||||
|
||||
/** Backend may register under variant container names */
|
||||
@@ -536,7 +537,7 @@ const PACKAGE_ALIASES: Record<string, string[]> = {
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
'mempool-web': ['archy-mempool-web'],
|
||||
indeedhub: ['indeedhub-build_app_1'],
|
||||
electrs: ['mempool-electrs', 'archy-electrs'],
|
||||
electrumx: ['mempool-electrs', 'electrs', 'archy-electrs'],
|
||||
}
|
||||
|
||||
function resolvePackageKey(routeId: string): string {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="app-session-root">
|
||||
<Teleport to="body" :disabled="displayMode === 'panel'">
|
||||
<Teleport to="body" :disabled="isInlinePanel">
|
||||
<div
|
||||
:class="backdropClasses"
|
||||
@click.self="goBack"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
ref="sessionRef"
|
||||
@@ -178,12 +178,24 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
|
||||
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
/** True when rendered inline via store (panel mode), false when route-based */
|
||||
const isInlinePanel = computed(() => !!props.appIdProp)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -214,6 +226,25 @@ function setMode(mode: DisplayMode) {
|
||||
displayMode.value = mode
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
||||
showModeMenu.value = false
|
||||
|
||||
// Switch from inline panel → route-based overlay/fullscreen
|
||||
if (isInlinePanel.value && mode !== 'panel') {
|
||||
const id = appId.value
|
||||
emit('close')
|
||||
router.push({ name: 'app-session', params: { appId: id } })
|
||||
return
|
||||
}
|
||||
|
||||
// Switch from route-based → inline panel
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
router.push({ name: 'apps' }).then(() => {
|
||||
launcher.panelAppId = id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Enter fullscreen if selected
|
||||
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
@@ -222,49 +253,92 @@ function setMode(mode: DisplayMode) {
|
||||
|
||||
// Reactive classes based on display mode
|
||||
const backdropClasses = computed(() => {
|
||||
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
|
||||
return 'app-session-backdrop-overlay'
|
||||
}
|
||||
return 'app-session-backdrop-panel'
|
||||
if (isInlinePanel.value) return 'app-session-backdrop-inline'
|
||||
return 'app-session-backdrop-overlay'
|
||||
})
|
||||
|
||||
const panelClasses = computed(() => {
|
||||
const base = 'app-session-panel glass-card'
|
||||
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
|
||||
if (isInlinePanel.value) return `${base} app-session-inline`
|
||||
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
|
||||
return `${base} app-session-inpanel`
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
|
||||
const appId = computed(() => route.params.appId as string)
|
||||
const appId = computed(() => props.appIdProp || (route.params.appId as string))
|
||||
|
||||
const APP_URLS: Record<string, string> = {
|
||||
// Container apps — use nginx proxy paths (strips X-Frame-Options)
|
||||
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
||||
const APP_PORTS: Record<string, number> = {
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'lnd': 8081,
|
||||
'archy-lnd-ui': 8081,
|
||||
'mempool': 4080,
|
||||
'mempool-web': 4080,
|
||||
'archy-mempool-web': 4080,
|
||||
'homeassistant': 8123,
|
||||
'grafana': 3000,
|
||||
'searxng': 8888,
|
||||
'ollama': 11434,
|
||||
'onlyoffice': 9980,
|
||||
'penpot': 9001,
|
||||
'nextcloud': 8085,
|
||||
'vaultwarden': 8082,
|
||||
'jellyfin': 8096,
|
||||
'photoprism': 2342,
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'filebrowser': 8083,
|
||||
'nginx-proxy-manager': 81,
|
||||
'portainer': 9000,
|
||||
'uptime-kuma': 3001,
|
||||
'tailscale': 8240,
|
||||
'fedimint': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'nostr-rs-relay': 18081,
|
||||
'indeedhub': 7777,
|
||||
'dwn': 3100,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
/** Apps that need nginx proxy for iframe embedding.
|
||||
* IndeedHub loads via direct port 7777 — deploy script removes X-Frame-Options
|
||||
* from the container's internal nginx so iframe works on all servers. */
|
||||
const PROXY_APPS: Record<string, string> = {}
|
||||
|
||||
/** Nginx proxy paths — used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
|
||||
* On HTTP, direct port access is used instead (faster, no proxy). */
|
||||
const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'electrs': '/app/electrs/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
'lnd': '/app/lnd/',
|
||||
'electrumx': '/app/electrs/',
|
||||
'electrs': '/app/electrs/',
|
||||
'mempool-electrs': '/app/electrs/',
|
||||
'mempool': '/app/mempool/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'grafana': '/app/grafana/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'archy-mempool-web': '/app/mempool/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'searxng': '/app/searxng/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'ollama': '/app/ollama/',
|
||||
'onlyoffice': '/app/onlyoffice/',
|
||||
'penpot': '/app/penpot/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'immich': '/app/immich/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'portainer': '/app/portainer/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'immich_server': '/app/immich/',
|
||||
'tailscale': '/app/tailscale/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'nostr-rs-relay': '/app/nostr-rs-relay/',
|
||||
'endurain': '/app/endurain/',
|
||||
'indeedhub': '/app/indeedhub/',
|
||||
'dwn': '/app/dwn/',
|
||||
'endurain': '/app/endurain/',
|
||||
}
|
||||
|
||||
/** External HTTPS apps — always loaded directly */
|
||||
const EXTERNAL_URLS: Record<string, string> = {
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
@@ -286,15 +360,47 @@ const APP_TITLES: Record<string, string> = {
|
||||
|
||||
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
||||
|
||||
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
||||
const NEW_TAB_APPS = new Set([
|
||||
'btcpay-server', // X-Frame-Options: DENY
|
||||
'grafana', // X-Frame-Options: deny
|
||||
'photoprism', // X-Frame-Options: DENY
|
||||
'homeassistant', // X-Frame-Options: SAMEORIGIN
|
||||
'vaultwarden', // X-Frame-Options: SAMEORIGIN
|
||||
'nextcloud', // X-Frame-Options: SAMEORIGIN
|
||||
'uptime-kuma', // X-Frame-Options: SAMEORIGIN
|
||||
'penpot', // Not reachable / blocks iframe
|
||||
])
|
||||
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
const appUrl = computed(() => {
|
||||
const url = APP_URLS[appId.value]
|
||||
if (!url) return ''
|
||||
// Proxy paths — same origin
|
||||
if (url.startsWith('/')) return `${window.location.origin}${url}`
|
||||
// External HTTPS sites — direct
|
||||
if (url.startsWith('https://')) return url
|
||||
// Fallback: localhost port URLs (shouldn't reach here normally)
|
||||
return url.replace('localhost', window.location.hostname)
|
||||
const id = appId.value
|
||||
|
||||
// External HTTPS apps — iframe overlay
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
|
||||
const proxyPath = PROXY_APPS[id]
|
||||
if (proxyPath) return `${window.location.origin}${proxyPath}`
|
||||
|
||||
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
|
||||
if (window.location.protocol === 'https:') {
|
||||
const httpsProxy = HTTPS_PROXY_PATHS[id]
|
||||
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
|
||||
}
|
||||
|
||||
// HTTP: direct port access (faster, no proxy overhead)
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
let base = `http://${window.location.hostname}:${port}`
|
||||
|
||||
// Append sub-path from query param (e.g. ?path=/tx/abc123)
|
||||
const subpath = route.query.path as string | undefined
|
||||
if (subpath) base += subpath
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Identity ---
|
||||
@@ -325,6 +431,9 @@ function onIdentitySelected(identity: SelectedIdentity) {
|
||||
showIdentityPicker.value = false
|
||||
storeIdentity(identity)
|
||||
sendIdentity(identity)
|
||||
// NIP-98 auto-login disabled — apps like IndeedHub have their own login flow
|
||||
// that properly sets up internal account state. We provide window.nostr via
|
||||
// nostr-provider.js so the app's built-in "Sign In" button works.
|
||||
}
|
||||
|
||||
async function sendIdentity(identity: SelectedIdentity) {
|
||||
@@ -339,6 +448,8 @@ async function sendIdentity(identity: SelectedIdentity) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// NIP-98 auto-login removed — apps handle their own login via window.nostr (NIP-07)
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
function onLoad() {
|
||||
@@ -393,7 +504,7 @@ function startLoadTimeout() {
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
goBack()
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
@@ -408,14 +519,14 @@ function iframeGoForward() {
|
||||
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
router.back()
|
||||
function handleBackdropClick() {
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function closeSession() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
router.push({ name: 'apps' })
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.push({ name: 'apps' })
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -463,12 +574,18 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
const { id, method, params } = event.data
|
||||
const source = event.source as Window | null
|
||||
if (!source) return
|
||||
const identityId = getStoredIdentity()?.id || null
|
||||
const storedIdentity = getStoredIdentity()
|
||||
const identityId = storedIdentity?.id || null
|
||||
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
if (method === 'getPublicKey') {
|
||||
if (identityId) {
|
||||
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
|
||||
if (storedIdentity?.nostr_pubkey) {
|
||||
result = storedIdentity.nostr_pubkey
|
||||
console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
|
||||
} else if (identityId) {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
@@ -476,11 +593,13 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
|
||||
if (identityId) {
|
||||
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
|
||||
} else {
|
||||
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
}
|
||||
console.log('[NIP-07] signEvent OK')
|
||||
} else if (method === 'getRelays') { result = {} }
|
||||
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
@@ -489,23 +608,30 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
|
||||
source.postMessage({ type: 'nostr-response', id, result }, '*')
|
||||
} catch (err) {
|
||||
console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes (X-Frame-Options) — open in new tab, close session
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.back()
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
document.addEventListener('click', onClickOutside)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
// Known blocked apps — show fallback immediately
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
} else {
|
||||
startLoadTimeout()
|
||||
}
|
||||
// Enter fullscreen if that's the stored mode
|
||||
if (displayMode.value === 'fullscreen') {
|
||||
requestAnimationFrame(() => {
|
||||
sessionRef.value?.requestFullscreen().catch(() => {})
|
||||
@@ -528,19 +654,18 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
|
||||
.app-session-backdrop-panel {
|
||||
/* Inline panel mode — fills content area, no blur, original layout */
|
||||
.app-session-backdrop-inline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-session-inpanel {
|
||||
.app-session-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -550,10 +675,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-backdrop-panel {
|
||||
.app-session-backdrop-inline {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.app-session-inpanel {
|
||||
.app-session-inline {
|
||||
border-radius: 1rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
|
||||
@@ -81,8 +81,9 @@
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace')) }"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
@click="appLauncher.closePanel()"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -292,6 +293,13 @@
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel mode app session — renders alongside current page content -->
|
||||
<Transition name="panel-slide">
|
||||
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
||||
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
|
||||
@@ -308,11 +316,12 @@
|
||||
v-for="item in mobileNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
@click="appLauncher.closePanel()"
|
||||
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'
|
||||
? (route.path.includes('/apps') || route.path.includes('/marketplace'))
|
||||
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session'))
|
||||
: (route.path.includes('/cloud') || route.path.includes('/server')))
|
||||
: undefined
|
||||
}"
|
||||
@@ -385,6 +394,8 @@ import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import AppSession from '@/views/AppSession.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
@@ -405,6 +416,7 @@ const chatFullscreen = computed(() => route.path === '/dashboard/chat')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const web5Badge = useWeb5BadgeStore()
|
||||
|
||||
@@ -1257,6 +1269,27 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
}
|
||||
|
||||
/* Wrapper to contain perspective without clipping */
|
||||
/* Panel mode app session — fills content area, sidebar stays untouched */
|
||||
.app-panel-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.panel-slide-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.panel-slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.panel-slide-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.panel-slide-leave-to {
|
||||
transform: translateX(40px) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -96,14 +96,28 @@
|
||||
</div>
|
||||
<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">{{ t('home.installed') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
<p class="text-xs text-white/60 mb-1">Installed / Running</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}/{{ runningCount }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
|
||||
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
|
||||
<button
|
||||
v-for="app in quickLaunchApps"
|
||||
:key="app.id"
|
||||
@click="useAppLauncherStore().openSession(app.id)"
|
||||
class="group"
|
||||
:title="app.name"
|
||||
>
|
||||
<div
|
||||
class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center"
|
||||
:style="app.bg ? { background: app.bg } : {}"
|
||||
:class="{ 'bg-white/5': !app.bg }"
|
||||
>
|
||||
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('home.browseStore') }}
|
||||
@@ -476,6 +490,12 @@ const runningCount = computed(() =>
|
||||
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
|
||||
)
|
||||
|
||||
const quickLaunchApps = [
|
||||
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
|
||||
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
|
||||
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
|
||||
]
|
||||
|
||||
// Network card computed values
|
||||
const servicesAllRunning = computed(() =>
|
||||
appCount.value > 0 && runningCount.value === appCount.value
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@@ -214,6 +214,10 @@ import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const currentRoute = useRoute()
|
||||
|
||||
/** After login, redirect to the intended page or default to home */
|
||||
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard')
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
@@ -377,8 +381,8 @@ async function handleSetup() {
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await store.login(password.value)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
@@ -421,8 +425,8 @@ async function handleLogin() {
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
@@ -456,8 +460,8 @@ async function handleTotpVerify() {
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
|
||||
@@ -445,12 +445,12 @@ 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' }],
|
||||
'electrumx': [{ 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' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', dockerImage: 'docker.io/lukechilds/electrumx:v1.18.0' },
|
||||
],
|
||||
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
|
||||
}
|
||||
@@ -533,7 +533,7 @@ async function installDependencies() {
|
||||
installError.value = null
|
||||
|
||||
try {
|
||||
// Install dependencies sequentially (order matters: bitcoin before electrs)
|
||||
// Install dependencies sequentially (order matters: bitcoin before electrumx)
|
||||
for (const dep of missingDeps) {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="autoAdvancing" class="text-lg text-white/80 mb-2">DID retrieved, continuing...</p>
|
||||
<p v-else class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your node's decentralized identifier
|
||||
</p>
|
||||
</div>
|
||||
@@ -128,10 +127,8 @@ const generatedDid = ref<string>('')
|
||||
const nostrNpub = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const autoAdvancing = ref(false)
|
||||
const didCopied = ref(false)
|
||||
const npubCopied = ref(false)
|
||||
const elapsedSeconds = ref(0)
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -141,7 +138,6 @@ function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
elapsedSeconds.value = secs
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
@@ -179,7 +175,6 @@ async function fetchDid() {
|
||||
}
|
||||
}).catch(() => { /* Nostr key may not exist yet */ })
|
||||
|
||||
autoAdvanceAfterDelay()
|
||||
} catch {
|
||||
isGenerating.value = false
|
||||
if (!waitingForServer.value) {
|
||||
@@ -190,13 +185,6 @@ async function fetchDid() {
|
||||
}
|
||||
}
|
||||
|
||||
function autoAdvanceAfterDelay() {
|
||||
autoAdvancing.value = true
|
||||
setTimeout(() => {
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = localStorage.getItem('neode_did')
|
||||
const cachedNpub = localStorage.getItem('neode_nostr_npub')
|
||||
|
||||
@@ -119,7 +119,7 @@ async function signChallenge() {
|
||||
if (did) {
|
||||
const result = await rpcClient.call({
|
||||
method: 'identity.verify',
|
||||
params: { did, data: currentChallenge.value, signature: sig },
|
||||
params: { did, message: currentChallenge.value, signature: sig },
|
||||
}) as { valid: boolean }
|
||||
verified.value = result.valid !== false
|
||||
} else {
|
||||
|
||||
@@ -328,12 +328,70 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
|
||||
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
|
||||
</div>
|
||||
<!-- Incoming Transactions Badge -->
|
||||
<button
|
||||
v-if="incomingTxCount > 0"
|
||||
@click="showIncomingTxPanel = !showIncomingTxPanel"
|
||||
class="incoming-tx-badge shrink-0"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
<span>Incoming {{ incomingTxCount }}</span>
|
||||
<span class="incoming-tx-ping"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Transactions Panel -->
|
||||
<transition name="incoming-tx-slide">
|
||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
|
||||
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-white/5">
|
||||
<div
|
||||
v-for="tx in incomingTransactions"
|
||||
:key="tx.tx_hash"
|
||||
class="incoming-tx-row"
|
||||
@click="openInMempool(tx.tx_hash)"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="incoming-tx-icon" :class="tx.num_confirmations === 0 ? 'incoming-tx-icon-pending' : 'incoming-tx-icon-confirmed'">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
|
||||
>
|
||||
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
|
||||
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<!-- On-chain Balance -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
@@ -1149,20 +1207,24 @@
|
||||
class="card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
|
||||
:style="{ '--stagger-index': idx }"
|
||||
>
|
||||
<!-- Purpose Icon -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
}">
|
||||
<svg class="w-5 h-5" :class="{
|
||||
'text-blue-400': identity.purpose === 'personal',
|
||||
'text-orange-400': identity.purpose === 'business',
|
||||
'text-purple-400': identity.purpose === 'anonymous',
|
||||
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Avatar (clickable to edit profile) -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
}">
|
||||
<span class="text-sm font-bold" :class="{
|
||||
'text-blue-400': identity.purpose === 'personal',
|
||||
'text-orange-400': identity.purpose === 'business',
|
||||
'text-purple-400': identity.purpose === 'anonymous',
|
||||
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -1175,14 +1237,25 @@
|
||||
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
|
||||
}">{{ identity.purpose }}</span>
|
||||
</div>
|
||||
<p class="text-white/50 text-xs font-mono truncate mt-0.5" :title="identity.did">{{ identity.did }}</p>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
|
||||
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
|
||||
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
|
||||
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
|
||||
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
|
||||
@@ -1201,6 +1274,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Create Identity Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
|
||||
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
|
||||
@@ -1233,8 +1307,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
|
||||
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
|
||||
@@ -1247,7 +1323,221 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Key Viewer Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
|
||||
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
|
||||
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
|
||||
}">
|
||||
<svg class="w-5 h-5" :class="{
|
||||
'text-blue-400': keyViewerIdentity.purpose === 'personal',
|
||||
'text-orange-400': keyViewerIdentity.purpose === 'business',
|
||||
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
|
||||
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
|
||||
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Keys -->
|
||||
<div class="space-y-3 mb-5">
|
||||
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
Public Keys
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">DID (Ed25519)</span>
|
||||
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
|
||||
</div>
|
||||
<div class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
|
||||
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
|
||||
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
|
||||
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Private Keys Section -->
|
||||
<div class="border-t border-white/10 pt-5">
|
||||
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
Private Keys
|
||||
</h3>
|
||||
|
||||
<!-- Locked state — password required -->
|
||||
<div v-if="!keyViewerPrivateKeys">
|
||||
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="keyViewerPassword"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
|
||||
@keydown.enter="unlockPrivateKeys"
|
||||
/>
|
||||
<button
|
||||
@click="unlockPrivateKeys"
|
||||
:disabled="!keyViewerPassword || keyViewerUnlocking"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50"
|
||||
>
|
||||
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Unlocked state — show private keys -->
|
||||
<div v-else class="space-y-2">
|
||||
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
|
||||
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
|
||||
{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
|
||||
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
|
||||
{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
|
||||
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
|
||||
{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
|
||||
</div>
|
||||
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">
|
||||
Lock private keys
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<div class="flex justify-end mt-5">
|
||||
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Profile Editor Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
||||
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
|
||||
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} · {{ profileEditorIdentity.purpose }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Display Name</label>
|
||||
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full 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">About / Bio</label>
|
||||
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." 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 resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
|
||||
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full 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">Banner Image URL</label>
|
||||
<input v-model="profileForm.banner" type="url" placeholder="https://..." 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">Website</label>
|
||||
<input v-model="profileForm.website" type="url" placeholder="https://..." 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">NIP-05 (Nostr address)</label>
|
||||
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
|
||||
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full 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="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-red-300 text-xs">{{ profileError }}</p>
|
||||
</div>
|
||||
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg">
|
||||
<p class="text-green-300 text-xs">{{ profileSuccess }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||
{{ profileSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">
|
||||
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Unified Send Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
|
||||
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
|
||||
@@ -1346,8 +1636,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Unified Receive Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
|
||||
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
|
||||
@@ -1411,6 +1703,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Decentralized Web Node (DWN) -->
|
||||
<div class="glass-card p-6 mb-8">
|
||||
@@ -1641,6 +1934,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Domains Management Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -1716,8 +2010,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Relay Management Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -1759,6 +2055,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Identity Toast -->
|
||||
<Transition name="content-fade">
|
||||
@@ -1770,7 +2067,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@@ -2194,6 +2491,62 @@ const connectingWallet = ref(false)
|
||||
const lndOnchainBalance = ref(0)
|
||||
const lndChannelBalance = ref(0)
|
||||
|
||||
// Incoming Transactions
|
||||
interface WalletTransaction {
|
||||
tx_hash: string
|
||||
amount_sats: number
|
||||
direction: 'incoming' | 'outgoing'
|
||||
num_confirmations: number
|
||||
time_stamp: number
|
||||
total_fees: number
|
||||
dest_addresses: string[]
|
||||
label: string
|
||||
block_height: number
|
||||
}
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
const showIncomingTxPanel = ref(false)
|
||||
|
||||
const incomingTransactions = computed(() =>
|
||||
walletTransactions.value.filter(tx => tx.direction === 'incoming')
|
||||
)
|
||||
const incomingTxCount = computed(() => incomingTransactions.value.length)
|
||||
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
|
||||
walletTransactions.value = res.transactions || []
|
||||
// Auto-show panel when new unconfirmed incoming txs appear
|
||||
const pending = res.incoming_pending_count || 0
|
||||
if (pending > 0 && !showIncomingTxPanel.value) {
|
||||
showIncomingTxPanel.value = true
|
||||
}
|
||||
} catch {
|
||||
walletTransactions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatTxTime(timestamp: number): string {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function openInMempool(txHash: string) {
|
||||
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
|
||||
}
|
||||
|
||||
// Auto-refresh wallet data every 30s
|
||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Nostr Relays
|
||||
interface NostrRelayData {
|
||||
url: string
|
||||
@@ -2988,6 +3341,16 @@ function copyOnionAddress() {
|
||||
}
|
||||
|
||||
// --- Identity Management ---
|
||||
interface IdentityProfile {
|
||||
display_name?: string
|
||||
about?: string
|
||||
picture?: string
|
||||
banner?: string
|
||||
website?: string
|
||||
nip05?: string
|
||||
lud16?: string
|
||||
}
|
||||
|
||||
interface ManagedIdentity {
|
||||
id: string
|
||||
name: string
|
||||
@@ -2996,6 +3359,131 @@ interface ManagedIdentity {
|
||||
did: string
|
||||
created_at: string
|
||||
is_default: boolean
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
profile?: IdentityProfile
|
||||
}
|
||||
|
||||
// --- Key Viewer Modal ---
|
||||
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
|
||||
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
|
||||
const keyViewerPassword = ref('')
|
||||
const keyViewerUnlocking = ref(false)
|
||||
const keyViewerError = ref('')
|
||||
const keyViewerCopied = ref<string | null>(null)
|
||||
|
||||
function openKeyViewer(identity: ManagedIdentity) {
|
||||
keyViewerIdentity.value = identity
|
||||
keyViewerPrivateKeys.value = null
|
||||
keyViewerPassword.value = ''
|
||||
keyViewerError.value = ''
|
||||
}
|
||||
|
||||
function closeKeyViewer() {
|
||||
// Clear sensitive data immediately
|
||||
keyViewerPrivateKeys.value = null
|
||||
keyViewerPassword.value = ''
|
||||
keyViewerError.value = ''
|
||||
keyViewerIdentity.value = null
|
||||
}
|
||||
|
||||
async function unlockPrivateKeys() {
|
||||
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
|
||||
keyViewerUnlocking.value = true
|
||||
keyViewerError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
ed25519_secret_hex: string
|
||||
nostr_secret_hex: string | null
|
||||
nostr_nsec: string | null
|
||||
}>({
|
||||
method: 'identity.export-keys',
|
||||
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
|
||||
})
|
||||
keyViewerPrivateKeys.value = {
|
||||
ed25519_secret_hex: res.ed25519_secret_hex,
|
||||
nostr_secret_hex: res.nostr_secret_hex || '',
|
||||
nostr_nsec: res.nostr_nsec || '',
|
||||
}
|
||||
keyViewerPassword.value = '' // Clear password from memory immediately
|
||||
} catch (err: unknown) {
|
||||
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
|
||||
} finally {
|
||||
keyViewerUnlocking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyKeyValue(label: string, value: string) {
|
||||
safeClipboardWrite(value)
|
||||
keyViewerCopied.value = label
|
||||
setTimeout(() => { keyViewerCopied.value = null }, 2000)
|
||||
}
|
||||
|
||||
// --- Profile Editor ---
|
||||
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
|
||||
const profileForm = ref<IdentityProfile>({})
|
||||
const profileSaving = ref(false)
|
||||
const profilePublishing = ref(false)
|
||||
const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
|
||||
function openProfileEditor(identity: ManagedIdentity) {
|
||||
profileEditorIdentity.value = identity
|
||||
profileForm.value = { ...identity.profile }
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
}
|
||||
|
||||
function closeProfileEditor() {
|
||||
profileEditorIdentity.value = null
|
||||
profileForm.value = {}
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!profileEditorIdentity.value || profileSaving.value) return
|
||||
profileSaving.value = true
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.update-profile',
|
||||
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
||||
})
|
||||
await loadIdentities()
|
||||
profileSuccess.value = 'Profile saved'
|
||||
setTimeout(() => { profileSuccess.value = '' }, 3000)
|
||||
} catch (err: unknown) {
|
||||
profileError.value = err instanceof Error ? err.message : 'Failed to save'
|
||||
} finally {
|
||||
profileSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishProfile() {
|
||||
if (!profileEditorIdentity.value || profilePublishing.value) return
|
||||
profilePublishing.value = true
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
try {
|
||||
// Save first, then publish
|
||||
await rpcClient.call({
|
||||
method: 'identity.update-profile',
|
||||
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
||||
})
|
||||
const res = await rpcClient.call<{ event_id: string }>({
|
||||
method: 'identity.publish-profile',
|
||||
params: { id: profileEditorIdentity.value.id },
|
||||
})
|
||||
await loadIdentities()
|
||||
profileSuccess.value = `Published to relay (${res.event_id.slice(0, 12)}...)`
|
||||
setTimeout(() => { profileSuccess.value = '' }, 5000)
|
||||
} catch (err: unknown) {
|
||||
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
|
||||
} finally {
|
||||
profilePublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const managedIdentities = ref<ManagedIdentity[]>([])
|
||||
@@ -3090,7 +3578,14 @@ onMounted(() => {
|
||||
loadNostrRelays()
|
||||
loadCredentials()
|
||||
loadLndBalances()
|
||||
loadTransactions()
|
||||
detectHardwareWallets()
|
||||
// Auto-refresh wallet balances and transactions every 30s
|
||||
walletRefreshInterval = setInterval(() => {
|
||||
loadLndBalances()
|
||||
loadTransactions()
|
||||
loadEcashBalance()
|
||||
}, 30000)
|
||||
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
|
||||
if (route.query.tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
@@ -3101,6 +3596,13 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (walletRefreshInterval) {
|
||||
clearInterval(walletRefreshInterval)
|
||||
walletRefreshInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
|
||||
Reference in New Issue
Block a user