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:
Dorian
2026-03-17 23:56:37 +00:00
parent 70f1348c15
commit d37ec1dea5
48 changed files with 3432 additions and 438 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.9f8m1arrh28"
"revision": "0.h5o7c3cl7uo"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -27,9 +27,13 @@ const docker = new Docker()
const app = express()
const PORT = 5959
// Dev mode from environment (setup, onboarding, existing, or default)
// Dev mode from environment (setup, onboarding, existing, boot, or default)
const DEV_MODE = process.env.VITE_DEV_MODE || 'default'
// Boot mode: simulate server startup delay
let BOOT_START_TIME = Date.now()
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
// CORS configuration
const corsOptions = {
credentials: true,
@@ -89,6 +93,15 @@ function initializeUserState() {
passwordHash: MOCK_PASSWORD,
}
break
case 'boot':
// Boot mode: Simulate server startup delay (shows boot screen)
// Server responds with 502 for the first 10 seconds, then works like onboarding mode
userState = {
setupComplete: true,
onboardingComplete: false,
passwordHash: MOCK_PASSWORD,
}
break
default:
// Default: Fully set up (for UI development)
userState = {
@@ -748,6 +761,26 @@ app.post('/rpc/v1', (req, res) => {
const { method, params } = req.body
console.log(`[RPC] ${method}`)
// Boot mode: return 502 during simulated startup delay
if (DEV_MODE === 'boot') {
// Reset boot timer when browser does a fresh page load (server.echo with 'boot' message)
if (method === 'server.echo' && params?.message === 'boot-reset') {
BOOT_START_TIME = Date.now()
console.log(`[Boot] Timer RESET — simulating ${BOOT_DELAY_MS / 1000}s startup`)
return res.status(502).json({ error: 'Server starting up (reset)' })
}
const elapsed = Date.now() - BOOT_START_TIME
if (elapsed < BOOT_DELAY_MS) {
const secs = Math.round(elapsed / 1000)
const total = Math.round(BOOT_DELAY_MS / 1000)
console.log(`[Boot] Server starting... ${secs}s / ${total}s`)
return res.status(502).json({ error: 'Server starting up' })
}
if (elapsed < BOOT_DELAY_MS + 2000) {
console.log(`[Boot] Server is now READY (took ${Math.round(elapsed / 1000)}s)`)
}
}
try {
switch (method) {
// Authentication endpoints

View File

@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.1.0",
"version": "1.2.0-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -10,6 +10,7 @@
"test:watch": "vitest",
"dev": "vite",
"dev:mock": "concurrently \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
"dev:boot": "VITE_DEV_MODE=boot concurrently \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js",
"backend:real": "cd ../core && cargo run --release",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -158,7 +158,8 @@ function onKeyDown(e: KeyboardEvent) {
}
const route = useRoute()
const showSplash = ref(true)
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
const isReady = ref(false)
/**
@@ -175,16 +176,22 @@ onMounted(async () => {
window.addEventListener('touchstart', onUserActivity)
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isDirectRoute = route.path !== '/'
if (seenIntro || isDirectRoute) {
const fromBoot = route.query.intro === '1'
if (fromBoot && !seenIntro) {
// Coming from boot screen — show the full splash intro
showSplash.value = true
// SplashScreen will emit 'complete' → handleSplashComplete
} else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') {
// Normal first visit (not boot mode) — show splash intro
showSplash.value = true
} else {
// Already seen intro, direct route, or boot mode (boot screen handles intro)
showSplash.value = false
document.body.classList.add('splash-complete')
// Wait for router to finish initial navigation before showing content (fixes hard refresh)
await router.isReady()
isReady.value = true
}
// If splash should show, wait for it to complete
// SplashScreen will emit 'complete' which calls handleSplashComplete
})
onBeforeUnmount(() => {

View File

@@ -0,0 +1,457 @@
<template>
<Transition name="boot-fade">
<div v-if="visible" class="boot-screen" @click="handleClick">
<!-- Particle starfield -->
<canvas ref="canvasRef" class="boot-stars" />
<!-- Two-column layout: terminal left, orb right -->
<div class="boot-layout" :class="{ 'boot-layout-centered': bootDone }">
<!-- Left: Terminal log (fades out when done) -->
<Transition name="terminal-fade">
<div v-if="!bootDone" class="boot-left">
<div class="boot-terminal" ref="terminalRef">
<p v-for="(line, i) in logLines" :key="i" class="boot-log-line" :class="line.type">
<span class="boot-log-ts">{{ line.prefix }}</span>
<span>{{ line.text }}</span>
</p>
<span class="boot-cursor">_</span>
</div>
<div class="boot-progress-wrap">
<svg class="boot-arc" viewBox="0 0 200 12" preserveAspectRatio="none">
<rect x="0" y="4" width="200" height="4" rx="2" fill="rgba(255,255,255,0.06)" />
<rect x="0" y="4" :width="progress * 2" height="4" rx="2" fill="url(#boot-grad)" />
<defs>
<linearGradient id="boot-grad" x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fb923c" />
<stop offset="1" stop-color="#f59e0b" />
</linearGradient>
</defs>
</svg>
<span class="boot-pct">{{ Math.round(progress) }}%</span>
</div>
</div>
</Transition>
<!-- Right (or center when done): The orb / screensaver -->
<div class="boot-right">
<div class="boot-orb" :class="{ 'boot-orb-screensaver': bootDone }">
<!-- Viz ring segments -->
<div class="boot-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="boot-viz-seg"
:style="{ '--si': i, '--sd': `${(i / 48) * 360}deg` }"
:class="{ 'boot-seg-lit': i < litBars }"
/>
</div>
<!-- Center: screensaver-style bordered frame with pixel icon / logo -->
<div class="boot-center-icon">
<div class="logo-gradient-border boot-icon-frame">
<div class="boot-icon-inner">
<Transition name="icon-morph" mode="out-in">
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<div v-html="icons[currentIcon]" />
</div>
<div v-else key="logo" class="boot-logo-inner-logo">
<AnimatedLogo size="xl" no-border fit />
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ ready: [] }>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const bootDone = ref(false)
const currentIcon = ref(0)
const progress = ref(0)
const litBars = ref(0)
const glitching = ref(false)
// 16x16 pixel art icons
const icons = [
// Big smiley — warm and friendly
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="4" y="5" width="2" height="3" fill="white"/>
<rect x="10" y="5" width="2" height="3" fill="white"/>
<rect x="4" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
<rect x="10" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
<rect x="3" y="10" width="2" height="1" fill="white"/>
<rect x="11" y="10" width="2" height="1" fill="white"/>
<rect x="5" y="11" width="6" height="1" fill="white"/>
</svg>`,
// Bitcoin
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="5" y="1" width="2" height="2" fill="#f7931a"/><rect x="9" y="1" width="2" height="2" fill="#f7931a"/>
<rect x="4" y="3" width="2" height="10" fill="#f7931a"/><rect x="6" y="3" width="4" height="2" fill="#f7931a"/>
<rect x="10" y="4" width="2" height="3" fill="#f7931a"/><rect x="6" y="7" width="4" height="2" fill="#f7931a"/>
<rect x="10" y="8" width="2" height="4" fill="#f7931a"/><rect x="6" y="11" width="4" height="2" fill="#f7931a"/>
<rect x="5" y="13" width="2" height="2" fill="#f7931a"/><rect x="9" y="13" width="2" height="2" fill="#f7931a"/>
</svg>`,
// Lightning
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="8" y="0" width="3" height="3" fill="#fbbf24"/><rect x="6" y="3" width="3" height="3" fill="#fbbf24"/>
<rect x="4" y="6" width="8" height="2" fill="#fbbf24"/><rect x="7" y="8" width="3" height="3" fill="#fbbf24"/>
<rect x="5" y="11" width="3" height="3" fill="#fbbf24"/><rect x="3" y="14" width="3" height="2" fill="#fbbf24"/>
</svg>`,
// Shield
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="3" y="1" width="10" height="2" fill="#60a5fa"/><rect x="2" y="3" width="2" height="7" fill="#60a5fa"/>
<rect x="12" y="3" width="2" height="7" fill="#60a5fa"/><rect x="4" y="10" width="2" height="2" fill="#60a5fa"/>
<rect x="10" y="10" width="2" height="2" fill="#60a5fa"/><rect x="6" y="12" width="4" height="2" fill="#60a5fa"/>
<rect x="7" y="5" width="2" height="4" fill="white" opacity="0.5"/><rect x="6" y="6" width="4" height="2" fill="white" opacity="0.5"/>
</svg>`,
// Key
`<svg viewBox="0 0 16 16" class="boot-svg">
<circle cx="5" cy="6" r="3" fill="none" stroke="#4ade80" stroke-width="1.5"/>
<rect x="7" y="5" width="7" height="2" fill="#4ade80"/>
<rect x="12" y="7" width="2" height="2" fill="#4ade80"/><rect x="10" y="7" width="2" height="2" fill="#4ade80"/>
</svg>`,
// Mesh nodes
`<svg viewBox="0 0 16 16" class="boot-svg">
<circle cx="8" cy="8" r="2" fill="white"/>
<circle cx="3" cy="3" r="1.5" fill="#a78bfa"/><circle cx="13" cy="3" r="1.5" fill="#a78bfa"/>
<circle cx="3" cy="13" r="1.5" fill="#a78bfa"/><circle cx="13" cy="13" r="1.5" fill="#a78bfa"/>
<line x1="8" y1="8" x2="3" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="13" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="3" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="13" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
</svg>`,
]
interface LogLine { prefix: string; text: string; type: string }
const logLines = ref<LogLine[]>([])
const bootMessages = [
{ delay: 500, prefix: 'sys', text: 'Archipelago v0.1.0', type: 'info' },
{ delay: 1500, prefix: 'sec', text: 'Loading ed25519 keys...', type: 'info' },
{ delay: 3000, prefix: ' ok', text: 'Cryptographic keys loaded', type: 'success' },
{ delay: 4500, prefix: 'net', text: 'Binding to port 5678', type: 'info' },
{ delay: 5500, prefix: ' ok', text: 'Nginx proxy detected', type: 'success' },
{ delay: 7000, prefix: ' id', text: 'Initializing identity store...', type: 'info' },
{ delay: 8500, prefix: ' ok', text: 'DID resolver online', type: 'success' },
{ delay: 10000, prefix: 'btc', text: 'Connecting to Bitcoin node...', type: 'info' },
{ delay: 12000, prefix: 'lnd', text: 'Lightning daemon syncing', type: 'info' },
{ delay: 14000, prefix: ' ok', text: 'LND chain synced', type: 'success' },
{ delay: 16000, prefix: 'pod', text: 'Scanning containers...', type: 'info' },
{ delay: 17500, prefix: ' ok', text: '12 containers discovered', type: 'success' },
{ delay: 19000, prefix: 'sec', text: 'AppArmor profiles verified', type: 'success' },
{ delay: 20500, prefix: 'dwn', text: 'DWN node connected', type: 'success' },
{ delay: 22000, prefix: 'msh', text: 'Mesh radio initialized', type: 'success' },
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
]
// Starfield
let animFrame = 0
const stars: { x: number; y: number; z: number }[] = []
function initStars(c: HTMLCanvasElement) {
for (let i = 0; i < 180; i++) {
stars.push({ x: (Math.random() - 0.5) * c.width * 3, y: (Math.random() - 0.5) * c.height * 3, z: Math.random() * 1500 + 500 })
}
}
function drawStars(c: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, c.width, c.height)
const speed = 0.6 + (progress.value / 100) * 2.5
const cx = c.width / 2, cy = c.height / 2
for (const s of stars) {
s.z -= speed
if (s.z <= 0) { s.z = 1500; s.x = (Math.random() - 0.5) * c.width * 3; s.y = (Math.random() - 0.5) * c.height * 3 }
const sx = (s.x / s.z) * 300 + cx, sy = (s.y / s.z) * 300 + cy
if (sx < 0 || sx > c.width || sy < 0 || sy > c.height) continue
const size = Math.max(0.5, (1 - s.z / 1500) * 2)
const alpha = Math.min(1, (1 - s.z / 1500) * 1.2)
ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.7})`; ctx.fill()
}
animFrame = requestAnimationFrame(() => drawStars(c, ctx))
}
function triggerGlitch() { glitching.value = true; setTimeout(() => { glitching.value = false }, 200) }
function handleClick() {
if (!bootDone.value) return
// Clear intro flag so App.vue's SplashScreen plays the full intro sequence
localStorage.removeItem('neode_intro_seen')
// Also clear onboarding flag so it goes through onboarding after intro
localStorage.removeItem('neode_onboarding_complete')
emit('ready')
}
// Health check
async function checkHealth(): Promise<boolean> {
try {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 3000)
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: 'boot' } }),
signal: ac.signal,
})
clearTimeout(t)
return res.status !== 502 && res.status !== 503
} catch { return false }
}
let iconInterval: ReturnType<typeof setInterval> | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
let logTimeouts: ReturnType<typeof setTimeout>[] = []
function startPolling() {
iconInterval = setInterval(() => {
if (!bootDone.value) { currentIcon.value = (currentIcon.value + 1) % icons.length; triggerGlitch() }
}, 2500)
// Feed boot log messages — the visual sequence drives the timeline
const lastMsgDelay = bootMessages[bootMessages.length - 1]!.delay
for (const msg of bootMessages) {
logTimeouts.push(setTimeout(() => {
logLines.value.push({ prefix: msg.prefix, text: msg.text, type: msg.type })
if (logLines.value.length > 8) logLines.value.shift()
const idx = bootMessages.indexOf(msg)
progress.value = Math.min(95, ((idx + 1) / bootMessages.length) * 100)
litBars.value = Math.round((progress.value / 100) * 48)
nextTick(() => { if (terminalRef.value) terminalRef.value.scrollTop = terminalRef.value.scrollHeight })
}, msg.delay))
}
// After the last message, start polling for real server readiness
// (visual sequence must complete before we transition)
logTimeouts.push(setTimeout(() => {
// In dev/mock mode the server may already be ready — check and complete
const finishBoot = () => {
stopPolling()
progress.value = 100
litBars.value = 48
setTimeout(() => { bootDone.value = true }, 1200)
}
// Check immediately
checkHealth().then(r => {
if (r) { finishBoot(); return }
// Not ready yet — poll until it is
pollInterval = setInterval(async () => {
if (await checkHealth()) finishBoot()
}, 2000)
})
}, lastMsgDelay + 1500))
// Reset mock boot timer on fresh page load
fetch('/rpc/v1', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'server.echo', params: { message: 'boot-reset' } }),
}).catch(() => {})
}
function stopPolling() {
if (iconInterval) { clearInterval(iconInterval); iconInterval = null }
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
for (const t of logTimeouts) clearTimeout(t)
logTimeouts = []
}
function initCanvas() {
const c = canvasRef.value
if (!c) return
c.width = window.innerWidth; c.height = window.innerHeight
const ctx = c.getContext('2d')
if (ctx) { initStars(c); drawStars(c, ctx) }
}
watch(() => props.visible, v => { if (v) { startPolling(); nextTick(initCanvas) } })
onMounted(() => { if (props.visible) { startPolling(); nextTick(initCanvas) } })
onBeforeUnmount(() => { stopPolling(); cancelAnimationFrame(animFrame) })
defineExpose({ startPolling })
</script>
<style scoped>
.boot-screen {
position: fixed; inset: 0; z-index: 9000;
display: flex; align-items: center; justify-content: center;
cursor: default; overflow: hidden;
}
.boot-screen:has(.boot-click-prompt) { cursor: pointer; }
.boot-stars { position: absolute; inset: 0; width: 100%; height: 100%; }
/* Two-column layout */
.boot-layout {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3rem;
max-width: 900px; width: 90%; padding: 0 1rem;
transition: justify-content 0.8s ease;
}
.boot-layout-centered { justify-content: center; }
/* Left column: terminal */
.boot-left {
flex: 1; min-width: 0; max-width: 400px;
}
.boot-terminal {
max-height: 200px; overflow: hidden;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px; line-height: 1.8;
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 100%);
}
.boot-log-line { white-space: nowrap; overflow: hidden; animation: log-in 0.3s ease both; }
.boot-log-line.info { color: rgba(255,255,255,0.35); }
.boot-log-line.success { color: #4ade80; }
.boot-log-line.ready { color: #fb923c; font-weight: 600; text-shadow: 0 0 10px rgba(251,146,60,0.5); }
.boot-log-ts { color: rgba(255,255,255,0.15); margin-right: 8px; font-weight: 500; }
.boot-log-line.success .boot-log-ts { color: rgba(74,222,128,0.4); }
.boot-log-line.ready .boot-log-ts { color: rgba(251,146,60,0.6); }
@keyframes log-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
.boot-cursor { color: rgba(251,146,60,0.7); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
@keyframes blink { 50% { opacity: 0; } }
.boot-progress-wrap { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.boot-arc { flex: 1; height: 12px; }
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.25); min-width: 28px; text-align: right; }
/* Right column: orb */
.boot-right {
flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;
}
.boot-orb {
position: relative; width: 220px; height: 220px;
transition: width 0.8s ease, height 0.8s ease;
}
@media (min-width: 640px) { .boot-orb { width: 280px; height: 280px; } }
@media (min-width: 768px) { .boot-orb { width: 320px; height: 320px; } }
.boot-orb-screensaver {
width: 280px; height: 280px;
}
@media (min-width: 640px) { .boot-orb-screensaver { width: 360px; height: 360px; } }
@media (min-width: 768px) { .boot-orb-screensaver { width: 400px; height: 400px; } }
/* Viz ring */
.boot-viz-ring { position: absolute; inset: 0; --vr: 100px; }
@media (min-width: 640px) { .boot-viz-ring { --vr: 130px; } }
@media (min-width: 768px) { .boot-viz-ring { --vr: 150px; } }
.boot-orb-screensaver .boot-viz-ring { --vr: 130px; }
@media (min-width: 640px) { .boot-orb-screensaver .boot-viz-ring { --vr: 170px; } }
@media (min-width: 768px) { .boot-orb-screensaver .boot-viz-ring { --vr: 190px; } }
.boot-viz-seg {
position: absolute; left: 50%; top: 50%;
width: 3px; height: 18px; margin-left: -1.5px; margin-top: -9px;
border-radius: 1.5px; transform-origin: center center;
transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr)));
background: rgba(255,255,255,0.05);
transition: background 0.4s ease, height 0.4s ease, box-shadow 0.4s ease;
}
.boot-seg-lit {
background: linear-gradient(to bottom, rgba(251,146,60,0.8), rgba(245,158,11,0.3));
box-shadow: 0 0 5px rgba(251,146,60,0.2);
height: 22px; margin-top: -11px;
}
/* When done, all segments pulse like screensaver */
.boot-orb-screensaver .boot-viz-seg {
background: linear-gradient(to bottom, rgba(255,255,255,0.4), rgba(255,255,255,0.1));
box-shadow: none; height: 24px; margin-top: -12px;
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--si) * 0.02s);
}
@keyframes seg-pulse {
0%,14.3%,28.6%,42.9%,57.1%,71.4%,92.9%,100% { opacity:0.3; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(0.4); }
7.1%,21.4%,35.7%,50%,64.3% { opacity:0.9; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1); }
78.6%,85.7% { opacity:1; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1.5); }
}
/* Center icon */
.boot-center-icon {
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); z-index: 10;
filter: drop-shadow(0 0 30px rgba(255,255,255,0.1));
}
.boot-icon-frame {
width: 140px; height: 140px;
display: flex; align-items: center; justify-content: center; overflow: hidden;
}
@media (min-width: 640px) { .boot-icon-frame { width: 180px; height: 180px; } }
@media (min-width: 768px) { .boot-icon-frame { width: 220px; height: 220px; } }
.boot-orb-screensaver .boot-icon-frame {
width: 192px; height: 192px;
}
@media (min-width: 640px) { .boot-orb-screensaver .boot-icon-frame { width: 256px; height: 256px; } }
@media (min-width: 768px) { .boot-orb-screensaver .boot-icon-frame { width: 320px; height: 320px; } }
.boot-icon-inner {
position: absolute; inset: 3px;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.85); border-radius: inherit;
}
.boot-pixel-wrap { width: 72px; height: 72px; }
@media (min-width: 640px) { .boot-pixel-wrap { width: 90px; height: 90px; } }
.boot-logo-inner-logo { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
:deep(.boot-svg) { width: 100%; height: 100%; image-rendering: pixelated; image-rendering: crisp-edges; }
/* Glitch */
.boot-glitch { animation: glitch 0.2s steps(3) both; }
@keyframes glitch {
0% { transform: translate(0); filter: none; }
25% { transform: translate(2px,-1px); filter: hue-rotate(90deg); }
50% { transform: translate(-2px,1px); filter: hue-rotate(-90deg) brightness(1.4); }
75% { transform: translate(1px,2px); filter: hue-rotate(45deg); }
100% { transform: translate(0); filter: none; }
}
/* Icon morph */
.icon-morph-enter-active { transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; }
.icon-morph-leave-active { transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease; }
.icon-morph-enter-from { opacity:0; transform: scale(0.5) rotate(-10deg); filter: blur(4px); }
.icon-morph-leave-to { opacity:0; transform: scale(1.4) rotate(10deg); filter: blur(4px); }
/* Click prompt */
.boot-click-prompt {
color: rgba(255,255,255,0.4); font-size: 0.8rem; font-weight: 500;
letter-spacing: 0.1em; text-transform: uppercase;
animation: prompt-breathe 3s ease-in-out infinite;
}
@keyframes prompt-breathe {
0%,100% { opacity: 0.3; } 50% { opacity: 0.7; }
}
.prompt-fade-enter-active { transition: opacity 1s ease 0.5s; }
.prompt-fade-enter-from { opacity: 0; }
/* Terminal fade out */
.terminal-fade-leave-active { transition: opacity 0.8s ease, transform 0.8s ease; }
.terminal-fade-leave-to { opacity: 0; transform: translateX(-30px); }
/* Boot screen fade out */
.boot-fade-leave-active { transition: opacity 1.2s ease; }
.boot-fade-leave-to { opacity: 0; }
/* Mobile: stack vertically */
@media (max-width: 767px) {
.boot-layout { flex-direction: column-reverse; gap: 2rem; }
.boot-left { max-width: 100%; }
.boot-orb { width: 200px; height: 200px; }
.boot-orb-screensaver { width: 260px; height: 260px; }
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<!-- Lightning -->
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" 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="mb-3">
<label class="text-white/60 text-sm block mb-1">Memo (optional)</label>
<input v-model="invoiceMemo" type="text" placeholder="Payment for..." 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 v-if="invoiceResult" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Invoice (share with sender):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
</div>
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<p class="text-white/50 text-xs mb-2">Your Bitcoin address:</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
</div>
</div>
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." 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"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-green-500/20 border-green-500/30 disabled:opacity-50">
{{ processing ? 'Processing...' : receiveMethod === 'onchain' ? 'Generate Address' : receiveMethod === 'lightning' ? 'Create Invoice' : 'Receive' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; received: [] }>()
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('lightning')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')
const invoiceResult = ref('')
const onchainAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const processing = ref(false)
const error = ref('')
function close() {
invoiceResult.value = ''
onchainAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
error.value = ''
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function receive() {
processing.value = true
error.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = 'Enter an amount'; return }
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.addinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
})
invoiceResult.value = res.payment_request
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
onchainAddress.value = res.address
} else {
if (!ecashToken.value.trim()) { error.value = 'Paste an ecash token'; return }
await rpcClient.call<{ amount_sats: number }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
})
ecashResult.value = 'Token received successfully!'
emit('received')
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed'
} finally {
processing.value = false
}
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="close" @keydown.escape="close">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true">
<h2 class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : m }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">Auto-selects method based on amount: ecash &lt; 1k sats, Lightning 1k500k, on-chain &gt; 500k</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" 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 v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? 'Lightning Invoice (BOLT11)' : 'Bitcoin Address' }}
</label>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-white/30"></textarea>
</div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="resultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Sent! TX: {{ resultTxid }}</p>
</div>
<div v-if="resultHash" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Paid! Hash: {{ resultHash }}</p>
</div>
<div v-if="error" class="mb-3 text-xs text-red-400">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
{{ processing ? 'Sending...' : 'Send' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
const amount = ref<number>(0)
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const ecashToken = ref('')
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
if (amt <= 0) return 'lightning'
if (amt < 1000) return 'ecash'
if (amt > 500000) return 'onchain'
return 'lightning'
})
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
ecashToken.value = ''
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function send() {
if (!amount.value || processing.value) return
processing.value = true
error.value = ''
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
const method = effectiveMethod.value
try {
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({
method: 'lnd.payinvoice',
params: { payment_request: dest.value.trim() },
})
resultHash.value = res.payment_hash
} else {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: dest.value.trim(), amount: amount.value },
})
resultTxid.value = res.txid
}
emit('sent')
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
processing.value = false
}
}
</script>

View File

@@ -327,10 +327,10 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function relayTransaction(txHex: string) {
async function relayTransaction(txHex: string, mode: 'archy' | 'broadcast' = 'archy') {
return rpcClient.call<{ request_id: number; queued: boolean; tx_hex_len: number }>({
method: 'mesh.relay-tx',
params: { tx_hex: txHex },
params: { tx_hex: txHex, relay_mode: mode },
})
}
@@ -341,6 +341,20 @@ export const useMeshStore = defineStore('mesh', () => {
})
}
async function relayStatus(requestId: number) {
return rpcClient.call<{
status: 'pending' | 'confirmed' | 'failed' | 'unknown'
request_id: number
txid?: string
error?: string
error_code?: string
completed_at?: string
}>({
method: 'mesh.relay-status',
params: { request_id: requestId },
})
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
@@ -377,5 +391,6 @@ export const useMeshStore = defineStore('mesh', () => {
fetchBlockHeaders,
relayTransaction,
relayLightning,
relayStatus,
}
})

View File

@@ -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]

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 = []
}
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 {