feat: v1.2.0-alpha — E2E encrypted mesh relay, steganography, relay status polling
Phase 5 mesh networking: - E2E encrypted TX relay (X25519 + ChaCha20-Poly1305) — non-Archy nodes relay encrypted blobs transparently via Meshcore native routing - Steganographic encoding modes (WeatherStation, SensorNetwork) — traffic looks like sensor data on the wire, 0xAA marker, configurable per-node - Pre-flight Bitcoin Core health check on relay node — specific error codes (bitcoin_unreachable, bitcoin_syncing, tx_rejected) instead of generic fails - mesh.relay-status RPC endpoint — frontend polls for relay result every 3s - On-Chain / Lightning tabs in Off-Grid Bitcoin panel - Archy Peers vs Mesh Broadcast relay mode selector - Mesh view fills viewport (no page scroll), internal panel scrolling - Version bump to 1.2.0-alpha Also includes: deploy hardening, container fixes, IndeedHub updates, boot screen, dashboard improvements, MASTER_PLAN task tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
70f1348c15
commit
d37ec1dea5
@@ -333,7 +333,6 @@ const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
'immich_server': '/app/immich/',
|
||||
'tailscale': '/app/tailscale/',
|
||||
'endurain': '/app/endurain/',
|
||||
'indeedhub': '/app/indeedhub/',
|
||||
'dwn': '/app/dwn/',
|
||||
}
|
||||
|
||||
@@ -385,6 +384,17 @@ const appUrl = computed(() => {
|
||||
const proxyPath = PROXY_APPS[id]
|
||||
if (proxyPath) return `${window.location.origin}${proxyPath}`
|
||||
|
||||
// IndeedHub: always direct port (X-Frame-Options removed by deploy script)
|
||||
if (id === 'indeedhub') {
|
||||
const port = APP_PORTS[id]
|
||||
if (port) {
|
||||
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
|
||||
const subpath = route.query.path as string | undefined
|
||||
if (subpath) base += subpath
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
||||
+84
-35
@@ -1,19 +1,20 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Desktop: tabs + search in one row -->
|
||||
<div class="hidden md:flex items-center gap-4 mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search -->
|
||||
<div class="hidden md:flex mb-4 items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<button
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
|
||||
@click="activeTab = 'apps'"
|
||||
>My Apps</button>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
|
||||
@click="activeTab = 'services'"
|
||||
>Services</button>
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@@ -24,21 +25,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: tabs + search -->
|
||||
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="mode-switcher mode-switcher-full mb-3">
|
||||
<button
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
|
||||
@click="activeTab = 'apps'"
|
||||
>My Apps</button>
|
||||
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
|
||||
@click="activeTab = 'services'"
|
||||
>Services</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -253,6 +241,8 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
@@ -267,7 +257,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
@@ -277,33 +267,88 @@ import { PackageState, type PackageDataEntry } from '../types/api'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref<'apps' | 'services'>('apps')
|
||||
// Tabs — support ?tab=services from Marketplace link
|
||||
const activeTab = ref<'apps' | 'services'>(
|
||||
route.query.tab === 'services' ? 'services' : 'apps'
|
||||
)
|
||||
|
||||
|
||||
// Service container name patterns (backend/infra, not user-facing)
|
||||
// Exact container names or prefixes that are backend services (not user-facing)
|
||||
const SERVICE_NAMES = new Set([
|
||||
'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
'immich_postgres', 'immich_redis',
|
||||
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
'mysql-mempool',
|
||||
])
|
||||
|
||||
function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
const lower = id.toLowerCase()
|
||||
return lower.includes('_db') || lower.includes('-db') && !lower.includes('indeedhub')
|
||||
? SERVICE_NAMES.has(id)
|
||||
: false
|
||||
// Catch any indeedhub-build_* compose infrastructure containers
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
// Catch database containers
|
||||
if (id.endsWith('_db') || id.endsWith('-db')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Search
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Category filter (same categories as App Store)
|
||||
const selectedCategory = ref('all')
|
||||
|
||||
// Known app → category mappings (matches App Store categorisation)
|
||||
const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'onlyoffice': 'data',
|
||||
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
|
||||
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
|
||||
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
'uptime-kuma': 'networking', 'dwn': 'data',
|
||||
'botfights': 'l484', 'nwnn': 'l484', '484-kitchen': 'l484',
|
||||
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
||||
}
|
||||
|
||||
function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
||||
// Check hardcoded map first, then manifest category, then fallback
|
||||
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
|
||||
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
|
||||
return cat || 'other'
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES = computed(() => [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'community', name: t('marketplace.community') },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
||||
{ id: 'money', name: t('marketplace.money') },
|
||||
{ id: 'data', name: t('marketplace.data') },
|
||||
{ id: 'media', name: 'Media' },
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'l484', name: 'L484' },
|
||||
{ id: 'other', name: t('marketplace.other') },
|
||||
])
|
||||
|
||||
const categoriesWithApps = computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
|
||||
return ALL_CATEGORIES.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Track loading states for each app action
|
||||
const loadingActions = ref<Record<string, boolean>>({})
|
||||
|
||||
@@ -381,10 +426,14 @@ const packages = computed(() => {
|
||||
// Web-only apps first (alphabetically), then all other apps (alphabetically)
|
||||
const sortedPackageEntries = computed(() => {
|
||||
const entries = Object.entries(packages.value)
|
||||
// Filter by active tab
|
||||
const filtered = entries.filter(([id]) => {
|
||||
// Filter by active tab and category
|
||||
const filtered = entries.filter(([id, pkg]) => {
|
||||
const isSvc = isServiceContainer(id)
|
||||
return activeTab.value === 'services' ? isSvc : !isSvc
|
||||
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
|
||||
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
|
||||
return getAppCategory(id, pkg) === selectedCategory.value
|
||||
}
|
||||
return true
|
||||
})
|
||||
return filtered.sort(([idA, a], [idB, b]) => {
|
||||
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
||||
|
||||
@@ -189,38 +189,22 @@
|
||||
:class="{ 'glass-throw-mobile-tabs': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
|
||||
>
|
||||
<div class="glass-card p-2 rounded-lg flex gap-2 relative">
|
||||
<!-- Animated Active Indicator -->
|
||||
<div
|
||||
class="absolute top-2 bottom-2 rounded-lg bg-white/20 transition-all duration-300 ease-out"
|
||||
:style="{
|
||||
left: `${appsTabIndicatorLeft}px`,
|
||||
width: `${appsTabIndicatorWidth}px`,
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div class="mode-switcher mode-switcher-full">
|
||||
<RouterLink
|
||||
ref="appsTabRef"
|
||||
to="/dashboard/apps"
|
||||
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
|
||||
:class="{
|
||||
'bg-white/20 text-white': route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/'),
|
||||
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/'))
|
||||
}"
|
||||
>
|
||||
My Apps
|
||||
</RouterLink>
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
|
||||
>My Apps</RouterLink>
|
||||
<RouterLink
|
||||
ref="marketplaceTabRef"
|
||||
to="/dashboard/marketplace"
|
||||
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
|
||||
:class="{
|
||||
'bg-white/20 text-white': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/'),
|
||||
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/'))
|
||||
}"
|
||||
>
|
||||
App Store
|
||||
</RouterLink>
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') }"
|
||||
>App Store</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps?tab=services"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' }"
|
||||
>Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -232,38 +216,27 @@
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
|
||||
:style="{ top: showAppsTabs ? '80px' : '0' }"
|
||||
>
|
||||
<div class="glass-card p-2 rounded-lg flex gap-2 relative">
|
||||
<!-- Animated Active Indicator -->
|
||||
<div
|
||||
class="absolute top-2 bottom-2 rounded-lg bg-white/20 transition-all duration-300 ease-out"
|
||||
:style="{
|
||||
left: `${networkTabIndicatorLeft}px`,
|
||||
width: `${networkTabIndicatorWidth}px`,
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div class="mode-switcher mode-switcher-full">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/web5' || route.path.startsWith('/dashboard/web5/') }"
|
||||
>Web5</RouterLink>
|
||||
<RouterLink
|
||||
ref="cloudTabRef"
|
||||
to="/dashboard/cloud"
|
||||
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
|
||||
:class="{
|
||||
'bg-white/20 text-white': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/'),
|
||||
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/'))
|
||||
}"
|
||||
>
|
||||
Cloud
|
||||
</RouterLink>
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/') }"
|
||||
>Cloud</RouterLink>
|
||||
<RouterLink
|
||||
ref="serverTabRef"
|
||||
to="/dashboard/server"
|
||||
class="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-300 text-center relative z-10"
|
||||
:class="{
|
||||
'bg-white/20 text-white': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/'),
|
||||
'text-white/60 hover:text-white/80': !(route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/'))
|
||||
}"
|
||||
>
|
||||
Network
|
||||
</RouterLink>
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/') }"
|
||||
>Network</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/mesh"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/mesh' || route.path.startsWith('/dashboard/mesh/') }"
|
||||
>Mesh</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -273,7 +246,7 @@
|
||||
<Transition :name="getTransitionName(route)">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
v-if="route.path === '/dashboard/chat'"
|
||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||
class="h-full"
|
||||
>
|
||||
<component :is="Component" />
|
||||
@@ -524,7 +497,7 @@ const showNetworkTabs = computed(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (window.innerWidth >= 768) return false
|
||||
if (route.name === 'cloud-folder') return false
|
||||
return route.path.includes('/server') || route.path.includes('/cloud')
|
||||
return route.path.includes('/server') || route.path.includes('/cloud') || route.path.includes('/web5') || route.path.includes('/mesh')
|
||||
})
|
||||
|
||||
// Top padding for content div to clear fixed mobile tab overlays
|
||||
@@ -700,7 +673,7 @@ const desktopNavItems = computed(() => {
|
||||
const gamerMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
|
||||
{ path: '/dashboard/cloud', label: 'Network', icon: 'server', isCombined: true },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5', isCombined: true },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
@@ -754,6 +727,7 @@ async function handleLogout() {
|
||||
|
||||
// Track previous route for transition logic
|
||||
let previousPath = ''
|
||||
let previousTab = ''
|
||||
|
||||
// Tab order for vertical transitions
|
||||
const tabOrder = [
|
||||
@@ -809,38 +783,38 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
|
||||
|
||||
let transitionName = 'fade'
|
||||
|
||||
// Mobile: Horizontal slide transitions between Apps and Marketplace (check first on mobile)
|
||||
// Mobile: Horizontal slide transitions between sub-tabs
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
// From Marketplace to Apps: slide right
|
||||
if (wasMarketplaceList && isAppsList) {
|
||||
transitionName = 'slide-right'
|
||||
// Apps group: My Apps (0) → App Store (1) → Services (2)
|
||||
const isServices = currentPath === '/dashboard/apps' && currentRoute.query.tab === 'services'
|
||||
const wasServices = previousTab === 'services'
|
||||
const currentAppsIdx = isServices ? 2
|
||||
: currentPath === '/dashboard/marketplace' ? 1
|
||||
: currentPath === '/dashboard/apps' ? 0 : -1
|
||||
const prevAppsIdx = wasServices ? 2
|
||||
: previousPath === '/dashboard/marketplace' ? 1
|
||||
: previousPath === '/dashboard/apps' ? 0 : -1
|
||||
|
||||
// Web5 group: Web5 (0) → Cloud (1) → Network (2) → Mesh (3)
|
||||
const web5TabOrder = ['/dashboard/web5', '/dashboard/cloud', '/dashboard/server', '/dashboard/mesh']
|
||||
const currentWeb5Idx = web5TabOrder.indexOf(currentPath)
|
||||
const prevWeb5Idx = web5TabOrder.indexOf(previousPath)
|
||||
|
||||
// Apps sub-tab transitions
|
||||
if (currentAppsIdx !== -1 && prevAppsIdx !== -1 && currentAppsIdx !== prevAppsIdx) {
|
||||
transitionName = currentAppsIdx > prevAppsIdx ? 'slide-left' : 'slide-right'
|
||||
}
|
||||
// From Apps to Marketplace: slide left
|
||||
else if (wasAppsList && isMarketplaceList) {
|
||||
transitionName = 'slide-left'
|
||||
// Web5 sub-tab transitions
|
||||
else if (currentWeb5Idx !== -1 && prevWeb5Idx !== -1 && currentWeb5Idx !== prevWeb5Idx) {
|
||||
transitionName = currentWeb5Idx > prevWeb5Idx ? 'slide-left' : 'slide-right'
|
||||
}
|
||||
// From Network to Cloud: slide right
|
||||
else if (previousPath === '/dashboard/server' && isCloudList) {
|
||||
transitionName = 'slide-right'
|
||||
}
|
||||
// From Cloud to Network: slide left
|
||||
else if (wasCloudList && currentPath === '/dashboard/server') {
|
||||
transitionName = 'slide-left'
|
||||
}
|
||||
// Vertical transition: between main tabs (mobile fallback)
|
||||
// Vertical transition: between main bottom nav tabs
|
||||
else {
|
||||
const currentIndex = tabOrder.indexOf(currentPath)
|
||||
const previousIndex = tabOrder.indexOf(previousPath)
|
||||
|
||||
|
||||
if (currentIndex !== -1 && previousIndex !== -1 && currentIndex !== previousIndex) {
|
||||
// Moving down the menu (visual down)
|
||||
if (currentIndex > previousIndex) {
|
||||
transitionName = 'slide-down'
|
||||
}
|
||||
// Moving up the menu (visual up)
|
||||
else {
|
||||
transitionName = 'slide-up'
|
||||
}
|
||||
transitionName = currentIndex > previousIndex ? 'slide-down' : 'slide-up'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -892,6 +866,7 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
|
||||
|
||||
// Update previous path for next transition
|
||||
previousPath = currentPath
|
||||
previousTab = (currentRoute.query.tab as string) || ''
|
||||
|
||||
return transitionName
|
||||
}
|
||||
|
||||
+175
-34
@@ -226,7 +226,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Overview -->
|
||||
<!-- Wallet Overview -->
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
@@ -237,42 +237,123 @@
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.web5') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.web5Desc') }}</p>
|
||||
<div class="home-card-text flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 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>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('web5.wallet') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 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>
|
||||
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</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="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
|
||||
@click="openInMempool(tx.tx_hash)"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15'"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" :class="tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400'" 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="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="web5DidStatus === 'Active' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm text-white/80">{{ t('home.didStatus') }}</span>
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="web5DidStatus === 'Active' ? 'text-green-400' : 'text-white/50'">{{ web5DidStatus }}</span>
|
||||
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="web5DwnStatus === 'Synced' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm text-white/80">{{ t('home.dwnSync') }}</span>
|
||||
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="web5DwnStatus === 'Synced' ? 'text-green-400' : 'text-white/50'">{{ web5DwnStatus }}</span>
|
||||
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/30"></div>
|
||||
<span class="text-sm text-white/80">{{ t('home.credentials') }}</span>
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ t('web5.ecash') }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-white/50 font-medium">{{ web5CredentialCount }}</span>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('home.manageWeb5') }}
|
||||
<div class="home-card-buttons grid grid-cols-3 gap-2 mt-auto pt-4 shrink-0">
|
||||
<button @click="showSendModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('common.send') }}
|
||||
</button>
|
||||
<button @click="showReceiveModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('web5.receiveBitcoin') }}
|
||||
</button>
|
||||
<RouterLink to="/dashboard/web5" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Web5
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,12 +473,18 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send/Receive Bitcoin Modals -->
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -628,29 +715,83 @@ onMounted(async () => {
|
||||
loadWeb5Status()
|
||||
})
|
||||
|
||||
// Web5 status (fetched from RPC instead of hardcoded)
|
||||
const web5DidStatus = ref('--')
|
||||
const web5DwnStatus = ref('--')
|
||||
const web5CredentialCount = ref('--')
|
||||
// Send/Receive modals
|
||||
const showSendModal = ref(false)
|
||||
const showReceiveModal = ref(false)
|
||||
|
||||
// Wallet balances and transactions (fetched from RPC)
|
||||
const walletConnected = ref(false)
|
||||
const walletOnchain = ref(0)
|
||||
const walletLightning = ref(0)
|
||||
const walletEcash = ref(0)
|
||||
const showIncomingTxPanel = ref(false)
|
||||
|
||||
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 incomingTransactions = computed(() =>
|
||||
walletTransactions.value.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
|
||||
)
|
||||
const incomingTxCount = computed(() => incomingTransactions.value.length)
|
||||
|
||||
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}` } })
|
||||
}
|
||||
|
||||
async function loadWeb5Status() {
|
||||
try {
|
||||
const identity = await rpcClient.call<{ did: string }>({ method: 'identity.get', timeout: 5000 })
|
||||
web5DidStatus.value = identity.did ? 'Active' : 'Inactive'
|
||||
const res = await rpcClient.call<{
|
||||
balance_sats: number
|
||||
channel_balance_sats: number
|
||||
}>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
walletOnchain.value = res.balance_sats || 0
|
||||
walletLightning.value = res.channel_balance_sats || 0
|
||||
walletConnected.value = true
|
||||
} catch {
|
||||
web5DidStatus.value = '--'
|
||||
walletConnected.value = false
|
||||
walletOnchain.value = 0
|
||||
walletLightning.value = 0
|
||||
}
|
||||
try {
|
||||
const dwn = await rpcClient.call<{ status: string }>({ method: 'dwn.health', timeout: 5000 })
|
||||
web5DwnStatus.value = dwn.status === 'ok' ? 'Synced' : dwn.status || '--'
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
|
||||
walletEcash.value = res.balance_sats ?? 0
|
||||
} catch {
|
||||
web5DwnStatus.value = '--'
|
||||
walletEcash.value = 0
|
||||
}
|
||||
try {
|
||||
const creds = await rpcClient.call<{ credentials: unknown[] }>({ method: 'identity.list-credentials', timeout: 5000 })
|
||||
web5CredentialCount.value = String(creds.credentials?.length ?? 0)
|
||||
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 })
|
||||
walletTransactions.value = res.transactions || []
|
||||
const pending = res.incoming_pending_count || 0
|
||||
if (pending > 0 && !showIncomingTxPanel.value) {
|
||||
showIncomingTxPanel.value = true
|
||||
}
|
||||
} catch {
|
||||
web5CredentialCount.value = '0'
|
||||
walletTransactions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<button
|
||||
@@ -101,7 +102,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Search only (categories handled by floating filter modal) -->
|
||||
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden mb-4">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
|
||||
+135
-29
@@ -35,6 +35,8 @@ const relayingLn = ref(false)
|
||||
const relayResult = ref('')
|
||||
const meshSendAddr = ref('')
|
||||
const meshSendAmount = ref('')
|
||||
const relayMode = ref<'archy' | 'broadcast'>('archy')
|
||||
const sendTab = ref<'onchain' | 'lightning'>('onchain')
|
||||
const deadmanConfiguring = ref(false)
|
||||
const deadmanInterval = ref('21600')
|
||||
const deadmanEnabled = ref(false)
|
||||
@@ -72,11 +74,15 @@ async function handleMeshSendBitcoin() {
|
||||
params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) },
|
||||
})
|
||||
// Step 2: Relay via mesh
|
||||
relayResult.value = 'Sending via mesh radio...'
|
||||
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex)
|
||||
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for broadcast confirmation from peers`
|
||||
relayResult.value = relayMode.value === 'broadcast'
|
||||
? 'Broadcasting via mesh network...'
|
||||
: 'Sending to Archy peers (encrypted)...'
|
||||
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex, relayMode.value)
|
||||
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for relay peer to broadcast...`
|
||||
meshSendAddr.value = ''
|
||||
meshSendAmount.value = ''
|
||||
// Step 3: Poll for relay result (every 3s for 90s)
|
||||
pollRelayStatus(relayRes.request_id)
|
||||
} catch (err: unknown) {
|
||||
relayResult.value = err instanceof Error ? err.message : 'Send failed'
|
||||
} finally {
|
||||
@@ -84,6 +90,30 @@ async function handleMeshSendBitcoin() {
|
||||
}
|
||||
}
|
||||
|
||||
function pollRelayStatus(requestId: number) {
|
||||
let attempts = 0
|
||||
const maxAttempts = 30
|
||||
const interval = setInterval(async () => {
|
||||
attempts++
|
||||
try {
|
||||
const res = await mesh.relayStatus(requestId)
|
||||
if (res.status === 'confirmed' && res.txid) {
|
||||
relayResult.value = `TX broadcast! txid: ${res.txid.slice(0, 8)}...${res.txid.slice(-8)}`
|
||||
clearInterval(interval)
|
||||
} else if (res.status === 'failed') {
|
||||
const code = res.error_code ? ` [${res.error_code}]` : ''
|
||||
relayResult.value = `Relay failed${code}: ${res.error || 'unknown error'}`
|
||||
clearInterval(interval)
|
||||
} else if (attempts >= maxAttempts) {
|
||||
relayResult.value += ' (timed out waiting for confirmation)'
|
||||
clearInterval(interval)
|
||||
}
|
||||
} catch {
|
||||
if (attempts >= maxAttempts) clearInterval(interval)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleRelayTx() {
|
||||
if (!txHexInput.value.trim()) return
|
||||
relayingTx.value = true
|
||||
@@ -221,6 +251,7 @@ function openChat(peer: MeshPeer) {
|
||||
activeChatChannel.value = null
|
||||
sendError.value = ''
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
mesh.markChatRead(peer.contact_id)
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
@@ -230,6 +261,7 @@ function openChannelChat(channel: { index: number; name: string }) {
|
||||
activeChatPeer.value = null
|
||||
sendError.value = ''
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
|
||||
@@ -468,6 +500,11 @@ function truncatePubkey(hex: string | null): string {
|
||||
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||
|
||||
<!-- Relay status notification -->
|
||||
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
|
||||
{{ relayResult }}
|
||||
</div>
|
||||
|
||||
<!-- Block Headers -->
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
@@ -483,46 +520,61 @@ function truncatePubkey(hex: string | null): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Bitcoin (creates TX + auto-relays) -->
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
<span class="mesh-bitcoin-label">Send Bitcoin (Off-Grid)</span>
|
||||
</div>
|
||||
<!-- On-Chain / Lightning tabs -->
|
||||
<div class="mesh-send-tabs">
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain tab -->
|
||||
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
|
||||
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
|
||||
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
|
||||
{{ relayingTx ? 'Sending...' : 'Send Bitcoin via Mesh' }}
|
||||
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
|
||||
</button>
|
||||
<details class="mesh-bitcoin-advanced">
|
||||
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
|
||||
<div style="margin-top: 8px;">
|
||||
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
||||
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
||||
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Send Lightning (auto-relays invoice) -->
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
<span class="mesh-bitcoin-label">Pay Lightning Invoice (Off-Grid)</span>
|
||||
</div>
|
||||
<!-- Lightning tab -->
|
||||
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
|
||||
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
|
||||
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
|
||||
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: Raw TX Relay -->
|
||||
<details class="mesh-bitcoin-advanced">
|
||||
<summary class="mesh-bitcoin-label">Advanced: Raw TX Relay</summary>
|
||||
<div class="mesh-bitcoin-section" style="margin-top: 8px;">
|
||||
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
||||
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
||||
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') ? 'error' : 'success'">
|
||||
{{ relayResult }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dead Man's Switch Panel -->
|
||||
@@ -790,7 +842,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mesh-right {
|
||||
@@ -800,6 +852,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Status card ─── */
|
||||
@@ -1493,6 +1546,59 @@ function truncatePubkey(hex: string | null): string {
|
||||
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
|
||||
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-bitcoin-input-sm { max-width: 200px; }
|
||||
.mesh-relay-mode {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.mesh-relay-mode-option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(255,255,255,0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.mesh-relay-mode-option:hover { border-color: rgba(255,255,255,0.2); }
|
||||
.mesh-relay-mode-option.active {
|
||||
border-color: rgba(251,146,60,0.4);
|
||||
background: rgba(251,146,60,0.08);
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
.mesh-relay-mode-option input[type="radio"] { display: none; }
|
||||
.mesh-relay-mode-icon { font-size: 1rem; }
|
||||
.mesh-relay-mode-option small { display: block; font-size: 0.65rem; color: rgba(255,255,255,0.4); }
|
||||
.mesh-send-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.mesh-send-tab {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
color: rgba(255,255,255,0.5);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.mesh-send-tab:first-child { border-right: 1px solid rgba(255,255,255,0.1); }
|
||||
.mesh-send-tab:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.03); }
|
||||
.mesh-send-tab.active {
|
||||
color: #fb923c;
|
||||
background: rgba(251,146,60,0.08);
|
||||
}
|
||||
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-block-row {
|
||||
display: flex;
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
|
||||
<svg class="animate-spin h-8 w-8 text-white/60" 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 class="min-h-full">
|
||||
<BootScreen :visible="showBootScreen" @ready="onServerReady" />
|
||||
<div v-if="!showBootScreen" class="min-h-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
|
||||
<svg class="animate-spin h-8 w-8 text-white/60" 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
import BootScreen from '@/components/BootScreen.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const showBootScreen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
async function quickHealthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 2000)
|
||||
const res = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server.echo', params: { message: 'ping' } }),
|
||||
signal: ac.signal,
|
||||
})
|
||||
clearTimeout(t)
|
||||
return res.status !== 502 && res.status !== 503
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedToApp() {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.replace('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// Check localStorage first for instant redirect (avoids 5s timeout)
|
||||
const localComplete = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
if (localComplete) {
|
||||
router.replace('/login').catch(() => {})
|
||||
@@ -41,11 +63,51 @@ onMounted(async () => {
|
||||
seenOnboarding = false
|
||||
}
|
||||
router.replace(seenOnboarding ? '/login' : '/onboarding/intro').catch(() => {})
|
||||
}
|
||||
|
||||
function onServerReady() {
|
||||
// Clear flags so splash intro plays on reload
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
localStorage.removeItem('neode_onboarding_complete')
|
||||
// Reload with ?intro=1 so we know to skip boot and let App.vue handle splash
|
||||
window.location.href = '/?intro=1'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
|
||||
// Coming back from boot screen — do nothing, let App.vue's SplashScreen take over
|
||||
if (route.query.intro === '1') {
|
||||
// Clean the URL without navigating
|
||||
window.history.replaceState({}, '', '/')
|
||||
return
|
||||
}
|
||||
|
||||
// Standard dev modes
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
// Boot dev mode — always show boot screen
|
||||
if (devMode === 'boot') {
|
||||
showBootScreen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Production: quick health check
|
||||
const isUp = await quickHealthCheck()
|
||||
if (isUp) {
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
// Server not ready — show boot screen
|
||||
showBootScreen.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Only show spinner after 500ms delay — most redirects happen instantly */
|
||||
.root-redirect-fade {
|
||||
animation: root-fade-in 0.3s ease 0.5s forwards;
|
||||
}
|
||||
|
||||
@@ -332,30 +332,51 @@
|
||||
<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 -->
|
||||
<!-- Transaction Activity Badge -->
|
||||
<button
|
||||
v-if="incomingTxCount > 0"
|
||||
v-if="txActivityCount > 0"
|
||||
@click="showIncomingTxPanel = !showIncomingTxPanel"
|
||||
class="incoming-tx-badge shrink-0"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
<span>Incoming {{ incomingTxCount }}</span>
|
||||
<span v-if="incomingTxCount > 0">Incoming {{ incomingTxCount }}</span>
|
||||
<span v-if="meshRelayActive" class="ml-1">Mesh TX</span>
|
||||
<span class="incoming-tx-ping"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Transactions Panel -->
|
||||
<!-- Transaction Activity 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 v-if="showIncomingTxPanel && (incomingTransactions.length > 0 || meshRelayActive)" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
|
||||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Transactions</span>
|
||||
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-white/5">
|
||||
<!-- Mesh Relay TX (outgoing via mesh) -->
|
||||
<div v-if="meshRelayActive" class="incoming-tx-row">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="incoming-tx-icon" style="background: rgba(251,146,60,0.15);">
|
||||
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-orange-400">Mesh Relay</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">
|
||||
{{ sendResultTxid ? 'Broadcast' : 'Sending...' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 mt-0.5">{{ meshRelayStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Incoming TXs -->
|
||||
<div
|
||||
v-for="tx in incomingTransactions"
|
||||
:key="tx.tx_hash"
|
||||
@@ -2540,6 +2561,7 @@ const incomingTransactions = computed(() =>
|
||||
walletTransactions.value.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
|
||||
)
|
||||
const incomingTxCount = computed(() => incomingTransactions.value.length)
|
||||
const txActivityCount = computed(() => incomingTxCount.value + (meshRelayActive.value ? 1 : 0))
|
||||
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user