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:
457
neode-ui/src/components/BootScreen.vue
Normal file
457
neode-ui/src/components/BootScreen.vue
Normal 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>
|
||||
131
neode-ui/src/components/ReceiveBitcoinModal.vue
Normal file
131
neode-ui/src/components/ReceiveBitcoinModal.vue
Normal 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>
|
||||
138
neode-ui/src/components/SendBitcoinModal.vue
Normal file
138
neode-ui/src/components/SendBitcoinModal.vue
Normal 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 < 1k sats, Lightning 1k–500k, on-chain > 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>
|
||||
Reference in New Issue
Block a user