Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit dc80b552a1
1639 changed files with 352647 additions and 0 deletions
+622
View File
@@ -0,0 +1,622 @@
<template>
<div id="app">
<!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
<!-- Main App Content - only show after splash and routing is complete -->
<div v-if="!showSplash && !isReady" class="min-h-screen bg-black" />
<RouterView v-else-if="!showSplash && isReady" />
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
<SpotlightSearch />
<!-- CLI popup (F key) -->
<CLIPopup />
<!-- App launcher overlay (iframe popup) -->
<AppLauncherOverlay />
<!-- Global toast notifications -->
<ToastStack />
<!-- Screensaver -->
<Screensaver />
<!-- Help guide modal (from spotlight) -->
<HelpGuideModal
:show="spotlightStore.helpModal.show"
:title="spotlightStore.helpModal.title"
:content="spotlightStore.helpModal.content"
:related-path="spotlightStore.helpModal.relatedPath"
@close="spotlightStore.closeHelpModal()"
/>
<!-- PWA Update Prompt -->
<PWAUpdatePrompt />
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<ExternalExplorerModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
<!-- Toast notifications - top right, glass style, any page -->
<Teleport to="body">
<Transition name="toast">
<div
v-if="toastMessage.show"
@click="messageToast.dismissToastAndOpenMessages"
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">New message</p>
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
<p class="mt-1 text-xs text-orange-400">Click to view</p>
</div>
<button
@click.stop="messageToast.closeToast"
aria-label="Dismiss notification"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
>
<svg class="h-4 w-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>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import SpotlightSearch from './components/SpotlightSearch.vue'
import CLIPopup from './components/CLIPopup.vue'
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useMessageToast } from '@/composables/useMessageToast'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode'
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
import { shouldShowIntroSplash } from '@/utils/introSplash'
const router = useRouter()
const screensaverStore = useScreensaverStore()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const uiModeStore = useUIModeStore()
const messageToast = useMessageToast()
const toastMessage = messageToast.toastMessage
useControllerNav()
function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
// Very first kiosk boot: the launcher opens /kiosk before the route
// guard has had a chance to persist localStorage.kiosk.
|| window.location.pathname === '/kiosk'
// Global styling hook: kiosk chromium runs software-composited
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
// (3D bg layers, animated mix-blend overlays) fail and paint black.
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
if (isKiosk) {
// AIUI (same-origin iframe) themes from localStorage['aiui-theme'], and a
// stored value beats its prefers-color-scheme fallback. Kiosk profiles
// that ran before the launcher forced a dark color-scheme have "light"
// baked in — white panels on an otherwise dark TV UI — so pin it dark.
try { localStorage.setItem('aiui-theme', 'dark') } catch { /* storage full/denied */ }
}
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
} else {
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
}
}
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
messageToast.startPolling()
screensaverStore.resetInactivityTimer()
// Kiosk included: the backend's remote-input path is relay-only (it
// validates and broadcasts; it never runs xdotool), so the browser-side
// relay is the ONLY consumer of companion input. Skipping it on kiosk —
// an assumption from a long-gone system-level xdotool injector — left
// TVs deaf to the companion remote.
startRemoteRelay()
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' }
screensaverStore.clearInactivityTimer()
screensaverStore.deactivate()
stopRemoteRelay()
}
}, { immediate: true })
// Reset screensaver inactivity on user activity (when authenticated)
function onUserActivity() {
if (appStore.isAuthenticated && !screensaverStore.isActive) {
screensaverStore.resetInactivityTimer()
}
}
function onKeyDown(e: KeyboardEvent) {
const isMac = navigator.platform.toUpperCase().includes('MAC')
const mod = isMac ? e.metaKey : e.ctrlKey
// Cmd+K / Ctrl+K only (modifier required - avoids accidental trigger when typing)
const target = e.target as HTMLElement
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
if (mod && e.key === 'k') {
e.preventDefault()
spotlightStore.toggle()
return
}
// F key - CLI popup (skip when in input or modifier held)
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
e.preventDefault()
cliStore.toggle()
return
}
// Cmd+1/2/3 - switch UI mode (skip when in input)
if (mod && !isInput && appStore.isAuthenticated) {
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
}
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
e.preventDefault()
uiModeStore.cycleMode()
router.push('/dashboard')
return
}
// 's' key activates screensaver when authenticated (skip if typing in input)
if (e.key === 's' || e.key === 'S') {
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive && !screensaverStore.isSuppressed) {
e.preventDefault()
screensaverStore.activate()
}
}
// Keyboard typing sound - plays on any character typed in inputs (global)
if (isInput && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
playKeyboardTypingSound()
}
}
const route = useRoute()
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
const isReady = ref(false)
let modalOverlayObserver: MutationObserver | null = null
let lockedScrollY = 0
let previousBodyStyles: Partial<CSSStyleDeclaration> = {}
let bodyLockedForModal = false
let modalTouchY: number | null = null
function hasBlockingOverlay() {
if (typeof document === 'undefined') return false
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.some((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function visibleBlockingOverlays() {
if (typeof document === 'undefined') return []
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.filter((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function closestOverlay(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return null
return visibleBlockingOverlays().find((overlay) => overlay.contains(target)) || null
}
function canScrollInsideOverlay(target: EventTarget | null, overlay: HTMLElement, deltaY: number) {
if (!(target instanceof HTMLElement)) return false
let el: HTMLElement | null = target
while (el && overlay.contains(el)) {
const style = window.getComputedStyle(el)
const canScrollY = /(auto|scroll)/.test(style.overflowY)
&& el.scrollHeight > el.clientHeight
if (canScrollY) {
if (deltaY < 0 && el.scrollTop > 0) return true
if (deltaY > 0 && el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true
}
if (el === overlay) break
el = el.parentElement
}
return false
}
function containModalWheel(ev: WheelEvent) {
if (!bodyLockedForModal) return
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, ev.deltaY)) {
ev.preventDefault()
}
}
function containModalTouchStart(ev: TouchEvent) {
modalTouchY = ev.touches[0]?.clientY ?? null
}
function containModalTouchMove(ev: TouchEvent) {
if (!bodyLockedForModal) return
const currentY = ev.touches[0]?.clientY
if (currentY === undefined || modalTouchY === null) {
ev.preventDefault()
return
}
const deltaY = modalTouchY - currentY
modalTouchY = currentY
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, deltaY)) {
ev.preventDefault()
}
}
function lockBodyForModal() {
if (bodyLockedForModal || typeof document === 'undefined') return
lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0
previousBodyStyles = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
right: document.body.style.right,
width: document.body.style.width,
overflow: document.body.style.overflow,
}
document.body.style.position = 'fixed'
document.body.style.top = `-${lockedScrollY}px`
document.body.style.left = '0'
document.body.style.right = '0'
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
document.documentElement.classList.add('modal-scroll-locked')
bodyLockedForModal = true
}
function unlockBodyForModal() {
if (!bodyLockedForModal || typeof document === 'undefined') return
document.body.style.position = previousBodyStyles.position || ''
document.body.style.top = previousBodyStyles.top || ''
document.body.style.left = previousBodyStyles.left || ''
document.body.style.right = previousBodyStyles.right || ''
document.body.style.width = previousBodyStyles.width || ''
document.body.style.overflow = previousBodyStyles.overflow || ''
document.documentElement.classList.remove('modal-scroll-locked')
window.scrollTo(0, lockedScrollY)
previousBodyStyles = {}
bodyLockedForModal = false
modalTouchY = null
}
function syncModalBodyLock() {
if (hasBlockingOverlay()) lockBodyForModal()
else unlockBodyForModal()
}
/**
* Determine if splash screen should be shown
* Splash is skipped if:
* - User has already seen the intro
* - User is on a direct route (refresh/bookmark)
*/
// Fix Chromium backdrop-filter rendering bug: when tab loses/regains focus,
// the compositor fails to repaint backdrop-filter layers over animated
// fixed-position overlays (body::before/after with mix-blend-mode).
// On return: strip backdrop-filter via class, wait a frame, then restore.
function onVisibilityChange() {
if (document.hidden) {
document.documentElement.classList.add('tab-hidden')
} else {
// Step 1: strip backdrop-filter while animations stay paused (tab-hidden)
document.documentElement.classList.add('no-backdrop')
// Step 2: restore backdrop-filter over static content (clean compositor rebuild)
// Use setTimeout — Chromium batches rAFs on tab return
setTimeout(() => {
document.documentElement.classList.remove('no-backdrop')
// Step 3: resume animations after backdrop-filter layers are established
requestAnimationFrame(() => {
document.documentElement.classList.remove('tab-hidden')
})
}, 50)
}
}
onMounted(async () => {
syncKioskSafeArea()
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
// setup modal on any page (the Mesh view's own poll takes over there).
useMeshStore().startGlobalDetection()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
window.addEventListener('mousedown', onUserActivity)
window.addEventListener('keydown', onUserActivity)
window.addEventListener('touchstart', onUserActivity)
window.addEventListener('message', onShareToMeshMessage)
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
document.addEventListener('touchstart', containModalTouchStart, { capture: true, passive: true })
document.addEventListener('touchmove', containModalTouchMove, { capture: true, passive: false })
modalOverlayObserver = new MutationObserver(() => {
requestAnimationFrame(syncModalBodyLock)
})
modalOverlayObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style'],
})
syncModalBodyLock()
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
// One-shot "Replay intro" request from the login screen — must survive the
// auto-re-mark below (which otherwise instantly suppresses the replay on any
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// The route object still holds the router's START_LOCATION ('/') here —
// the initial navigation resolves asynchronously — so a deep-route load
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
// bar is the only truthful source this early.
//
// The kiosk chromium opens /kiosk — a marker route whose beforeEnter
// stamps localStorage.kiosk and immediately redirects to '/'. That is a
// root boot, not a deep route: without normalizing it, the fresh-install
// kiosk never plays the typing intro (deep-route suppression regression).
const rawBootPath = window.location.pathname
const bootPath = rawBootPath === '/kiosk' ? '/' : rawBootPath
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && bootPath === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
// Root boots always ask the backend — even when this browser thinks it has
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
// are per-origin browser state: after a reinstall (or another node coming
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
// a fresh install's intro / misroute it to login.
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
if (splashCandidate) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
// Bound the pre-splash status check: its retry ladder can spend ~30s
// against a still-booting backend, and this await holds the black
// "!isReady" screen the whole time — exactly where the typing intro
// should be playing on a fresh kiosk boot. Unknown within 2.5s → let
// the splash play (a fresh install IS the slow-backend case; onboarded
// nodes answer in milliseconds, so their suppression path is intact).
// handleSplashComplete re-checks with full retries after the intro.
const live = await Promise.race([
checkOnboardingStatus(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
])
if (live !== null) onboardingComplete = live
if (live === false && seenIntro) {
// Backend-confirmed fresh node behind a browser with a stale flag —
// drop it so this boot (and every later one) plays the intro.
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
seenIntro = false
}
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
}
if (!replayRequested && !seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true
}
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot, 'onboardingComplete:', onboardingComplete)
if (shouldShowIntroSplash({
seenIntro,
routePath: bootPath,
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
replayRequested,
})) {
// The intro is definitely playing — unmute the whole cinematic (speech,
// synthwave, pops) even on browsers whose localStorage says onboarding is
// complete; the sound gate otherwise silences replays.
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
enableCinematicSounds()
// Kick off the intro video download NOW: the splash's <video> element only
// mounts ~20s in (after the typing sequence), and on a cold cache the file
// would otherwise start fetching mid-sequence and stutter. A detached
// <video preload=auto> is used because Chromium does not support
// <link rel=preload as=video> — the media cache then serves the splash's
// element, which uses the identical URL.
try {
const warm = document.createElement('video')
warm.muted = true
warm.preload = 'auto'
warm.src = '/assets/video/video-intro.mp4?v=8'
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
} catch { /* ignore */ }
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
showSplash.value = true
} else {
// Already seen intro, direct route, or boot mode (boot screen handles intro)
// Set isReady BEFORE hiding splash to prevent flash of partial content
await router.isReady()
isReady.value = true
showSplash.value = false
document.body.classList.add('splash-complete')
}
})
onBeforeUnmount(() => {
document.documentElement.classList.remove('kiosk-safe-area')
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)
window.removeEventListener('mousedown', onUserActivity)
window.removeEventListener('keydown', onUserActivity)
window.removeEventListener('touchstart', onUserActivity)
window.removeEventListener('message', onShareToMeshMessage)
document.removeEventListener('wheel', containModalWheel, { capture: true })
document.removeEventListener('touchstart', containModalTouchStart, { capture: true })
document.removeEventListener('touchmove', containModalTouchMove, { capture: true })
modalOverlayObserver?.disconnect()
modalOverlayObserver = null
unlockBodyForModal()
})
/**
* Phase 3c: marketplace app iframes share files into mesh chats by POSTing
* to /api/share-to-mesh then postMessaging the CID back to this parent
* window. We stash it in sessionStorage + route to /mesh; Mesh.vue reads the
* stash on mount and stages it as a pending attachment.
*/
function onShareToMeshMessage(ev: MessageEvent) {
// Same-origin senders only (matches Chat.vue's handler) — otherwise any
// embedded frame can force-navigate the UI to /mesh with a staged CID.
if (ev.origin !== window.location.origin) return
const data = ev.data as { type?: string; cid?: string } | null
if (!data || data.type !== 'share-to-mesh' || !data.cid) return
try {
sessionStorage.setItem('archipelago_share_to_mesh', JSON.stringify(data))
} catch {
/* quota — fall through */
}
if (route.path !== '/mesh') {
router.push('/mesh')
} else {
// Already on /mesh — dispatch a synthetic event so the view picks it up.
window.dispatchEvent(new CustomEvent('archipelago:share-to-mesh'))
}
}
/**
* Handle splash screen completion
* Routes user directly to appropriate screen based on onboarding status (from backend)
*/
async function handleSplashComplete() {
sessionStorage.setItem('archipelago_from_splash', '1')
// Commit the destination route BEFORE revealing RouterView. Revealing
// first rendered whatever route was current — '/' → RootRedirect's
// spinner — for a beat between the intro's final frame and the
// onboarding/login screen actually mounting: a visible flash on every
// fresh install. Await the push so the first RouterView render is
// already the destination.
const reveal = () => {
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
}
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
await router.push('/login').catch(() => {})
reveal()
return
}
// Demo: the cinematic always continues into the onboarding intro page —
// the mock backend reports "onboarded", which would otherwise route
// straight to /login and cut the sequence short.
{
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO) {
await router.push('/onboarding/intro').catch(() => {})
reveal()
return
}
}
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
if (seenOnboarding === true) {
await router.push('/login').catch(() => {})
} else if (seenOnboarding === false) {
await router.push('/onboarding/intro').catch(() => {})
} else if (localStorage.getItem('neode_onboarding_complete') === '1') {
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it) —
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
await router.push('/login').catch(() => {})
} else {
await router.push('/').catch(() => {})
}
} catch {
// Do NOT default to /onboarding/intro here. RootRedirect has retry
// + polling + boot-screen handling; let it decide.
await router.push('/').catch(() => {})
}
reveal()
}
</script>
<style>
/* Global styles are in style.css */
</style>
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the rpc-client module
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
import { containerClient } from '../container-client'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('containerClient', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('installApp calls container-install with manifest path', async () => {
mockedRpc.call.mockResolvedValue('container-abc123')
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-install',
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
})
expect(result).toBe('container-abc123')
})
it('startContainer calls container-start with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.startContainer('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-start',
params: { app_id: 'bitcoin-knots' },
})
})
it('stopContainer calls container-stop with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopContainer('lnd')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-stop',
params: { app_id: 'lnd' },
})
})
it('removeContainer calls container-remove with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.removeContainer('mempool')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-remove',
params: { app_id: 'mempool' },
})
})
it('getContainerStatus returns status for a container', async () => {
const mockStatus = {
id: '1',
name: 'bitcoin-knots',
state: 'running' as const,
image: 'bitcoinknots:29',
created: '2026-01-01',
ports: ['8332'],
lan_address: 'http://localhost:8332',
}
mockedRpc.call.mockResolvedValue(mockStatus)
const result = await containerClient.getContainerStatus('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-status',
params: { app_id: 'bitcoin-knots' },
})
expect(result).toEqual(mockStatus)
})
it('getContainerLogs returns log lines with default line count', async () => {
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
mockedRpc.call.mockResolvedValue(mockLogs)
const result = await containerClient.getContainerLogs('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'bitcoin-knots', lines: 100 },
})
expect(result).toEqual(mockLogs)
})
it('getContainerLogs respects custom line count', async () => {
mockedRpc.call.mockResolvedValue([])
await containerClient.getContainerLogs('lnd', 50)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'lnd', lines: 50 },
})
})
it('listContainers returns all containers', async () => {
const mockContainers = [
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
]
mockedRpc.call.mockResolvedValue(mockContainers)
const result = await containerClient.listContainers()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-list',
params: {},
})
expect(result).toHaveLength(2)
})
it('getHealthStatus returns health map', async () => {
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
mockedRpc.call.mockResolvedValue(mockHealth)
const result = await containerClient.getHealthStatus()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-health',
params: {},
})
expect(result).toEqual(mockHealth)
})
it('startBundledApp sends full app config', async () => {
mockedRpc.call.mockResolvedValue(undefined)
const app = {
id: 'filebrowser',
name: 'FileBrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
}
await containerClient.startBundledApp(app)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-start',
params: {
app_id: 'filebrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
},
})
})
it('stopBundledApp calls bundled-app-stop', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopBundledApp('filebrowser')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-stop',
params: { app_id: 'filebrowser' },
})
})
it('propagates RPC errors from the client', async () => {
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
})
})
@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sanitizePath } from '../filebrowser-client'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in constructor, so stub it
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
// Import after stubs
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
// A real File Browser JSON response carries this; listDirectory now guards
// on it (B4) to detect the SPA-fallback HTML / 502 cases.
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
/** Set up authenticated state — bypasses jsdom cookie path restrictions */
function setAuthenticated() {
;(fileBrowserClient as any)._authenticated = true
document.cookie = 'auth=test-token'
}
describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as any)._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
describe('login', () => {
it('authenticates via backend RPC and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
const result = await fileBrowserClient.login()
expect(result).toBe(true)
expect(fileBrowserClient.isAuthenticated).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
'/rpc/v1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ method: 'app.filebrowser-token' }),
}),
)
})
it('returns false on failed login', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
it('returns false on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
})
describe('listDirectory', () => {
it('lists items in a directory', async () => {
setAuthenticated()
const mockItems = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
],
numDirs: 1,
numFiles: 1,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
const items = await fileBrowserClient.listDirectory('/')
expect(items).toHaveLength(2)
expect(items[0]!.name).toBe('photos')
expect(items[1]!.extension).toBe('txt')
})
it('adds leading slash if missing', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
await fileBrowserClient.listDirectory('photos')
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos')
})
it('throws on non-OK response', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('File Browser is not available (HTTP 404)')
})
it('throws a friendly error when File Browser is absent and nginx serves the SPA (B4)', async () => {
setAuthenticated()
// 200 but text/html (SPA index.html fallback) — res.json() would throw the
// opaque "Unexpected token '<'"; the guard must surface a friendly message.
const htmlResponse = {
...jsonResponse('<!doctype html><html></html>'),
headers: new Headers({ 'content-type': 'text/html' }),
} as Response
mockFetch.mockResolvedValueOnce(htmlResponse)
await expect(fileBrowserClient.listDirectory('/')).rejects.toThrow('File Browser is not available')
})
})
describe('downloadUrl', () => {
it('constructs download URL for file path', async () => {
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
expect(url).toContain('/api/raw/photos/sunset.jpg')
})
it('adds leading slash if missing', async () => {
const url = fileBrowserClient.downloadUrl('file.txt')
expect(url).toContain('/api/raw/file.txt')
})
})
describe('upload', () => {
it('uploads a file to the correct path', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
await fileBrowserClient.upload('/documents', file)
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/test.txt')
expect(url).toContain('override=true')
expect(init.method).toBe('POST')
expect(init.body).toBe(file)
})
it('throws on upload failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
const file = new File(['data'], 'big.bin')
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
})
})
describe('createFolder', () => {
it('creates a folder at the correct path', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.createFolder('/documents', 'photos')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/photos/')
expect(init.method).toBe('POST')
})
it('throws on failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
})
})
describe('deleteItem', () => {
it('sends DELETE request for the item', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.deleteItem('/photos/old.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('DELETE')
})
it('throws on failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
})
})
describe('getUsage', () => {
it('returns usage summary for root directory', async () => {
setAuthenticated()
const mockData = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
],
numDirs: 1,
numFiles: 2,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
const usage = await fileBrowserClient.getUsage()
expect(usage.totalSize).toBe(2000)
expect(usage.folderCount).toBe(1)
expect(usage.fileCount).toBe(2)
})
it('returns zeros on failed request', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
const usage = await fileBrowserClient.getUsage()
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
})
})
describe('isTextFile', () => {
it('identifies text file extensions', () => {
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
})
it('returns false for binary files', () => {
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
})
})
describe('rename', () => {
it('sends PATCH request with new destination', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('PATCH')
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
})
it('throws on rename failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
})
})
})
describe('sanitizePath', () => {
it('returns / for empty path', () => {
expect(sanitizePath('')).toBe('/')
})
it('preserves simple paths', () => {
expect(sanitizePath('/photos')).toBe('/photos')
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
})
it('adds leading slash', () => {
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
})
it('resolves . segments', () => {
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
})
it('resolves .. segments', () => {
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
})
it('prevents traversal past root', () => {
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
expect(sanitizePath('/../../..')).toBe('/')
})
it('handles multiple consecutive .. at root', () => {
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
})
it('handles mixed . and .. segments', () => {
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
})
it('removes trailing slashes in segments', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
})
})
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { isTextField, typeKeyIntoField } from '../remote-relay'
/**
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
* values in the browser, so the relay edits `.value` at the caret directly and
* fires an `input` event. These tests lock in that behaviour so a regression
* (like the old "type goes to document, nothing happens" bug) is caught before
* release rather than by a user with a companion controller.
*/
describe('isTextField', () => {
it('accepts text-like inputs and textareas', () => {
const text = document.createElement('input')
text.type = 'text'
const search = document.createElement('input')
search.type = 'search'
const area = document.createElement('textarea')
expect(isTextField(text)).toBe(true)
expect(isTextField(search)).toBe(true)
expect(isTextField(area)).toBe(true)
})
it('rejects non-text controls and null', () => {
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
expect(isTextField(checkbox)).toBe(false)
expect(isTextField(document.createElement('button'))).toBe(false)
expect(isTextField(document.createElement('div'))).toBe(false)
expect(isTextField(null)).toBe(false)
})
})
describe('typeKeyIntoField', () => {
let input: HTMLInputElement
let inputEvents: number
beforeEach(() => {
input = document.createElement('input')
input.type = 'search'
document.body.appendChild(input)
inputEvents = 0
input.addEventListener('input', () => { inputEvents++ })
})
it('inserts printable characters at the caret and fires input', () => {
typeKeyIntoField(input, 'b')
typeKeyIntoField(input, 't')
typeKeyIntoField(input, 'c')
expect(input.value).toBe('btc')
expect(input.selectionStart).toBe(3)
expect(inputEvents).toBe(3)
})
it('inserts a character in the middle of existing text', () => {
input.value = 'bc'
input.selectionStart = input.selectionEnd = 1
typeKeyIntoField(input, 't')
expect(input.value).toBe('btc')
expect(input.selectionStart).toBe(2)
})
it('backspace deletes the char before the caret', () => {
input.value = 'btc'
input.selectionStart = input.selectionEnd = 3
typeKeyIntoField(input, 'Backspace')
expect(input.value).toBe('bt')
expect(input.selectionStart).toBe(2)
})
it('backspace removes the active selection', () => {
input.value = 'bitcoin'
input.selectionStart = 0
input.selectionEnd = 3
typeKeyIntoField(input, 'Backspace')
expect(input.value).toBe('coin')
expect(input.selectionStart).toBe(0)
})
it('arrow keys move the caret without changing the value', () => {
input.value = 'abc'
input.selectionStart = input.selectionEnd = 3
typeKeyIntoField(input, 'ArrowLeft')
expect(input.selectionStart).toBe(2)
expect(input.value).toBe('abc')
})
it('Enter on a single-line input is left for the app to handle', () => {
input.value = 'query'
input.selectionStart = input.selectionEnd = 5
const consumed = typeKeyIntoField(input, 'Enter')
expect(consumed).toBe(false)
expect(input.value).toBe('query')
})
it('Enter inserts a newline in a textarea', () => {
const area = document.createElement('textarea')
area.value = 'a'
area.selectionStart = area.selectionEnd = 1
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
expect(area.value).toBe('a\n')
})
it('non-text keys are not consumed as editing', () => {
input.value = 'x'
input.selectionStart = input.selectionEnd = 1
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
expect(input.value).toBe('x')
})
})
@@ -0,0 +1,573 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// We need to test the RPCClient class, so import it by re-creating the module
// Import the actual class and instance
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
const { rpcClient } = await import('../rpc-client')
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status, statusText),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(body)),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('RPCClient', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('makes a successful RPC call and returns the result', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
const result = await rpcClient.call<{ did: string }>({
method: 'node.did',
params: {},
})
expect(result).toEqual({ did: 'did:key:z123' })
expect(mockFetch).toHaveBeenCalledOnce()
const [url, init] = mockFetch.mock.calls[0]!
expect(url).toBe('/rpc/v1')
expect(init.method).toBe('POST')
expect(init.credentials).toBe('include')
expect(JSON.parse(init.body)).toEqual({ method: 'node.did', params: {} })
})
it('includes credentials for session cookies', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
await rpcClient.call({ method: 'test', params: {} })
const [, init] = mockFetch.mock.calls[0]!
expect(init.credentials).toBe('include')
})
it('retries on 502 Bad Gateway and eventually succeeds', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('ok')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('retries on 503 Service Unavailable and eventually succeeds', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(null, 503, 'Service Unavailable'))
.mockResolvedValueOnce(jsonResponse({ result: 'recovered' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('recovered')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('throws after max retries on persistent 502', async () => {
mockFetch
.mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway')
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('throws immediately on non-retryable HTTP errors (e.g. 401)', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Session expired')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('throws on RPC-level error in response body', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: { code: -32600, message: 'Invalid method' } }),
)
await expect(rpcClient.call({ method: 'bad.method' })).rejects.toThrow('Invalid method')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('throws timeout error when request times out', async () => {
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockFetch.mockRejectedValue(abortError)
await expect(
rpcClient.call({ method: 'slow', timeout: 100 }),
).rejects.toThrow('Request timeout')
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('retries on network/fetch errors and eventually succeeds', async () => {
mockFetch
.mockRejectedValueOnce(new Error('fetch failed'))
.mockResolvedValueOnce(jsonResponse({ result: 'back online' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('back online')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('throws on non-retryable errors immediately', async () => {
mockFetch.mockRejectedValueOnce(new Error('some random error'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('some random error')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('handles unknown (non-Error) thrown values', async () => {
mockFetch.mockRejectedValueOnce('string error')
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Unknown error occurred')
})
it('uses default params when none provided', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
await rpcClient.call({ method: 'test' })
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.params).toEqual({})
})
it('sends an abort signal for timeout', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
await rpcClient.call({ method: 'test', timeout: 5000 })
const [, init] = mockFetch.mock.calls[0]!
expect(init.signal).toBeInstanceOf(AbortSignal)
})
})
describe('RPCClient convenience methods', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
function mockSuccess(result: unknown) {
mockFetch.mockResolvedValueOnce(jsonResponse({ result }))
}
function getLastMethod(): string {
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
return body.method
}
function getLastParams(): Record<string, unknown> {
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
return body.params
}
it('login calls auth.login with password', async () => {
mockSuccess(null)
await rpcClient.login('test123')
expect(getLastMethod()).toBe('auth.login')
expect(getLastParams().password).toBe('test123')
})
it('loginTotp calls auth.login.totp', async () => {
mockSuccess({ success: true })
await rpcClient.loginTotp('123456')
expect(getLastMethod()).toBe('auth.login.totp')
expect(getLastParams().code).toBe('123456')
})
it('loginBackup calls auth.login.backup', async () => {
mockSuccess({ success: true })
await rpcClient.loginBackup('ABCD-1234')
expect(getLastMethod()).toBe('auth.login.backup')
expect(getLastParams().code).toBe('ABCD-1234')
})
it('totpSetupBegin calls auth.totp.setup.begin', async () => {
mockSuccess({ qr_svg: '<svg/>', secret_base32: 'ABC', pending_token: 'tok' })
await rpcClient.totpSetupBegin('password')
expect(getLastMethod()).toBe('auth.totp.setup.begin')
})
it('totpSetupConfirm calls auth.totp.setup.confirm', async () => {
mockSuccess({ enabled: true, backup_codes: ['A', 'B'] })
await rpcClient.totpSetupConfirm({ code: '123456', password: 'pw', pendingToken: 'tok' })
expect(getLastMethod()).toBe('auth.totp.setup.confirm')
})
it('totpDisable calls auth.totp.disable', async () => {
mockSuccess({ disabled: true })
await rpcClient.totpDisable('pw', '123456')
expect(getLastMethod()).toBe('auth.totp.disable')
})
it('totpStatus calls auth.totp.status', async () => {
mockSuccess({ enabled: false })
await rpcClient.totpStatus()
expect(getLastMethod()).toBe('auth.totp.status')
})
it('changePassword calls auth.changePassword', async () => {
mockSuccess({ success: true })
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new' })
expect(getLastMethod()).toBe('auth.changePassword')
expect(getLastParams().alsoChangeSsh).toBe(true)
})
it('changePassword respects alsoChangeSsh option', async () => {
mockSuccess({ success: true })
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new', alsoChangeSsh: false })
expect(getLastParams().alsoChangeSsh).toBe(false)
})
it('logout calls auth.logout', async () => {
mockSuccess(undefined)
await rpcClient.logout()
expect(getLastMethod()).toBe('auth.logout')
})
it('completeOnboarding calls auth.onboardingComplete', async () => {
mockSuccess(true)
await rpcClient.completeOnboarding()
expect(getLastMethod()).toBe('auth.onboardingComplete')
})
it('isOnboardingComplete calls auth.isOnboardingComplete', async () => {
mockSuccess(true)
const result = await rpcClient.isOnboardingComplete()
expect(result).toBe(true)
expect(getLastMethod()).toBe('auth.isOnboardingComplete')
})
it('resetOnboarding calls auth.resetOnboarding', async () => {
mockSuccess(true)
await rpcClient.resetOnboarding()
expect(getLastMethod()).toBe('auth.resetOnboarding')
})
it('getNodeDid calls node.did', async () => {
mockSuccess({ did: 'did:key:z123', pubkey: 'abc' })
const result = await rpcClient.getNodeDid()
expect(result.did).toBe('did:key:z123')
expect(getLastMethod()).toBe('node.did')
})
it('signChallenge calls node.signChallenge', async () => {
mockSuccess({ signature: 'sig123' })
await rpcClient.signChallenge('test-challenge')
expect(getLastMethod()).toBe('node.signChallenge')
expect(getLastParams().challenge).toBe('test-challenge')
})
it('createBackup calls node.createBackup', async () => {
mockSuccess({ version: 1, did: 'did:key:z', pubkey: 'pk', kid: 'k1', encrypted: true, blob: 'data', timestamp: '2026-01-01' })
await rpcClient.createBackup('passphrase')
expect(getLastMethod()).toBe('node.createBackup')
})
it('resolveDid calls identity.resolve-did', async () => {
mockSuccess({})
await rpcClient.resolveDid('did:key:z123')
expect(getLastMethod()).toBe('identity.resolve-did')
expect(getLastParams().did).toBe('did:key:z123')
})
it('resolveDid without did sends empty params', async () => {
mockSuccess({})
await rpcClient.resolveDid()
expect(getLastParams()).toEqual({})
})
it('createPresentation calls identity.create-presentation', async () => {
mockSuccess({})
await rpcClient.createPresentation({ holderId: 'h1', credentialIds: ['c1'] })
expect(getLastMethod()).toBe('identity.create-presentation')
})
it('verifyPresentation calls identity.verify-presentation', async () => {
mockSuccess({ valid: true, holder_valid: true, credentials: [] })
await rpcClient.verifyPresentation({ type: 'test' })
expect(getLastMethod()).toBe('identity.verify-presentation')
})
it('createPsbt calls lnd.create-psbt', async () => {
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
expect(getLastMethod()).toBe('lnd.create-psbt')
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10)
})
it('finalizePsbt calls lnd.finalize-psbt', async () => {
mockSuccess({ raw_final_tx: 'rawtx', broadcast: true })
await rpcClient.finalizePsbt('signed-psbt')
expect(getLastMethod()).toBe('lnd.finalize-psbt')
})
it('publishNostrIdentity calls node.nostr-publish', async () => {
mockSuccess({ event_id: 'evt', success: 1, failed: 0 })
await rpcClient.publishNostrIdentity()
expect(getLastMethod()).toBe('node.nostr-publish')
})
it('getNostrPubkey calls node.nostr-pubkey', async () => {
mockSuccess({ nostr_pubkey: 'npub1...' })
await rpcClient.getNostrPubkey()
expect(getLastMethod()).toBe('node.nostr-pubkey')
})
it('listPeers calls node-list-peers', async () => {
mockSuccess({ peers: [] })
await rpcClient.listPeers()
expect(getLastMethod()).toBe('node-list-peers')
})
it('addPeer calls node-add-peer', async () => {
mockSuccess({ peers: [] })
await rpcClient.addPeer({ onion: 'abc.onion', pubkey: 'pk' })
expect(getLastMethod()).toBe('node-add-peer')
})
it('removePeer calls node-remove-peer', async () => {
mockSuccess({ peers: [] })
await rpcClient.removePeer('pk123')
expect(getLastMethod()).toBe('node-remove-peer')
})
it('sendMessageToPeer calls node-send-message', async () => {
mockSuccess({ ok: true, sent_to: 'abc.onion' })
await rpcClient.sendMessageToPeer('abc.onion', 'hello')
expect(getLastMethod()).toBe('node-send-message')
})
it('checkPeerReachable calls node-check-peer', async () => {
mockSuccess({ onion: 'abc.onion', reachable: true })
await rpcClient.checkPeerReachable('abc.onion')
expect(getLastMethod()).toBe('node-check-peer')
})
it('getReceivedMessages calls node-messages-received', async () => {
mockSuccess({ messages: [] })
await rpcClient.getReceivedMessages()
expect(getLastMethod()).toBe('node-messages-received')
})
it('discoverNodes calls node-nostr-discover', async () => {
mockSuccess({ nodes: [] })
await rpcClient.discoverNodes()
expect(getLastMethod()).toBe('node-nostr-discover')
})
it('getTorAddress calls node.tor-address', async () => {
mockSuccess({ tor_address: 'abc123.onion' })
await rpcClient.getTorAddress()
expect(getLastMethod()).toBe('node.tor-address')
})
it('verifyNostrRevoked calls node-nostr-verify-revoked', async () => {
mockSuccess({ revoked: false, nostr_pubkey: 'npub' })
await rpcClient.verifyNostrRevoked()
expect(getLastMethod()).toBe('node-nostr-verify-revoked')
})
it('echo calls server.echo', async () => {
mockSuccess('hello')
const result = await rpcClient.echo('hello')
expect(result).toBe('hello')
expect(getLastMethod()).toBe('server.echo')
})
it('getSystemTime calls server.time', async () => {
mockSuccess({ now: '2026-03-11', uptime: 3600 })
await rpcClient.getSystemTime()
expect(getLastMethod()).toBe('server.time')
})
it('getMetrics calls server.metrics', async () => {
mockSuccess({ cpu: 50 })
await rpcClient.getMetrics()
expect(getLastMethod()).toBe('server.metrics')
})
it('updateServer calls server.update', async () => {
mockSuccess('no-updates')
await rpcClient.updateServer('https://example.com')
expect(getLastMethod()).toBe('server.update')
})
it('detectUsbDevices calls system.detect-usb-devices', async () => {
mockSuccess({ devices: [] })
await rpcClient.detectUsbDevices()
expect(getLastMethod()).toBe('system.detect-usb-devices')
})
it('restartServer calls server.restart', async () => {
mockSuccess(undefined)
await rpcClient.restartServer()
expect(getLastMethod()).toBe('server.restart')
})
it('shutdownServer calls server.shutdown', async () => {
mockSuccess(undefined)
await rpcClient.shutdownServer()
expect(getLastMethod()).toBe('server.shutdown')
})
it('installPackage calls package.install', async () => {
mockSuccess('bitcoin-knots')
await rpcClient.installPackage('btc', 'https://mp.com', '1.0')
expect(getLastMethod()).toBe('package.install')
})
it('uninstallPackage calls package.uninstall', async () => {
mockSuccess(undefined)
await rpcClient.uninstallPackage('btc')
expect(getLastMethod()).toBe('package.uninstall')
})
it('uninstallPackage forwards preserve_data when requested', async () => {
mockSuccess(undefined)
await rpcClient.uninstallPackage('btc', { preserveData: true })
expect(getLastParams()).toEqual({ id: 'btc', preserve_data: true })
})
it('startPackage calls package.start', async () => {
mockSuccess(undefined)
await rpcClient.startPackage('btc')
expect(getLastMethod()).toBe('package.start')
})
it('stopPackage calls package.stop', async () => {
mockSuccess(undefined)
await rpcClient.stopPackage('btc')
expect(getLastMethod()).toBe('package.stop')
})
it('restartPackage calls package.restart', async () => {
mockSuccess(undefined)
await rpcClient.restartPackage('btc')
expect(getLastMethod()).toBe('package.restart')
})
it('getMarketplace calls marketplace.get', async () => {
mockSuccess({})
await rpcClient.getMarketplace('https://mp.com')
expect(getLastMethod()).toBe('marketplace.get')
})
it('federationInvite calls federation.invite', async () => {
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
await rpcClient.federationInvite()
expect(getLastMethod()).toBe('federation.invite')
})
it('federationJoin calls federation.join', async () => {
mockSuccess({ joined: true, node: {} })
await rpcClient.federationJoin('invite-code')
expect(getLastMethod()).toBe('federation.join')
})
it('federationListNodes calls federation.list-nodes', async () => {
mockSuccess({ nodes: [] })
await rpcClient.federationListNodes()
expect(getLastMethod()).toBe('federation.list-nodes')
})
it('federationRemoveNode calls federation.remove-node', async () => {
mockSuccess({ removed: true, nodes_remaining: 0 })
await rpcClient.federationRemoveNode('did:key:z')
expect(getLastMethod()).toBe('federation.remove-node')
})
it('federationSetTrust calls federation.set-trust', async () => {
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
await rpcClient.federationSetTrust('did:key:z', 'trusted')
expect(getLastMethod()).toBe('federation.set-trust')
})
it('federationSyncState calls federation.sync-state', async () => {
mockSuccess({ synced: 1, failed: 0, results: [] })
await rpcClient.federationSyncState()
expect(getLastMethod()).toBe('federation.sync-state')
})
it('federationDeployApp calls federation.deploy-app', async () => {
mockSuccess({ deployed: true, app_id: 'btc', peer_did: 'did', peer_onion: 'onion' })
await rpcClient.federationDeployApp({ did: 'did:key:z', appId: 'btc' })
expect(getLastMethod()).toBe('federation.deploy-app')
expect(getLastParams().version).toBe('latest')
})
it('vpnStatus calls vpn.status', async () => {
mockSuccess({ connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '' })
await rpcClient.vpnStatus()
expect(getLastMethod()).toBe('vpn.status')
})
it('vpnConfigure calls vpn.configure', async () => {
mockSuccess({ configured: true, provider: 'tailscale' })
await rpcClient.vpnConfigure({ provider: 'tailscale', auth_key: 'key' })
expect(getLastMethod()).toBe('vpn.configure')
})
it('vpnDisconnect calls vpn.disconnect', async () => {
mockSuccess({ disconnected: true })
await rpcClient.vpnDisconnect()
expect(getLastMethod()).toBe('vpn.disconnect')
})
it('marketplaceDiscover calls marketplace.discover', async () => {
mockSuccess({ apps: [], relay_count: 0 })
await rpcClient.marketplaceDiscover()
expect(getLastMethod()).toBe('marketplace.discover')
})
it('dnsStatus calls network.dns-status', async () => {
mockSuccess({ provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [] })
await rpcClient.dnsStatus()
expect(getLastMethod()).toBe('network.dns-status')
})
it('configureDns calls network.configure-dns', async () => {
mockSuccess({ ok: true, provider: 'cloudflare', servers: [], doh_enabled: true, doh_url: null })
await rpcClient.configureDns({ provider: 'cloudflare' })
expect(getLastMethod()).toBe('network.configure-dns')
})
it('diskStatus calls system.disk-status', async () => {
mockSuccess({ used_bytes: 100, total_bytes: 1000, free_bytes: 900, used_percent: 10, level: 'ok' })
await rpcClient.diskStatus()
expect(getLastMethod()).toBe('system.disk-status')
})
it('diskCleanup calls system.disk-cleanup', async () => {
mockSuccess({ freed_bytes: 500, freed_human: '500B', actions: [] })
await rpcClient.diskCleanup()
expect(getLastMethod()).toBe('system.disk-cleanup')
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
const { rpcClient } = await import('../rpc-client')
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status, statusText),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(body)),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('marketplaceDiscover', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns apps array and relay_count on success', async () => {
const payload = {
apps: [
{
manifest: {
app_id: 'bitcoin',
name: 'Bitcoin Core',
version: '27.0',
description: { short: 'Full node', long: 'Bitcoin Core full node' },
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
category: 'bitcoin',
icon_url: '/icons/bitcoin.png',
repo_url: 'https://github.com/bitcoin/bitcoin',
license: 'MIT',
},
trust_score: 95,
trust_tier: 'verified',
relay_count: 8,
first_seen: '2025-01-15T00:00:00Z',
nostr_pubkey: 'npub1abc',
},
],
relay_count: 12,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toHaveLength(1)
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
expect(result.apps[0]!.trust_score).toBe(95)
expect(result.relay_count).toBe(12)
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('marketplace.discover')
expect(body.params).toEqual({})
})
it('handles empty results', async () => {
const payload = {
apps: [],
relay_count: 0,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toEqual([])
expect(result.apps).toHaveLength(0)
expect(result.relay_count).toBe(0)
})
})
describe('diskStatus', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns expected fields', async () => {
const payload = {
used_bytes: 500_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 500_000_000_000,
used_percent: 50,
level: 'ok' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.used_bytes).toBe(500_000_000_000)
expect(result.total_bytes).toBe(1_000_000_000_000)
expect(result.free_bytes).toBe(500_000_000_000)
expect(result.used_percent).toBe(50)
expect(result.level).toBe('ok')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-status')
})
it('level is warning when percent >= 85', async () => {
const payload = {
used_bytes: 850_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 150_000_000_000,
used_percent: 85,
level: 'warning' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('warning')
expect(result.used_percent).toBe(85)
})
it('level is critical when percent >= 90', async () => {
const payload = {
used_bytes: 950_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 50_000_000_000,
used_percent: 95,
level: 'critical' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('critical')
expect(result.used_percent).toBe(95)
})
})
describe('diskCleanup', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns freed_bytes and actions array', async () => {
const payload = {
freed_bytes: 2_000_000_000,
freed_human: '2 GB',
actions: ['Removed 5 dangling images', 'Cleared build cache'],
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskCleanup()
expect(result.freed_bytes).toBe(2_000_000_000)
expect(result.freed_human).toBe('2 GB')
expect(result.actions).toHaveLength(2)
expect(result.actions[0]).toBe('Removed 5 dangling images')
expect(result.actions[1]).toBe('Cleared build cache')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-cleanup')
})
it('uses 60s timeout', async () => {
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockFetch.mockRejectedValue(abortError)
const promise = rpcClient.diskCleanup()
// The call should eventually reject with timeout after retries
await expect(promise).rejects.toThrow('Request timeout')
// Verify all 3 attempts used the signal (timeout is set via AbortController)
expect(mockFetch).toHaveBeenCalledTimes(3)
for (const call of mockFetch.mock.calls) {
expect(call[1].signal).toBeInstanceOf(AbortSignal)
}
})
})
@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock fast-json-patch
vi.mock('fast-json-patch', () => ({
applyPatch: vi.fn((doc: unknown, _ops: unknown[]) => ({
newDocument: { ...doc as Record<string, unknown>, patched: true },
})),
}))
// Mock WebSocket
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = MockWebSocket.CONNECTING
onopen: ((ev: Event) => void) | null = null
onclose: ((ev: CloseEvent) => void) | null = null
onerror: ((ev: Event) => void) | null = null
onmessage: ((ev: MessageEvent) => void) | null = null
url: string
constructor(url: string) {
this.url = url
// Auto-open in next tick
setTimeout(() => {
this.readyState = MockWebSocket.OPEN
this.onopen?.(new Event('open'))
}, 0)
}
send = vi.fn()
close = vi.fn().mockImplementation(function (this: MockWebSocket) {
this.readyState = MockWebSocket.CLOSED
this.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
})
}
vi.stubGlobal('WebSocket', MockWebSocket)
// Must import after mocks
const { WebSocketClient, applyDataPatch } = await import('../websocket')
describe('WebSocketClient', () => {
let client: InstanceType<typeof WebSocketClient>
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
client = new WebSocketClient('/ws/test')
})
afterEach(() => {
client.reset()
vi.useRealTimers()
})
it('initializes with disconnected state', () => {
expect(client.state).toBe('disconnected')
expect(client.isConnected()).toBe(false)
})
it('connects and transitions to connected state', async () => {
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
expect(client.state).toBe('connected')
expect(client.isConnected()).toBe(true)
expect(states).toContain('connecting')
expect(states).toContain('connected')
})
it('resolves immediately if already connected', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Second connect should resolve immediately
await client.connect()
expect(client.isConnected()).toBe(true)
})
it('subscribe returns unsubscribe function', async () => {
const callback = vi.fn()
const unsub = client.subscribe(callback)
expect(typeof unsub).toBe('function')
unsub()
// Should not throw
})
it('notifies subscribers on message', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Simulate receiving a message
const ws = (client as unknown as { ws: MockWebSocket }).ws
const update = { id: 1, type: 'state', data: { running: true } }
ws.onmessage?.(new MessageEvent('message', { data: JSON.stringify(update) }))
expect(callback).toHaveBeenCalledWith(update)
})
it('handles malformed JSON messages gracefully', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Should not throw
ws.onmessage?.(new MessageEvent('message', { data: 'not-json{' }))
expect(callback).not.toHaveBeenCalled()
})
it('onConnectionStateChange returns unsubscribe function', () => {
const callback = vi.fn()
const unsub = client.onConnectionStateChange(callback)
expect(typeof unsub).toBe('function')
unsub()
})
it('disconnect sets state to disconnecting then cleans up', async () => {
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
client.disconnect()
expect(states).toContain('disconnecting')
expect(client.isConnected()).toBe(false)
})
it('reset clears all callbacks and disconnects', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
client.reset()
expect(client.isConnected()).toBe(false)
})
it('sends ping messages via heartbeat', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Advance past ping interval (30s)
await vi.advanceTimersByTimeAsync(31000)
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
})
it('disconnect prevents reconnection after abnormal close', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Disconnect explicitly — should prevent future reconnections
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
client.disconnect()
expect(states).toContain('disconnecting')
})
it('handles close event with normal closure code', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Simulate normal close — should still try to reconnect (shouldReconnect is true)
ws.readyState = MockWebSocket.CLOSED
ws.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
// After close, state transitions to disconnected
// Then reconnection happens automatically (mock auto-opens)
await vi.advanceTimersByTimeAsync(200)
// Client should have attempted reconnect (state went through disconnected → connecting → connected)
expect(client.state).toBe('connected')
})
it('heartbeat detects stale connection after 5 minutes', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
const closeSpy = ws.close
// Advance 5+ minutes without any messages
await vi.advanceTimersByTimeAsync(310000)
// Heartbeat should have closed the stale connection
expect(closeSpy).toHaveBeenCalled()
})
it('state getter returns current connection state', () => {
expect(client.state).toBe('disconnected')
})
})
describe('applyDataPatch', () => {
it('returns original data for empty patch', () => {
const data = { a: 1, b: 2 }
const result = applyDataPatch(data, [])
expect(result).toBe(data)
})
it('returns original data for non-array patch', () => {
const data = { a: 1 }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = applyDataPatch(data, null as any)
expect(result).toBe(data)
})
it('applies valid patch operations', () => {
const data = { name: 'test', count: 0 }
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/count', value: 5 }]
const result = applyDataPatch(data, patch)
// The mock returns { ...data, patched: true }
expect(result).toHaveProperty('patched', true)
})
it('returns original data when patch application throws', async () => {
// Override mock to throw
const { applyPatch: mockApplyPatch } = await import('fast-json-patch')
vi.mocked(mockApplyPatch).mockImplementationOnce(() => {
throw new Error('Invalid patch')
})
const data = { value: 42 }
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/invalid', value: 0 }]
const result = applyDataPatch(data, patch)
expect(result).toBe(data)
})
})
+160
View File
@@ -0,0 +1,160 @@
// Container management API client
// Extends RPC client with container-specific methods
import { rpcClient } from './rpc-client'
export interface ContainerStatus {
id: string
name: string
state:
| 'created'
| 'running'
| 'stopped'
| 'exited'
| 'paused'
| 'unknown'
| 'stopping'
| 'starting'
| 'restarting'
| 'installing'
| 'updating'
| 'removing'
| 'installed'
image: string
created: string
ports: string[]
lan_address?: string // Launch URL for the app's UI
}
export interface ContainerAppInfo {
id: string
name: string
version: string
status: ContainerStatus
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
export interface BundledAppConfig {
id: string
name: string
image: string
ports: { host: number; container: number }[]
volumes: { host: string; container: string }[]
}
export const containerClient = {
/**
* Install a container app from a manifest file
*/
async installApp(manifestPath: string): Promise<string> {
return rpcClient.call<string>({
method: 'container-install',
params: { manifest_path: manifestPath },
})
},
/**
* Start a container
*/
async startContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-start',
params: { app_id: appId },
})
},
/**
* Stop a container
*/
async stopContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-stop',
params: { app_id: appId },
})
},
/**
* Restart a container (async; returns immediately with restarting state)
*/
async restartContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-restart',
params: { app_id: appId },
})
},
/**
* Remove a container
*/
async removeContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-remove',
params: { app_id: appId },
})
},
/**
* Get container status
*/
async getContainerStatus(appId: string): Promise<ContainerStatus> {
return rpcClient.call<ContainerStatus>({
method: 'container-status',
params: { app_id: appId },
})
},
/**
* Get container logs
*/
async getContainerLogs(appId: string, lines: number = 100): Promise<string[]> {
return rpcClient.call<string[]>({
method: 'container-logs',
params: { app_id: appId, lines },
})
},
/**
* List all containers
*/
async listContainers(): Promise<ContainerStatus[]> {
return rpcClient.call<ContainerStatus[]>({
method: 'container-list',
params: {},
})
},
/**
* Get health status for all containers
*/
async getHealthStatus(): Promise<Record<string, string>> {
return rpcClient.call<Record<string, string>>({
method: 'container-health',
params: {},
})
},
/**
* Start a bundled app (creates container if needed, then starts it)
*/
async startBundledApp(app: BundledAppConfig): Promise<void> {
return rpcClient.call<void>({
method: 'bundled-app-start',
params: {
app_id: app.id,
image: app.image,
ports: app.ports,
volumes: app.volumes,
},
})
},
/**
* Stop a bundled app
*/
async stopBundledApp(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'bundled-app-stop',
params: { app_id: appId },
})
},
}
+284
View File
@@ -0,0 +1,284 @@
export interface FileBrowserItem {
name: string
path: string
size: number
modified: string
isDir: boolean
type: string
extension: string
}
interface FileBrowserListResponse {
items: FileBrowserItem[]
numDirs: number
numFiles: number
sorting: { by: string; asc: boolean }
}
/**
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
* Always returns a path starting with `/` and never containing `..`.
*/
export function sanitizePath(path: string): string {
const segments = path.split('/').filter(Boolean)
const resolved: string[] = []
for (const seg of segments) {
if (seg === '.') continue
if (seg === '..') {
resolved.pop() // go up one level, but never past root
} else {
resolved.push(seg)
}
}
return '/' + resolved.join('/')
}
class FileBrowserClient {
private _authenticated = false
private baseUrl: string
constructor() {
this.baseUrl = `${window.location.origin}/app/filebrowser`
}
get isAuthenticated(): boolean {
return this._authenticated
}
private getAuthCookie(): string | null {
const match = document.cookie.match(/(?:^|;\s*)auth=([^;]+)/)
return match ? match[1]! : null
}
async login(): Promise<boolean> {
try {
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
// Use credentials: 'include' and CSRF token for proper auth
const csrfMatch = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1]! : ''
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
const rpcRes = await fetch('/rpc/v1', {
method: 'POST',
headers,
body: JSON.stringify({ method: 'app.filebrowser-token' }),
credentials: 'include',
})
if (!rpcRes.ok) return false
const rpcData = await rpcRes.json()
const token = rpcData?.result?.token
if (!token) return false
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}`
this._authenticated = true
return true
} catch {
return false
}
}
private headers(): Record<string, string> {
const h: Record<string, string> = {}
const cookie = this.getAuthCookie()
if (cookie) h['X-Auth'] = cookie
return h
}
/** Don't hammer app.filebrowser-token after a failed login — one attempt
* per cooldown window, so a broken/missing filebrowser doesn't turn every
* poll of the Files card into a fresh login + 401 pair (console spam). */
private _lastLoginFailure = 0
private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
private async ensureAuth(): Promise<void> {
if (this._authenticated && this.getAuthCookie()) return
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
const ok = await this.login()
if (!ok) {
this._lastLoginFailure = Date.now()
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
}
/** fetch() with auth headers + ONE transparent re-login on 401. The JWT
* from app.filebrowser-token is short-lived; before this, an expired
* cookie kept `_authenticated` true and every Files-card poll 401'd
* forever (the console-spam bug, 2026-07-22). */
private async authedFetch(url: string, init?: RequestInit): Promise<Response> {
await this.ensureAuth()
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
if (res.status === 401) {
this._authenticated = false
await this.ensureAuth()
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
}
return res
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502.
// Either way res.json() would throw the opaque "Unexpected token '<'"
// error, so detect a non-JSON body and surface a friendly message instead.
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
throw new Error('File Browser is not available — install or start the File Browser app to use your folders')
}
const data: FileBrowserListResponse = await res.json()
return (data.items || []).map((item) => ({
...item,
extension: item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '',
}))
}
/**
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
* Returns a plain URL (no token in query string).
*/
downloadUrl(path: string): string {
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
}
/**
* Fetch a file as a blob URL using header-based auth (no token in URL).
* Use this for img/video/audio src attributes and download links.
* For large files (video/audio), prefer streamUrl() instead.
*/
async fetchBlobUrl(path: string): Promise<string> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob()
return URL.createObjectURL(blob)
}
/**
* Get a direct streaming URL with auth token in query string.
* Use for video/audio <src> where browser needs to stream (range requests).
* The token is a short-lived JWT so exposure in URL is acceptable.
*/
async streamUrl(path: string): Promise<string> {
await this.ensureAuth()
const token = this.getAuthCookie()
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}?auth=${token}`
}
/**
* Trigger a file download using header-based auth (no token in URL).
*/
async downloadFile(path: string): Promise<void> {
const blobUrl = await this.fetchBlobUrl(path)
const filename = path.split('/').pop() || 'download'
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File): Promise<void> {
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
{ method: 'POST', body: file },
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Upload failed (${res.status}): ${text}`)
}
}
async createFolder(parentPath: string, name: string): Promise<void> {
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
method: 'POST',
})
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
}
async deleteItem(path: string): Promise<void> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
}
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
let res: Response
try {
res = await this.authedFetch(`${this.baseUrl}/api/resources/`)
} catch {
// Not installed / login cooling down — the Files card shows zeros.
return { totalSize: 0, folderCount: 0, fileCount: 0 }
}
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
const data: FileBrowserListResponse = await res.json()
const items = data.items || []
const folderCount = items.filter(i => i.isDir).length
const fileCount = items.filter(i => !i.isDir).length
const totalSize = items.reduce((sum, i) => sum + (i.size || 0), 0)
return { totalSize, folderCount, fileCount }
}
private static TEXT_EXTENSIONS = new Set([
'txt', 'md', 'json', 'csv', 'log', 'conf', 'yaml', 'yml', 'toml', 'xml',
'html', 'css', 'js', 'ts', 'py', 'sh', 'bash', 'env', 'ini', 'cfg',
'sql', 'rs', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'rb', 'php',
'dockerfile', 'makefile', 'gitignore', 'editorconfig',
])
isTextFile(path: string): boolean {
const ext = path.includes('.') ? path.split('.').pop()!.toLowerCase() : ''
const name = path.split('/').pop()?.toLowerCase() || ''
return FileBrowserClient.TEXT_EXTENSIONS.has(ext) || FileBrowserClient.TEXT_EXTENSIONS.has(name)
}
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob()
const size = blob.size
const truncated = size > maxBytes
const slice = truncated ? blob.slice(0, maxBytes) : blob
const content = await slice.text()
return { content, truncated, size }
}
async rename(oldPath: string, newName: string): Promise<void> {
const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
})
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
}
}
export const fileBrowserClient = new FileBrowserClient()
+411
View File
@@ -0,0 +1,411 @@
/**
* Remote Relay — receives companion app input via WebSocket and dispatches
* keyboard/mouse/scroll events into the browser, enabling the NES controller
* or companion keyboard to drive the web UI from another device.
*/
import { ref } from 'vue'
// xdotool key name → DOM key mapping
const KEY_MAP: Record<string, string> = {
Return: 'Enter',
BackSpace: 'Backspace',
Escape: 'Escape',
Tab: 'Tab',
Delete: 'Delete',
space: ' ',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Home: 'Home',
End: 'End',
Prior: 'PageUp',
Next: 'PageDown',
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', F5: 'F5', F6: 'F6',
F7: 'F7', F8: 'F8', F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
}
/** Reactive: relay WebSocket is connected to the server */
export const relayConnected = ref(false)
/** Reactive: a companion app is actively sending input (received input in last 30s) */
export const companionActive = ref(false)
/** Reactive: input is being received right now (flickers on each event) */
export const companionInputActive = ref(false)
let ws: WebSocket | null = null
let shouldReconnect = true
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
// Exponential backoff for the relay socket. It's a secondary feature (companion
// input), so when the backend is down it must NOT hammer a fixed-interval
// reconnect — that floods the console/network with failed-WS noise for the whole
// outage. Back off 1s → 30s, reset on a successful open. (Mirrors websocket.ts.)
let relayReconnectAttempts = 0
const RELAY_RECONNECT_BASE_MS = 1000
const RELAY_RECONNECT_MAX_MS = 30_000
let cursorEl: HTMLDivElement | null = null
let companionTimeout: ReturnType<typeof setTimeout> | null = null
let inputFlickerTimeout: ReturnType<typeof setTimeout> | null = null
let cursorX = typeof window !== 'undefined' ? window.innerWidth / 2 : 0
let cursorY = typeof window !== 'undefined' ? window.innerHeight / 2 : 0
let cursorHideTimer: ReturnType<typeof setTimeout> | null = null
function markCompanionActive() {
companionActive.value = true
companionInputActive.value = true
if (inputFlickerTimeout) clearTimeout(inputFlickerTimeout)
inputFlickerTimeout = setTimeout(() => { companionInputActive.value = false }, 200)
if (companionTimeout) clearTimeout(companionTimeout)
companionTimeout = setTimeout(() => { companionActive.value = false }, 30_000)
}
function createCursor(): HTMLDivElement {
if (cursorEl) return cursorEl
const el = document.createElement('div')
el.id = 'remote-relay-cursor'
el.style.cssText = `
position: fixed; z-index: 999999; pointer-events: none;
width: 20px; height: 20px; border-radius: 50%;
background: rgba(247, 147, 26, 0.7);
border: 2px solid rgba(247, 147, 26, 0.9);
transform: translate(-50%, -50%);
transition: opacity 0.3s;
opacity: 0; display: none;
`
document.body.appendChild(el)
cursorEl = el
return el
}
function showCursor() {
const el = createCursor()
el.style.display = 'block'
el.style.opacity = '1'
el.style.left = `${cursorX}px`
el.style.top = `${cursorY}px`
if (cursorHideTimer) clearTimeout(cursorHideTimer)
cursorHideTimer = setTimeout(() => {
if (cursorEl) cursorEl.style.opacity = '0'
}, 3000)
}
function moveCursor(dx: number, dy: number) {
cursorX = Math.max(0, Math.min(window.innerWidth, cursorX + dx))
cursorY = Math.max(0, Math.min(window.innerHeight, cursorY + dy))
showCursor()
}
function mapKey(xdotoolKey: string): string {
return KEY_MAP[xdotoolKey] ?? xdotoolKey
}
/** <input> types that accept free-text entry (so we should type into them). */
const TEXT_INPUT_TYPES = new Set([
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
])
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
if (!el) return false
if (el.tagName === 'TEXTAREA') return true
if (el.tagName === 'INPUT') {
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
return TEXT_INPUT_TYPES.has(type)
}
return false
}
/**
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any
* app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on
* direct ports) are opaque to the parent by browser security policy, so the
* deepest reachable element there is the <iframe> itself.
*/
function deepElementFromPoint(x: number, y: number): Element | null {
let cx = x
let cy = y
let el = document.elementFromPoint(cx, cy)
let guard = 0
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
let doc: Document | null = null
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
if (!doc) break
const rect = el.getBoundingClientRect()
cx -= rect.left
cy -= rect.top
const inner = doc.elementFromPoint(cx, cy)
if (!inner || inner === el) break
el = inner
}
return el
}
/**
* Find the nearest scrollable ancestor of `el` for the given delta, hopping out
* of same-origin iframes when needed. Synthetic WheelEvents are untrusted and
* never actually scroll the page, so two-finger scroll must call scrollBy on a
* real scroll container — this locates it (e.g. the right-hand app frame). (#7)
*/
function findScrollable(el: Element | null, dx: number, dy: number): Element | null {
let node: Element | null = el
let guard = 0
while (node && guard++ < 60) {
const win = node.ownerDocument?.defaultView
const style = win?.getComputedStyle(node)
if (style) {
const oy = style.overflowY
const ox = style.overflowX
const isRoot = node === node.ownerDocument?.scrollingElement
const canY =
(oy === 'auto' || oy === 'scroll' || isRoot) &&
node.scrollHeight > node.clientHeight + 1
const canX =
(ox === 'auto' || ox === 'scroll' || isRoot) &&
node.scrollWidth > node.clientWidth + 1
if ((dy !== 0 && canY) || (dx !== 0 && canX)) return node
}
if (node.parentElement) {
node = node.parentElement
} else if (win?.frameElement) {
node = win.frameElement as Element // same-origin iframe → continue in parent doc
} else {
break
}
}
return null
}
/** The actually-focused element, descending through same-origin iframes. */
function deepActiveElement(): Element | null {
let el: Element | null = document.activeElement
let guard = 0
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
let doc: Document | null = null
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
el = doc.activeElement
}
return el
}
/**
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
* input values (browser security), so we edit `.value` at the caret directly
* and fire an `input` event so Vue v-model / reactive search pick it up.
* Returns true if the key was consumed as text editing.
*/
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
const value = el.value
const start = el.selectionStart ?? value.length
const end = el.selectionEnd ?? value.length
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
const replaceSelection = (text: string) => {
el.value = value.slice(0, start) + text + value.slice(end)
setCaret(start + text.length)
}
if (key === 'Backspace') {
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
else return true
} else if (key === 'Delete') {
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
} else if (key === 'ArrowLeft') {
setCaret(Math.max(0, start - 1))
} else if (key === 'ArrowRight') {
setCaret(Math.min(value.length, end + 1))
} else if (key === 'Home') {
setCaret(0)
} else if (key === 'End') {
setCaret(value.length)
} else if (key === 'Enter') {
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
else return false // let the app's keydown handler act (e.g. search submit)
} else if (key.length === 1) {
replaceSelection(key) // printable character
} else {
return false // Tab / Escape / F-keys / etc. — not text editing
}
el.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
function handleMessage(data: string) {
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
try {
msg = JSON.parse(data)
} catch {
return
}
if (msg.t === 'ok') return // server ready, not companion input
markCompanionActive()
switch (msg.t) {
case 'k': {
if (!msg.k) break
const key = mapKey(msg.k)
// Dispatch player-tagged event for arcade/game apps (iframe postMessage or direct listeners)
const player = msg.p ?? 0 // 0 = untagged/broadcast, 1 = P1, 2 = P2
document.dispatchEvent(new CustomEvent('arcade-input', {
detail: { key, player, type: 'down' },
bubbles: true,
}))
// Also post to any iframe that might be listening (containerized apps like BotFights)
const iframe = document.querySelector('iframe') as HTMLIFrameElement | null
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
}
// Deliver the key to the actually-focused element (descending into
// same-origin iframes) so it reaches embedded-app inputs and search
// boxes, not just the top-level document.
const focused = deepActiveElement()
const keyTarget: EventTarget = focused ?? document
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
// Synthetic key events never insert text, so edit the field directly.
if (isTextField(focused)) {
typeKeyIntoField(focused, key)
}
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
break
}
case 'm': {
moveCursor(msg.x ?? 0, msg.y ?? 0)
break
}
case 'c': {
const target = deepElementFromPoint(cursorX, cursorY)
if (target) {
if (cursorEl) {
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
}
const eventInit: MouseEventInit = {
bubbles: true, cancelable: true, view: window,
clientX: cursorX, clientY: cursorY,
}
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
target.dispatchEvent(new MouseEvent('click', eventInit))
// A synthetic click does NOT move keyboard focus the way a real click
// does, so the app-store search box (and any input) would stay
// unfocused and untypable. Explicitly focus the nearest focusable
// element — for same-origin iframe targets this focuses inside the app.
const focusable = (target.closest?.(
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
) ?? target) as HTMLElement
if (typeof focusable.focus === 'function') {
focusable.focus({ preventScroll: true })
}
}
break
}
case 's': {
// Scroll the element under the virtual cursor (incl. inside same-origin
// app frames like the right-hand panel), not the top document. A synthetic
// wheel event won't scroll — call scrollBy on a real scroll container. (#7)
const dy = (msg.y ?? 0) * 100
const dx = (msg.x ?? 0) * 100
const start = deepElementFromPoint(cursorX, cursorY)
const scroller = findScrollable(start, dx, dy)
if (scroller) {
scroller.scrollBy({ left: dx, top: dy })
} else {
const win = start?.ownerDocument?.defaultView ?? window
win.scrollBy(dx, dy)
}
break
}
}
}
function doConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${window.location.host}/ws/remote-relay`
ws = new WebSocket(url)
ws.onopen = () => {
relayConnected.value = true
relayReconnectAttempts = 0 // healthy again — reset backoff
if (import.meta.env.DEV) console.log('[RemoteRelay] Connected')
}
ws.onmessage = (event) => {
handleMessage(event.data)
}
ws.onclose = () => {
relayConnected.value = false
ws = null
if (shouldReconnect) {
const delay = Math.min(
RELAY_RECONNECT_BASE_MS * 2 ** relayReconnectAttempts,
RELAY_RECONNECT_MAX_MS,
)
relayReconnectAttempts++
reconnectTimer = setTimeout(doConnect, delay)
}
}
ws.onerror = () => {
// onclose will handle reconnect
}
}
/**
* Ask the companion (phone) to open a URL in its own browser.
*
* "Open in external browser" apps can't be usefully opened on the kiosk when a
* companion is driving it — `window.open` lands on the kiosk, which the phone
* user never sees. When a companion is active we forward the URL over the relay
* socket ({"t":"o","url"}); the backend routes it to the phone, which opens it.
*
* Returns true if the request was forwarded (caller should NOT open locally),
* false if there's no active companion (caller should open normally).
*/
export function requestExternalOpen(url: string): boolean {
if (!url || !/^https?:\/\//i.test(url)) return false
if (!companionActive.value) return false
if (!ws || ws.readyState !== WebSocket.OPEN) return false
try {
ws.send(JSON.stringify({ t: 'o', url }))
if (import.meta.env.DEV) console.log('[RemoteRelay] Forwarded external-open to companion:', url)
return true
} catch {
return false
}
}
/** Start the remote relay listener. Connects to /ws/remote-relay. */
export function startRemoteRelay() {
shouldReconnect = true
relayReconnectAttempts = 0
doConnect()
}
/** Stop the remote relay listener and clean up. */
export function stopRemoteRelay() {
shouldReconnect = false
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
if (companionTimeout) { clearTimeout(companionTimeout); companionTimeout = null }
if (inputFlickerTimeout) { clearTimeout(inputFlickerTimeout); inputFlickerTimeout = null }
if (cursorHideTimer) { clearTimeout(cursorHideTimer); cursorHideTimer = null }
if (ws) { ws.onclose = null; ws.close(); ws = null }
if (cursorEl) { cursorEl.remove(); cursorEl = null }
relayConnected.value = false
companionActive.value = false
companionInputActive.value = false
}
File diff suppressed because it is too large Load Diff
+456
View File
@@ -0,0 +1,456 @@
// WebSocket handler for real-time updates
import type { Update, PatchOperation } from '../types/api'
import { applyPatch, type Operation } from 'fast-json-patch'
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
type WebSocketCallback = (update: Update) => void
type ConnectionStateCallback = (state: ConnectionState) => void
export class WebSocketClient {
private ws: WebSocket | null = null
private callbacks: Set<WebSocketCallback> = new Set()
private connectionStateCallbacks: Set<ConnectionStateCallback> = new Set()
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private maxReconnectDelay = 30000
private shouldReconnect = true
private url: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private visibilityChangeHandler: (() => void) | null = null
private onlineHandler: (() => void) | null = null
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private lastMessageTime: number = Date.now()
private heartbeatInterval = 10000 // Check connection every 10 seconds
private pingInterval = 30000 // Send ping every 30 seconds
private _state: ConnectionState = 'disconnected'
private isReconnecting = false
private parseErrorCount = 0
private connectCheckInterval: ReturnType<typeof setInterval> | null = null
constructor(url: string = '/ws/db') {
this.url = url
this.setupBrowserEventHandlers()
}
private setupBrowserEventHandlers(): void {
if (typeof window === 'undefined') return
// Handle page visibility changes (tab switching, browser minimizing)
this.visibilityChangeHandler = () => {
if (document.visibilityState === 'visible') {
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
// Only reconnect if we haven't been explicitly disconnected
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
this.reconnectAttempts = 0
this.connect().catch(err => {
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
})
}
}
}
document.addEventListener('visibilitychange', this.visibilityChangeHandler)
// Handle network online/offline events
this.onlineHandler = () => {
// Only reconnect if we haven't been explicitly disconnected
if (!this.shouldReconnect) return
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.reconnectAttempts = 0
this.connect().catch(err => {
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
})
}
}
window.addEventListener('online', this.onlineHandler)
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
// If already connected, resolve immediately
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
resolve()
return
}
// If connecting, wait for it
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
this.clearConnectCheck()
this.connectCheckInterval = setInterval(() => {
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
this.clearConnectCheck()
resolve()
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
this.clearConnectCheck()
// Connection failed or closing, will be handled by onclose
reject(new Error('Connection closed during connect'))
}
} else {
this.clearConnectCheck()
reject(new Error('WebSocket was cleared'))
}
}, 100)
// Timeout after 5 seconds
setTimeout(() => {
this.clearConnectCheck()
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Connection timeout'))
}
}, 5000)
return
}
// Don't close existing connection if it's still active
// Only close if it's in CLOSING or CLOSED state
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
this.ws = null
}
// If we have an active WebSocket, don't create a new one
if (this.ws) {
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
resolve()
return
}
// Only enable reconnect if not explicitly disconnected
// (shouldReconnect is set to false by disconnect())
if (this.shouldReconnect !== false) {
this.shouldReconnect = true
}
// In development, Vite proxies /ws to the backend
// In production, use the same host as the page
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}${this.url}`
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
this.setConnectionState('connecting')
this.ws = new WebSocket(wsUrl)
// Timeout handler in case connection hangs
const connectionTimeout = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
this.ws.close()
reject(new Error('Connection timeout'))
}
}, 3000) // 3 second timeout
this.ws.onopen = () => {
clearTimeout(connectionTimeout)
this.reconnectAttempts = 0
this.lastMessageTime = Date.now()
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
this.setConnectionState('connected')
this.startHeartbeat()
resolve()
}
this.ws.onerror = (error) => {
clearTimeout(connectionTimeout)
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
// Don't reject immediately - let onclose handle reconnection
// This prevents errors from blocking reconnection
}
this.ws.onmessage = (event) => {
this.lastMessageTime = Date.now()
try {
const update: Update = JSON.parse(event.data)
this.parseErrorCount = 0
this.callbacks.forEach((callback) => callback(update))
} catch (error) {
this.parseErrorCount++
if (import.meta.env.DEV) console.error(`Failed to parse WebSocket message (${this.parseErrorCount} consecutive):`, error)
if (this.parseErrorCount > 3) {
if (import.meta.env.DEV) console.warn('[WebSocket] Too many parse errors, closing to trigger reconnection')
this.ws?.close()
}
}
}
this.ws.onclose = (event) => {
clearTimeout(connectionTimeout)
this.stopHeartbeat()
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
// Notify connection state changed
this.setConnectionState('disconnected')
// Clear the WebSocket reference
this.ws = null
// Don't reconnect if we explicitly disconnected
if (!this.shouldReconnect) {
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
return
}
// Always try to reconnect unless we've exceeded max attempts
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const isHMR = event.code === 1001
const isNormalClosure = event.code === 1000 || event.code === 1001
const isServiceRestart = event.code === 1012
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
const delay = needsImmediateReconnect ? 0 :
(this.reconnectAttempts === 0 ? 100 :
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
// Clear any existing reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
const doReconnect = () => {
// Check again if we should reconnect (might have been disabled)
if (!this.shouldReconnect) {
return
}
// Prevent parallel reconnections from duplicate onclose events
if (this.isReconnecting) {
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection already in progress, skipping')
return
}
// Don't increment attempts for expected disconnects (HMR, normal closure)
if (!isHMR && !isNormalClosure) {
this.reconnectAttempts++
}
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
this.isReconnecting = true
this.connect().then(() => {
this.isReconnecting = false
}).catch((err) => {
this.isReconnecting = false
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
// onclose will be called again and will retry
})
}
if (delay === 0) {
// Immediate reconnection
doReconnect()
} else {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
doReconnect()
}, delay)
}
} else {
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
this.shouldReconnect = false
}
}
})
}
subscribe(callback: WebSocketCallback): () => void {
this.callbacks.add(callback)
return () => {
this.callbacks.delete(callback)
}
}
get state(): ConnectionState {
return this._state
}
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
this.connectionStateCallbacks.add(callback)
return () => {
this.connectionStateCallbacks.delete(callback)
}
}
private setConnectionState(state: ConnectionState): void {
this._state = state
this.connectionStateCallbacks.forEach((callback) => callback(state))
}
private clearConnectCheck(): void {
if (this.connectCheckInterval) {
clearInterval(this.connectCheckInterval)
this.connectCheckInterval = null
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
// Send ping messages every 30s
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'ping' }))
} catch {
// Send failed, connection likely broken
}
}
}, this.pingInterval)
// Check connection health every 10s
this.heartbeatTimer = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
this.stopHeartbeat()
return
}
const timeSinceLastMessage = Date.now() - this.lastMessageTime
// If no message for more than 5 minutes, assume connection is stale
if (timeSinceLastMessage > 300000) {
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
this.ws.close()
return
}
}, this.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
disconnect(): void {
this.shouldReconnect = false
this.reconnectAttempts = 0
this.setConnectionState('disconnecting')
this.stopHeartbeat()
this.clearConnectCheck()
// Clear reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.ws) {
// Remove handlers to prevent reconnection
this.ws.onclose = null
this.ws.onerror = null
try {
this.ws.close()
} catch (e) {
if (import.meta.env.DEV) console.warn('WebSocket close error', e)
}
this.ws = null
}
}
reset(): void {
this.disconnect()
this.callbacks.clear()
// Clean up browser event handlers
if (this.visibilityChangeHandler) {
document.removeEventListener('visibilitychange', this.visibilityChangeHandler)
this.visibilityChangeHandler = null
}
if (this.onlineHandler) {
window.removeEventListener('online', this.onlineHandler)
this.onlineHandler = null
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
}
// Create singleton that persists across HMR
let wsClientInstance: WebSocketClient | null = null
function getWebSocketClient(): WebSocketClient {
if (typeof window === 'undefined') {
// SSR - create new instance
if (!wsClientInstance) {
wsClientInstance = new WebSocketClient()
}
return wsClientInstance
}
// Check if we have a persisted instance from HMR
const existing = (window as unknown as Record<string, unknown>).__archipelago_ws_client
if (existing && existing instanceof WebSocketClient) {
// Check if the WebSocket is still valid
if (existing.isConnected()) {
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
wsClientInstance = existing
return existing
}
}
// Create new instance
if (!wsClientInstance) {
wsClientInstance = new WebSocketClient()
if (typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__archipelago_ws_client = wsClientInstance
}
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
}
return wsClientInstance
}
// Lazy initialization - only create when accessed
let _wsClient: WebSocketClient | null = null
export const wsClient: WebSocketClient = (() => {
if (_wsClient) {
return _wsClient
}
try {
_wsClient = getWebSocketClient()
return _wsClient
} catch (error) {
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
// Fallback to new instance
_wsClient = new WebSocketClient()
return _wsClient
}
})()
// Helper to apply patches to data
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
// Validate patch is an array before applying
if (!Array.isArray(patch) || patch.length === 0) {
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
return data
}
try {
const result = applyPatch(data, patch as Operation[], false, false)
return result.newDocument as T
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
return data // Return original data on error
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+95
View File
@@ -0,0 +1,95 @@
<template>
<div
class="flex-shrink-0 inline-block overflow-hidden"
:class="[
sizeClass,
!noBorder && 'logo-gradient-border'
]"
>
<!-- Neode logo - always white -->
<svg
class="block w-full h-full logo-svg"
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Neode"
>
<rect width="1024" height="1024" fill="#030202" />
<rect
v-for="(r, i) in rects"
:key="i"
:x="r.x"
:y="r.y"
:width="r.w"
:height="r.h"
fill="white"
class="logo-square"
:style="{ '--delay': delays[i] + 'ms' }"
/>
</svg>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
size?: 'sm' | 'lg' | 'xl'
noBorder?: boolean
/** When true, fit to container (w-full h-full) instead of fixed size - for use inside logo-gradient-border */
fit?: boolean
}>(), { size: 'sm', noBorder: false, fit: false })
const sizeClass = props.fit
? 'w-full h-full max-w-full max-h-full'
: props.size === 'xl'
? 'w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80'
: props.size === 'lg'
? 'w-32 h-32 sm:w-48 sm:h-48'
: 'w-14 h-14'
// Parsed from favico-black.svg path - 20 rects
const rects = [
{ x: 357.614, y: 318, w: 71.007, h: 70.936 },
{ x: 436.152, y: 318, w: 72.082, h: 70.936 },
{ x: 515.766, y: 318, w: 72.082, h: 70.936 },
{ x: 595.379, y: 318, w: 71.007, h: 70.936 },
{ x: 595.379, y: 396.46, w: 71.007, h: 72.011 },
{ x: 673.917, y: 396.46, w: 72.083, h: 72.011 },
{ x: 278, y: 475.994, w: 72.083, h: 72.012 },
{ x: 357.614, y: 475.994, w: 71.007, h: 72.012 },
{ x: 436.152, y: 475.994, w: 72.082, h: 72.012 },
{ x: 515.766, y: 475.994, w: 72.082, h: 72.012 },
{ x: 595.379, y: 475.994, w: 71.007, h: 72.012 },
{ x: 673.917, y: 475.994, w: 72.083, h: 72.012 },
{ x: 278, y: 555.529, w: 72.083, h: 70.936 },
{ x: 357.614, y: 555.529, w: 71.007, h: 70.936 },
{ x: 595.379, y: 555.529, w: 71.007, h: 70.936 },
{ x: 673.917, y: 555.529, w: 72.083, h: 70.936 },
{ x: 357.614, y: 633.989, w: 71.007, h: 72.011 },
{ x: 436.152, y: 633.989, w: 72.082, h: 72.011 },
{ x: 515.766, y: 633.989, w: 72.082, h: 72.011 },
{ x: 595.379, y: 633.989, w: 71.007, h: 72.011 },
]
// Stagger delays (ms) - row-by-row top-to-bottom, left-to-right for a clean reveal
const delays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
</script>
<style scoped>
.logo-svg {
will-change: auto;
}
.logo-square {
opacity: 0;
animation: logo-square-in 3s ease-out infinite;
animation-delay: var(--delay, 0ms);
animation-fill-mode: both;
}
/* Fade in only - no scale/position change. Squares stay fixed. */
@keyframes logo-square-in {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 1; }
}
</style>
@@ -0,0 +1,669 @@
<template>
<Teleport to="body">
<Transition name="app-launcher">
<!-- z-[4000]: apps must open ABOVE any modal that launched them
(BaseModal defaults to z-3000). At the old z-2400 an app opened
from e.g. the Transactions modal loaded invisibly underneath it;
now the existing enter animation plays over the modal, and closing
the app returns you to the modal you came from. -->
<div
v-if="store.isOpen"
class="fixed inset-0 z-[4000] flex items-stretch justify-stretch p-0"
@click.self="store.close()"
>
<!-- Backdrop - blur like spotlight -->
<div class="app-launcher-backdrop absolute inset-0 bg-black/75 backdrop-blur-md"></div>
<!-- Panel - full-screen overlay -->
<div
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none shadow-2xl"
:class="panelClasses"
style="border-radius: 0;"
>
<!-- Header bar - sticky on mobile -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
<div class="hidden md:flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ store.title || 'App' }}</span>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors disabled:opacity-70"
aria-label="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5 transition-transform duration-300"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Open in new tab"
title="Open in new tab"
@click="openInNewTab"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button
ref="closeBtnRef"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<!-- Loading indicator -->
<Transition name="content-fade">
<AppLoadingScreen
v-if="iframeLoading"
:icon="overlayIcon"
:title="store.title || 'App'"
:progress="loadProgress"
/>
</Transition>
<iframe
ref="iframeRef"
v-if="store.url && !iframeBlocked"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="onIframeLoad"
@error="onIframeError"
/>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2v-2m0-8h18M3 8v8m18-8v8" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Can't display in frame</h3>
<p class="text-white/50 text-sm mb-6">This app doesn't support embedded viewing.<br>Please open it in a new tab instead.</p>
<button
@click="openInNewTabAndClose"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</Transition>
<!-- Payment Confirmation Dialog -->
<Transition name="content-fade">
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h3 class="text-white font-semibold text-sm">Payment Request</h3>
<p class="text-white/50 text-xs">{{ store.title || 'App' }} wants to make a payment</p>
</div>
</div>
<div class="space-y-2 mb-4">
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Amount</span>
<span class="text-orange-400 font-bold text-lg">{{ pendingPayment.amount_sats.toLocaleString() }} sats</span>
</div>
<div v-if="pendingPayment.memo" class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Memo</span>
<span class="text-white/80 text-sm truncate ml-2">{{ pendingPayment.memo }}</span>
</div>
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Method</span>
<span class="text-white/80 text-sm capitalize">{{ pendingPayment.method || 'auto' }}</span>
</div>
</div>
<div v-if="paymentError" class="mb-3 alert-error">
<p class="text-xs">{{ paymentError }}</p>
</div>
<div class="flex gap-3">
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
Deny
</button>
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 glass-button glass-button-warning rounded-lg text-sm font-medium disabled:opacity-50">
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Nostr signing consent modal -->
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest {
request_id: string
amount_sats: number
memo?: string
method?: 'lightning' | 'ecash' | 'onchain' | 'auto'
invoice?: string
address?: string
}
const store = useAppLauncherStore()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
const iframeLoading = ref(true)
const iframeBlocked = ref(false)
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
// when present; AppLoadingScreen's <img> falls back to the default icon if the
// guessed asset 404s.
const overlayIcon = computed(() => {
const url = store.url
if (!url) return DEFAULT_APP_ICON
try {
const m = new URL(url, window.location.origin).pathname.match(/^\/app\/([a-z0-9._-]+)/i)
if (m?.[1]) return `/assets/img/app-icons/${m[1].toLowerCase()}.png`
} catch { /* not a parseable URL */ }
return DEFAULT_APP_ICON
})
// Faux load progress (cross-origin iframes give no real progress events): ease
// toward ~92% while loading, snap to 100% on load.
const loadProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
}
function startProgress() {
stopProgress()
loadProgress.value = 8
progressTimer = setInterval(() => {
loadProgress.value += Math.max(0.4, (92 - loadProgress.value) * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(iframeLoading, (loading) => {
if (loading) startProgress()
else { stopProgress(); loadProgress.value = 100 }
}, { immediate: true })
// Nostr identity picker state
const showIdentityPicker = ref(false)
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
id: string
name: string
did: string
pubkey: string
nostr_pubkey?: string
nostr_npub?: string
}
/** Get the stored identity for an app, or null if first launch */
function getStoredIdentity(appUrl: string): SelectedIdentity | null {
try {
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) as SelectedIdentity : null
} catch {
return null
}
}
/** Store the selected identity for an app */
function storeIdentity(appUrl: string, identity: SelectedIdentity) {
try {
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
localStorage.setItem(key, JSON.stringify(identity))
} catch { /* ignore */ }
}
/** Handle identity selection from the picker */
function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
if (store.url) {
storeIdentity(store.url, identity)
}
// Send identity to the iframe
sendSelectedIdentity(identity)
}
/** Send a specific identity to the iframe */
async function sendSelectedIdentity(identity: SelectedIdentity) {
try {
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({
method: 'identity.sign',
params: { id: identity.id, message: challenge }
})
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:identity',
did: identity.did,
name: identity.name,
pubkey: identity.pubkey,
nostr_pubkey: identity.nostr_pubkey || null,
nostr_npub: identity.nostr_npub || null,
challenge,
signature: sigRes.signature
}, '*')
} catch {
/* identity signing not available */
}
}
// Timers for iframe load detection
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let contentCheckId: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null }
}
// Wallet connect — payment request state
const pendingPayment = ref<PaymentRequest | null>(null)
const paymentProcessing = ref(false)
const paymentError = ref('')
const paymentOrigin = ref('')
function refreshIframe() {
isRefreshing.value = true
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
iframeRefreshKey.value++
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
}
}, 15000)
}
function openInNewTab() {
if (!store.url) return
// Inside the Archipelago companion app, open the app in the in-app WebView
// instead of window.open — which the WebView suppresses for noopener popups
// (so the tap silently no-ops). The native bridge is reliable; fall back to
// window.open in a plain mobile browser.
const native = (window as any).ArchipelagoNative
if (native && typeof native.openInApp === 'function') {
native.openInApp(store.url)
return
}
window.open(store.url, '_blank', 'noopener,noreferrer')
}
function openInNewTabAndClose() {
openInNewTab()
store.close()
}
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
iframeLoading.value = false
sendIdentityIfSupported()
// Clear the load timeout
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
// Check iframe content after a brief delay to let the app render
contentCheckId = setTimeout(checkIframeContent, 2000)
}
function onIframeError() {
clearTimers()
iframeLoading.value = false
iframeBlocked.value = true
}
/** Check if the iframe loaded meaningful content (same-origin only) */
function checkIframeContent() {
try {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return // Cross-origin — can't check, assume OK
const body = doc.body
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
iframeBlocked.value = true
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Cross-origin: can\'t access iframe, assume working', e)
}
}
/** Apps that support the Archipelago identity protocol (postMessage) */
function isIdentityAwareApp(url: string): boolean {
return url.includes('indeehub') || url.includes('indeedhub')
}
/** Send the user's identity to the iframe via postMessage.
* On first launch, shows the identity picker modal.
* On subsequent launches, uses the previously selected identity. */
async function sendIdentityIfSupported() {
if (!store.url || !isIdentityAwareApp(store.url)) return
// Check if we have a stored identity for this app
const stored = getStoredIdentity(store.url)
if (stored) {
// Use the previously selected identity
await sendSelectedIdentity(stored)
return
}
// First launch — show the identity picker
showIdentityPicker.value = true
return // Identity will be sent after selection via onIdentitySelected
}
function injectScrollbarHideIfSameOrigin() {
try {
const doc = iframeRef.value?.contentDocument
if (!doc) return
const style = doc.createElement('style')
style.textContent = `
* { -ms-overflow-style: none; scrollbar-width: none; }
*::-webkit-scrollbar { display: none; }
`
doc.head.appendChild(style)
// Escape from inside iframe → close overlay and return focus to launcher
doc.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') {
e.preventDefault()
window.parent.postMessage({ type: 'app-launcher-escape' }, '*')
}
})
} catch {
/* Cross-origin: cannot access iframe document */
}
}
const panelClasses = [
'glass-card',
'w-full h-full',
'max-w-none max-h-none',
]
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
function onMessage(e: MessageEvent) {
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
store.close()
}
// Iframe app requests identity on demand
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
sendIdentityIfSupported()
}
// Wallet connect — app requests a payment
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
handlePaymentRequest(e)
}
}
/** Handle incoming payment request from iframe app */
function handlePaymentRequest(e: MessageEvent) {
const data = e.data
if (!data.amount_sats || typeof data.amount_sats !== 'number' || data.amount_sats <= 0) {
sendPaymentResponse(e.origin, data.request_id, false, 'Invalid amount')
return
}
pendingPayment.value = {
request_id: data.request_id || `pay-${Date.now()}`,
amount_sats: data.amount_sats,
memo: data.memo,
method: data.method || 'auto',
invoice: data.invoice,
address: data.address,
}
paymentOrigin.value = e.origin
paymentError.value = ''
paymentProcessing.value = false
}
/** Send payment response back to the iframe */
function sendPaymentResponse(origin: string, requestId: string, success: boolean, error?: string, receipt?: Record<string, unknown>) {
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:payment-response',
request_id: requestId,
success,
error: error || null,
receipt: receipt || null,
}, origin || '*')
}
/** User approves the payment */
async function approvePayment() {
if (!pendingPayment.value || paymentProcessing.value) return
paymentProcessing.value = true
paymentError.value = ''
const pay = pendingPayment.value
const method = resolvePaymentMethod(pay)
try {
let receipt: Record<string, unknown> = {}
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string; amount_sats: number }>({
method: 'wallet.ecash-send',
params: { amount_sats: pay.amount_sats },
})
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
} else if (method === 'lightning') {
if (pay.invoice) {
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: pay.invoice },
})
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
} else {
// Create and immediately return an invoice for the requester to display
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: pay.amount_sats, memo: pay.memo || '' },
})
receipt = { method: 'lightning', payment_request: res.payment_request, amount_sats: pay.amount_sats }
}
} else {
if (!pay.address) {
paymentError.value = 'No Bitcoin address provided for on-chain payment'
return
}
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: pay.address, amount: pay.amount_sats },
})
receipt = { method: 'onchain', txid: res.txid, amount_sats: pay.amount_sats }
}
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
pendingPayment.value = null
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Payment failed'
paymentError.value = msg
} finally {
paymentProcessing.value = false
}
}
/** User rejects the payment */
function rejectPayment() {
if (pendingPayment.value) {
sendPaymentResponse(paymentOrigin.value, pendingPayment.value.request_id, false, 'Payment denied by user')
pendingPayment.value = null
}
}
/** Resolve auto method based on amount */
function resolvePaymentMethod(pay: PaymentRequest): 'ecash' | 'lightning' | 'onchain' {
if (pay.method && pay.method !== 'auto') return pay.method
if (pay.invoice) return 'lightning'
if (pay.address) return 'onchain'
if (pay.amount_sats < 1000) return 'ecash'
if (pay.amount_sats > 500000) return 'onchain'
return 'lightning'
}
watch(
() => store.isOpen,
(open) => {
if (open) {
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
// Set max load timeout — if iframe never fires load, show fallback
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
}
}, 15000)
closeBtnRef.value?.focus()
} else {
isRefreshing.value = false
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
// Clear any pending payment when closing
if (pendingPayment.value) {
rejectPayment()
}
}
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
})
onBeforeUnmount(() => {
clearTimers()
stopProgress()
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
})
</script>
<style scoped>
.app-launcher-panel {
will-change: transform, opacity;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.3s ease;
}
.app-launcher-enter-active .app-launcher-backdrop {
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
}
.app-launcher-leave-active .app-launcher-backdrop {
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
}
.app-launcher-enter-active .app-launcher-panel {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.app-launcher-leave-active .app-launcher-panel {
transition: transform 0.25s cubic-bezier(0.55, 0, 1, 0.45), opacity 0.2s ease;
}
.app-launcher-enter-from {
opacity: 0;
}
.app-launcher-enter-from .app-launcher-backdrop {
opacity: 0;
backdrop-filter: blur(0);
}
.app-launcher-enter-from .app-launcher-panel {
transform: translateY(40px);
opacity: 0;
}
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-leave-to .app-launcher-backdrop {
opacity: 0;
backdrop-filter: blur(0);
}
.app-launcher-leave-to .app-launcher-panel {
transform: translateY(30px);
opacity: 0;
}
</style>
@@ -0,0 +1,81 @@
<template>
<div class="app-loading-screen absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="app-loading-icon">
<img :src="icon" :alt="title" @error="handleImageError" />
</div>
<p class="app-loading-title">{{ title }}</p>
<div class="app-loading-bar">
<div class="app-loading-fill" :style="{ width: `${clampedProgress}%` }"></div>
</div>
<p class="app-loading-hint">{{ hint }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = withDefaults(defineProps<{
icon: string
title: string
progress: number
hint?: string
}>(), {
hint: 'Loading…',
})
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.progress)))
</script>
<style scoped>
.app-loading-screen {
gap: 18px;
background: #0b0d12;
}
.app-loading-icon {
width: 84px;
height: 84px;
border-radius: 20px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
animation: app-loading-pulse 1.8s ease-in-out infinite;
}
.app-loading-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-loading-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.app-loading-bar {
width: min(240px, 60vw);
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.app-loading-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #fb923c, #f59e0b);
transition: width 0.3s ease;
}
.app-loading-hint {
margin: 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
@keyframes app-loading-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="relative" ref="containerRef">
<!-- Mobile/tablet: Online button launches CLI directly (no CLI label, no dropdown) -->
<button
type="button"
class="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg glass-card text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0 border border-white/10"
@click="selectCLI"
>
<img
src="/assets/img/logo-archipelago.svg"
alt="Archipelago"
class="w-5 h-5 shrink-0 object-contain opacity-90"
/>
<div class="flex items-center gap-1.5 shrink-0">
<div class="relative">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
</div>
<span class="text-xs text-white/80">Online</span>
</div>
</button>
<!-- Desktop: Full switcher with dropdown (lg and up) -->
<button
type="button"
class="hidden lg:flex items-center gap-2 px-3 py-2 rounded-lg glass-card text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0 border border-white/10"
@click="showDropdown = !showDropdown"
>
<img
src="/assets/img/logo-archipelago.svg"
alt="Archipelago"
class="w-5 h-5 shrink-0 object-contain opacity-90"
/>
<span class="text-sm font-medium truncate max-w-[100px] sm:max-w-[120px]">Archipelago CLI</span>
<div class="flex items-center gap-1.5 shrink-0 pl-1 border-l border-white/20">
<div class="relative">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
</div>
<span class="text-xs text-white/80">Online</span>
</div>
<svg class="w-4 h-4 text-white/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Transition name="dropdown">
<div
v-if="showDropdown"
class="absolute right-0 top-full mt-1 py-1 min-w-[160px] rounded-lg glass-card shadow-xl z-50"
@click.stop
>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="inCLI ? 'bg-white/10 text-white' : 'text-white/80 hover:bg-white/10 hover:text-white'"
@click="selectCLI"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Archipelago CLI
</button>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="!inCLI ? 'bg-white/10 text-white' : 'text-white/80 hover:bg-white/10 hover:text-white'"
@click="selectWebUI"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Web UI
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useCLIStore } from '@/stores/cli'
const cliStore = useCLIStore()
const containerRef = ref<HTMLElement | null>(null)
const showDropdown = ref(false)
const inCLI = computed(() => cliStore.isOpen)
function selectCLI() {
showDropdown.value = false
cliStore.open()
}
function selectWebUI() {
showDropdown.value = false
cliStore.close()
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false
}
}
onMounted(() => {
document.addEventListener('pointerdown', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', handleClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<template>
<!-- Desktop: subtle "frosted pill" link, sits at the top of the content flow
(the style from the Networking Profits page, now shared globally). -->
<button
type="button"
@click="$emit('click')"
:class="['hidden md:inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white text-sm transition-colors', desktopMargin]"
>
<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="M15 19l-7-7 7-7" />
</svg>
{{ label }}
</button>
<!-- Mobile: floating transparent button pinned 8px above the tab bar -->
<Teleport to="body">
<button
type="button"
@click="$emit('click')"
class="md:hidden mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ label }}</span>
</button>
</Teleport>
</template>
<script setup lang="ts">
/**
* Standard back button. Renders a transparent text link at the top on desktop
* and a floating transparent "glass" pill pinned above the tab bar on mobile —
* the pattern set by the Cloud detail pages (PeerFiles/CloudFolder).
*
* Presentational only: it emits `click`; the parent keeps its own navigation
* logic (router.push / router.back / conditional goBack).
*/
withDefaults(
defineProps<{
label?: string
/** Desktop bottom-margin utility (views vary between mb-4 and mb-6). */
desktopMargin?: string
}>(),
{ label: 'Back', desktopMargin: 'mb-4' }
)
defineEmits<{ (e: 'click'): void }>()
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 flex items-center justify-center p-4"
:class="zClass"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Column layout (2026-07-22 modal contract): title row and the
optional #header slot (tabs) stay pinned at the top, the #footer
slot (action buttons) stays pinned at the bottom, and ONLY the
default slot scrolls. Callers that previously made the whole
card scroll via contentClass keep working the inner region
simply never lets the card overflow. -->
<div
ref="modalRef"
class="glass-card p-6 w-full relative z-10 flex flex-col"
:class="[maxWidth, contentClass, defaultMaxH]"
role="dialog"
aria-modal="true"
@click.stop
>
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
<button
@click="close"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div v-if="$slots.header" class="shrink-0">
<slot name="header" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto">
<slot />
</div>
<div v-if="$slots.footer" class="shrink-0 pt-4">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
const props = withDefaults(defineProps<{
show: boolean
title: string
maxWidth?: string
zIndex?: string
contentClass?: string
}>(), {
maxWidth: 'max-w-md',
zIndex: 'z-[3000]',
contentClass: '',
})
const emit = defineEmits<{
close: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const zClass = computed(() => props.zIndex)
// The pinned-footer layout needs a height bound or tall content pushes the
// footer off-screen anyway. Callers that set their own max-h (e.g. the
// Transactions modal's visual-viewport calc on mobile) keep authority —
// adding a second max-h class would make the CSS winner order-dependent.
const defaultMaxH = computed(() =>
props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]'
)
function close() {
emit('close')
}
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>
+374
View File
@@ -0,0 +1,374 @@
<template>
<Transition name="boot-fade">
<div v-if="visible" class="boot-screen">
<!-- Particle starfield -->
<canvas ref="canvasRef" class="boot-stars" />
<!-- Two-column layout: terminal left, orb right -->
<div class="boot-layout">
<!-- Left: Terminal log -->
<div 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="rgba(255,255,255,0.5)" />
<stop offset="1" stop-color="rgba(255,255,255,0.9)" />
</linearGradient>
</defs>
</svg>
<span class="boot-pct">{{ Math.round(progress) }}%</span>
</div>
</div>
<!-- Right: The orb -->
<div class="boot-right">
<div class="boot-orb">
<!-- 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: gradient-bordered frame with cycling icons -->
<div class="boot-center-icon">
<div class="boot-icon-frame boot-gradient-border">
<Transition name="icon-morph" mode="out-in">
<div :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<img :src="iconSources[currentIcon]" class="boot-icon-img" />
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ ready: [] }>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const currentIcon = ref(0)
const progress = ref(0)
const litBars = ref(0)
const glitching = ref(false)
// Boot screen icons — from /assets/icon/ directory
const iconSources = [
'/assets/icon/bitcoin.svg',
'/assets/icon/cloud-done.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) }
// 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(() => {
currentIcon.value = (currentIcon.value + 1) % iconSources.length
triggerGlitch()
}, 2500)
// Feed boot log messages
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, poll for server readiness then immediately transition
let finished = false
logTimeouts.push(setTimeout(() => {
const finishBoot = () => {
if (finished) return
finished = true
stopPolling()
progress.value = 100
litBars.value = 48
if (import.meta.env.DEV) console.log('[Boot] finishBoot — emitting ready in 800ms')
setTimeout(() => {
if (import.meta.env.DEV) console.log('[Boot] emitting ready now')
emit('ready')
}, 800)
}
checkHealth().then(r => {
if (import.meta.env.DEV) console.log('[Boot] health check result:', r)
if (r) { finishBoot(); return }
pollInterval = setInterval(async () => {
const healthy = await checkHealth()
if (import.meta.env.DEV) console.log('[Boot] poll health:', healthy)
if (healthy) 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) }
}
let started = false
function startIfNeeded() {
if (started) return
started = true
startPolling()
nextTick(initCanvas)
}
watch(() => props.visible, v => { if (v) startIfNeeded() })
onMounted(() => { if (props.visible) startIfNeeded() })
onBeforeUnmount(() => { stopPolling(); cancelAnimationFrame(animFrame) })
</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-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;
}
/* 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: rgba(255,255,255,0.7); }
.boot-log-line.ready { color: white; font-weight: 600; text-shadow: 0 0 10px rgba(255,255,255,0.4); }
.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(255,255,255,0.35); }
.boot-log-line.ready .boot-log-ts { color: rgba(255,255,255,0.5); }
@keyframes log-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
.boot-cursor { color: rgba(255,255,255,0.5); 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.3); 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;
}
@media (min-width: 640px) { .boot-orb { width: 280px; height: 280px; } }
@media (min-width: 768px) { .boot-orb { width: 320px; height: 320px; } }
/* 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-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(255,255,255,0.7), rgba(255,255,255,0.2));
box-shadow: 0 0 6px rgba(255,255,255,0.15);
height: 22px; margin-top: -11px;
}
/* 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: 120px; height: 120px;
display: flex; align-items: center; justify-content: center;
}
@media (min-width: 640px) { .boot-icon-frame { width: 160px; height: 160px; } }
@media (min-width: 768px) { .boot-icon-frame { width: 200px; height: 200px; } }
/* Gradient border — circular, matches logo-gradient-border style */
.boot-gradient-border {
position: relative;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,0,0,0.8) 100%);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.boot-gradient-border::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 9999px;
background: #000;
z-index: 0;
}
.boot-pixel-wrap {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
position: relative; z-index: 1;
}
.boot-icon-img {
width: 55%; height: 55%; object-fit: contain;
filter: brightness(0) invert(1) drop-shadow(0 0 8px rgba(255,255,255,0.15));
}
/* 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); }
/* 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; }
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<Teleport to="body">
<Transition name="cli-popup">
<div
v-if="cliStore.isOpen"
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
@click.self="cliStore.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="panelRef"
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
:style="panelStyle"
@mousedown="onPanelMouseDown"
>
<!-- Header: terminal icon + title + app switcher -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<div class="flex items-center gap-3 flex-1 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-white font-medium">CLI Access</span>
</div>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- Content: mock CLI in dev, SSH instructions in production -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<!-- Mock CLI interface (dev mode only) -->
<div
v-if="isDev"
class="flex-1 flex flex-col min-h-0 p-4 bg-black/80 rounded-b-lg font-mono text-sm"
>
<div ref="outputRef" class="flex-1 overflow-y-auto text-green-400/90 whitespace-pre-wrap break-words mb-2 min-h-0">{{ mockOutput }}</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-amber-400">archipelago@node</span>
<span class="text-white/60">~</span>
<span class="text-white/40">$</span>
<input
ref="cliInputRef"
v-model="mockCommand"
type="text"
class="flex-1 bg-transparent text-white outline-none border-none"
placeholder=" "
@keydown.enter="runMockCommand"
/>
</div>
</div>
<!-- SSH instructions (production only) -->
<div v-else class="flex-1 overflow-y-auto p-4 space-y-4">
<p class="text-white/80 text-sm">
Connect to this node via SSH to access the command line. Use the same host as this web interface.
</p>
<div class="space-y-3">
<div class="p-3 rounded-lg bg-white/5 font-mono text-sm">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">SSH Command</div>
<div class="flex items-center gap-2 flex-wrap">
<code class="text-green-400 break-all">{{ sshCommand }}</code>
<button
type="button"
class="shrink-0 px-2 py-1 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white text-xs transition-colors"
@click="copyCommand"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<div class="p-3 rounded-lg bg-white/5 text-sm space-y-1">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">Connection Details</div>
<div class="flex flex-col gap-1.5 text-white/80">
<div class="flex justify-between gap-4">
<span class="text-white/50">Host</span>
<span class="font-mono text-green-400">{{ host }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">User</span>
<span class="font-mono">archipelago</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">Password</span>
<span class="font-mono">archipelago</span>
</div>
</div>
</div>
<p class="text-white/50 text-xs">
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
</p>
<p class="text-white/40 text-xs">
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">F</kbd> to open this anytime.
</p>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useCLIStore } from '@/stores/cli'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const cliStore = useCLIStore()
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const outputRef = ref<HTMLElement | null>(null)
const cliInputRef = ref<HTMLInputElement | null>(null)
const copied = ref(false)
const mockCommand = ref('')
const mockOutput = ref(` ╔═══════════════════════════════════════════════════════════╗
║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║
║ Your sovereign Bitcoin infrastructure ║
╚═══════════════════════════════════════════════════════════╝
System Status:
─────────────────────────────────────────────────────────────
Mode: 🟢 Installed
Podman: 🟢 Installed
Bitcoin: 🟢 Running (blocks: syncing)
Lightning: 🟡 Stopped
Main Menu:
─────────────────────────────────────────────────────────────
r) Refresh - Update IP/status
w) Open Web UI - Launch graphical interface
1) Install to Disk - Permanently install Archipelago
2) Setup Bitcoin Core - Configure Bitcoin full node
3) Setup Lightning (LND) - Configure Lightning Network
4) Setup BTCPay Server - Bitcoin payment processor
5) View Logs - Monitor running services
6) Network Settings - Configure networking
7) System Info - View system information
q) Quit
`)
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const SAVED_POSITION_KEY = 'archipelago-cli-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
const isDev = import.meta.env.DEV
const host = computed(() => window.location.hostname)
const sshCommand = computed(() => `ssh archipelago@${host.value}`)
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
function loadSavedPosition() {
try {
const raw = localStorage.getItem(SAVED_POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw)
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
} else {
savedPosition.value = null
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved CLI position', e)
savedPosition.value = null
}
}
function savePosition(x: number, y: number) {
savedPosition.value = { x, y }
try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to save CLI position', e)
}
}
function runMockCommand() {
const cmd = mockCommand.value.trim()
if (!cmd) return
mockOutput.value += `\n archipelago@node ~ $ ${cmd}\n`
const lower = cmd.toLowerCase()
if (lower === 'r' || lower === 'refresh') {
mockOutput.value += ` Status refreshed.\n`
} else if (lower === 'w' || lower.startsWith('web')) {
mockOutput.value += ` Opening Web UI... (press C to return to CLI)\n`
} else if (lower === 'q' || lower === 'quit' || lower === 'exit') {
mockOutput.value += ` Goodbye! 🏝️\n`
cliStore.close()
} else if (lower === 'help' || lower === '?') {
mockOutput.value += ` Type r, w, 1-7, or q. Press C to switch to Web UI.\n`
} else {
mockOutput.value += ` Unknown command. Type 'help' or 'r' for menu.\n`
}
mockCommand.value = ''
nextTick(() => {
outputRef.value?.scrollTo({ top: outputRef.value.scrollHeight, behavior: 'smooth' })
})
}
async function copyCommand() {
try {
await navigator.clipboard.writeText(sshCommand.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = sshCommand.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
useModalKeyboard(panelRef, computed(() => cliStore.isOpen), () => cliStore.close())
watch(
() => cliStore.isOpen,
(open) => {
if (open) {
loadSavedPosition()
if (isDev) {
nextTick(() => cliInputRef.value?.focus())
}
}
}
)
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<style scoped>
.cli-popup-enter-active,
.cli-popup-leave-active {
transition: opacity 0.2s ease;
}
.cli-popup-enter-from,
.cli-popup-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,45 @@
<template>
<Transition name="companion-slide">
<div
v-if="relayConnected"
class="flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-300"
:class="companionActive
? 'bg-orange-500/10 border border-orange-500/20'
: 'bg-white/5 border border-white/10'"
:title="companionActive ? 'Companion app connected' : 'Remote relay ready'"
>
<svg
class="w-5 h-5 flex-shrink-0 transition-colors duration-200"
:class="companionActive
? (companionInputActive ? 'text-orange-400' : 'text-orange-400/70')
: 'text-white/20'"
fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"
>
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
<span
class="text-xs hidden sm:inline transition-colors duration-200"
:class="companionActive ? 'text-orange-400/80' : 'text-white/30'"
>{{ companionActive ? 'Companion' : 'Relay' }}</span>
<span
v-if="companionInputActive"
class="ml-auto w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"
/>
</div>
</Transition>
</template>
<script setup lang="ts">
import { relayConnected, companionActive, companionInputActive } from '@/api/remote-relay'
</script>
<style scoped>
.companion-slide-enter-active { transition: opacity 0.3s ease, max-height 0.3s ease; }
.companion-slide-leave-active { transition: opacity 0.2s ease, max-height 0.2s ease; }
.companion-slide-enter-from,
.companion-slide-leave-to { opacity: 0; max-height: 0; }
</style>
@@ -0,0 +1,443 @@
<template>
<Teleport to="body">
<Transition name="overlay-fade">
<div
v-if="visible"
class="fixed inset-0 flex items-end sm:items-center justify-center p-4 z-[3000]"
@click.self="dismiss"
>
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0 overflow-hidden"
@click.stop
>
<button
type="button"
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors z-10"
aria-label="Close companion modal"
@click="dismiss"
>
&times;
</button>
<Transition :name="slideName" mode="out-in">
<!-- Screen 1: get the app -->
<div v-if="step === 'download'" key="download">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
<p class="text-sm text-white/60 leading-relaxed">
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
</p>
</div>
</div>
<div class="hidden md:flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="qrDataUrl"
:src="qrDataUrl"
alt="Companion app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<div class="flex gap-2">
<a
:href="companionDownloadUrl"
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download app
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="showPairScreen"
>
I've installed it
</button>
</div>
</div>
<!-- Screen 2: pair the app with this node -->
<div v-else key="pair">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke-width="1.5" />
<path d="M13 13h3v3h-3zM17 17h3v3h-3z" fill="currentColor" stroke="none" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
<p class="text-sm text-white/60 leading-relaxed">
In the companion app, choose “Scan Node's QR and point your phone here it connects instantly and sets up secure remote access over the mesh.
</p>
</div>
</div>
<div class="flex justify-center mb-4">
<!-- Bigger than the download QR: this one is scanned by the
companion app camera, and physical size drives decode. -->
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="pairQrDataUrl"
:src="pairQrDataUrl"
alt="Companion app pairing QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
<svg v-if="pairLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
</div>
</div>
</div>
<!-- Same-device path: a QR on the phone's own screen can't be
scanned, so offer the deep link directly on small screens. -->
<a
v-if="pairingUrl"
:href="pairingUrl"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors mb-2"
>
Open in companion app
</a>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showDownloadScreen"
>
Back
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
import { companionIntroRequested } from '@/composables/useCompanionIntro'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { useServerStore } from '@/stores/server'
import { rpcClient } from '@/api/rpc-client'
const STORAGE_KEY = 'neode_companion_intro_seen'
// Absolute URL so the QR works when scanned by a phone (a relative path has no
// host to resolve). Points at the companion APK hosted on the 146 release server
// (publicly reachable) rather than the local node's /packages copy.
// The demo serves the APK from its own public origin instead, so the QR never
// exposes the release-server address.
const DEFAULT_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/archipelago-companion.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
// Deep-link scheme the companion app registers; carries the server entry the
// app should create (see docs/companion-pairing-qr.md for the contract).
const PAIR_SCHEME = 'archipelago://pair'
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
// Fallback display name when the node still has the factory server name.
const DEFAULT_PAIR_NAME = 'My Archipelago'
// Device-token entry name; re-minting replaces the previous token so
// re-showing this screen never piles up credentials server-side.
const DEVICE_TOKEN_NAME = 'companion'
const visible = ref(false)
const step = ref<'download' | 'pair'>('download')
const slideName = ref('slide-forward')
const qrDataUrl = ref('')
const pairQrDataUrl = ref('')
const pairingUrl = ref('')
const pairLoading = ref(false)
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
const loginTransition = useLoginTransitionStore()
const serverStore = useServerStore()
// Base delay before the popup may appear, and extra breathing room after the
// dashboard entrance cinematic ends so the popup never cuts into the reveal.
const BASE_DELAY_MS = 5000
const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
// Running inside the companion app's own WebView (it injects this JS bridge).
// The "get the companion app" pitch is nonsense there — the user is already in
// it. Server management for connected companions lives in the NESMenu instead.
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
onMounted(() => {
if (IN_COMPANION_APP) return
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
setTimeout(maybeShow, BASE_DELAY_MS)
}
} catch {
// localStorage unavailable
}
})
onUnmounted(() => {
if (calmTicker) clearInterval(calmTicker)
})
function maybeShow() {
// Show only after the scene has been CONTINUOUSLY calm (no reveal
// cinematic) for the full grace window. The previous point-in-time check
// raced a slow-starting reveal — on a cold cache the entrance video can
// begin buffering after the 5s base delay, so the flag was still false
// when sampled and the popup cut straight into the cinematic.
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
calmTicker = setInterval(() => {
if (loginTransition.introCinematicPlaying) {
calmSince = null
return
}
if (calmSince === null) calmSince = Date.now()
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
if (calmTicker) clearInterval(calmTicker)
calmTicker = null
visible.value = true
}
}, 250)
}
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
watch(companionIntroRequested, (requested) => {
if (!requested) return
companionIntroRequested.value = false
if (calmTicker) {
clearInterval(calmTicker)
calmTicker = null
}
step.value = 'download'
visible.value = true
})
watch(visible, async (isVisible) => {
if (!isVisible) return
// Generate large and let CSS scale down — at 112px source a ~45-module QR
// is 2.5px/module, which camera decoders (the companion app included)
// routinely fail on. 512px keeps every module crisp.
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}, { immediate: true, flush: 'post' })
/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */
function isTailnetIp(host: string): boolean {
const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
}
/**
* The server URL the companion app should connect to. The demo advertises its
* public https origin; a real node advertises the browser's own origin ONLY
* when a phone could plausibly reach it too. Two origins that a phone on the
* LAN can never dial get substituted with the node's real LAN address:
* - localhost/127.0.0.1 (the kiosk browses itself)
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
* QR carried one of these on 2026-07-22 and the companion sat there
* dialing an IP the phone had no route to)
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
const { hostname, origin } = window.location
const phoneUnreachable =
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
if (!phoneUnreachable) return origin
try {
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
method: 'system.get-hostname',
})
// Prefer the LAN IP (phones resolve it without mDNS support — Android
// notoriously lacks .local); fall back to the mDNS name.
if (res?.lan_ip) return `http://${res.lan_ip}`
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
} catch {
// RPC unavailable — fall through to the (unreachable) origin; the app
// still lets the user edit the address by hand.
}
return origin
}
/**
* Pairing payload the companion app consumes (docs/companion-pairing-qr.md):
* v contract version (1)
* url where the app connects right now (LAN origin / mDNS)
* name what the server is called in the app
* tok device token — the app logs in with it instantly (real nodes)
* pw shared demo password (demo only)
* fnpub node's FIPS mesh identity — the phone's embedded FIPS dials it
* fip node's fips0 ULA — where the UI stays reachable once meshed
* fhost host the phone dials for the mesh (same host `url` resolved to)
* fudp / ftcp mesh transport ports on fhost
*
* Token and mesh params are best-effort: without them the QR still pairs the
* old way (manual password, LAN only), so a mid-onboarding backend hiccup
* never produces a dead QR.
*/
async function buildPairingUrl(): Promise<string> {
const serverUrl = await resolveServerUrl()
const params = new URLSearchParams({ v: '1', url: serverUrl })
const name = serverStore.serverName
params.set('name', !name || name === 'Archipelago' ? DEFAULT_PAIR_NAME : name)
if (IS_DEMO) {
// Only the shared demo password ever rides in the QR; real node passwords
// are never available to the frontend.
params.set('pw', DEMO_PASSWORD)
return `${PAIR_SCHEME}?${params.toString()}`
}
try {
const res = await rpcClient.call<{ token?: string }>({
method: 'auth.createDeviceToken',
params: { name: DEVICE_TOKEN_NAME },
})
if (res?.token) params.set('tok', res.token)
} catch {
// Older backend or transient failure — app falls back to password entry.
}
try {
const info = await rpcClient.call<{
npub?: string
ula?: string | null
udp_port?: number
tcp_port?: number
anchors?: { npub: string; addr: string; transport: string }[]
}>({ method: 'fips.pair-info' })
if (info?.npub) {
params.set('fnpub', info.npub)
if (info.ula) params.set('fip', info.ula)
try {
params.set('fhost', new URL(serverUrl).hostname)
} catch {
params.set('fhost', window.location.hostname)
}
if (info.udp_port) params.set('fudp', String(info.udp_port))
if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
// FIRST entry is the paired node ITSELF: the phone peers with it by
// npub (the fhost addr is only a dial hint), so LAN contact is direct
// p2p over FIPS and survives DHCP renumbering; the node's public
// anchors follow for reaching it away from home. Cap keeps the QR at a
// camera-friendly density; the node lists its most reachable anchors
// first.
const selfAnchor = info.tcp_port
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
: []
// HARD CAP at 2 anchors: every extra npub adds ~100 URL-encoded chars
// and pushed the QR past what phone cameras decode off a screen
// (3 anchors ≈ 600 chars ≈ QR v23 — observed unscannable 2026-07-23).
// Self + one public rendezvous is all the phone needs; prefer the
// Archipelago-operated vps2 anchor as the public one.
const others = (info.anchors || []).filter((a) => a.npub !== info.npub)
const publicAnchor =
others.find((a) => a.addr.startsWith('146.59.87.168')) ?? others[0]
const anchors = [...selfAnchor, ...(publicAnchor ? [publicAnchor] : [])].slice(0, 2)
if (anchors.length) {
params.set(
'fanchors',
anchors.map((a) => `${a.npub}@${a.addr}/${a.transport}`).join(','),
)
}
}
} catch {
// FIPS not provisioned yet — LAN pairing still works; the app can mesh
// later from a re-scan.
}
return `${PAIR_SCHEME}?${params.toString()}`
}
async function showPairScreen() {
slideName.value = 'slide-forward'
step.value = 'pair'
if (pairQrDataUrl.value || pairLoading.value) return
pairLoading.value = true
try {
pairingUrl.value = await buildPairingUrl()
// Large source + a real quiet zone; this QR is scanned by the companion
// app's camera, so give it every advantage (see download QR note above).
// EC level L: the payload is long (token + npub + anchors) and screen
// scans don't suffer the damage EC-M protects against — L drops the
// module count a full version tier, which is what makes it scannable.
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
width: 768,
margin: 3,
errorCorrectionLevel: 'L',
color: {
dark: '#111111',
light: '#ffffff',
},
})
} finally {
pairLoading.value = false
}
}
function showDownloadScreen() {
slideName.value = 'slide-back'
step.value = 'download'
}
function dismiss() {
visible.value = false
step.value = 'download'
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
// ignore
}
}
</script>
<style scoped>
.overlay-fade-enter-active { transition: opacity 0.3s ease; }
.overlay-fade-leave-active { transition: opacity 0.2s ease; }
.overlay-fade-enter-from,
.overlay-fade-leave-to { opacity: 0; }
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
/* Horizontal slide between the download and pairing screens */
.slide-forward-enter-active,
.slide-forward-leave-active,
.slide-back-enter-active,
.slide-back-leave-active { transition: transform 0.25s ease, opacity 0.25s ease; }
.slide-forward-enter-from { transform: translateX(40px); opacity: 0; }
.slide-forward-leave-to { transform: translateX(-40px); opacity: 0; }
.slide-back-enter-from { transform: translateX(-40px); opacity: 0; }
.slide-back-leave-to { transform: translateX(40px); opacity: 0; }
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div class="flex items-center gap-2">
<!-- Status Indicator -->
<div class="relative">
<div
class="w-3 h-3 rounded-full transition-colors"
:class="statusClass"
></div>
<div
v-if="isRunning"
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
:class="statusClass"
></div>
</div>
<!-- Status Text -->
<span class="text-sm font-medium" :class="textClass">
{{ statusText }}
</span>
<!-- Health Badge (if running) -->
<span
v-if="isRunning && health"
class="px-2 py-0.5 rounded text-xs font-medium"
:class="healthClass"
>
{{ healthText }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
state:
| 'created'
| 'running'
| 'stopped'
| 'exited'
| 'paused'
| 'unknown'
| 'stopping'
| 'starting'
| 'restarting'
| 'installing'
| 'updating'
| 'removing'
| 'installed'
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
const props = withDefaults(defineProps<Props>(), {
health: 'unknown',
})
const isRunning = computed(() => props.state === 'running')
const statusClass = computed(() => {
switch (props.state) {
case 'running':
return 'bg-green-400'
case 'stopped':
case 'exited':
case 'installed':
return 'bg-gray-400'
case 'paused':
case 'starting':
case 'stopping':
case 'restarting':
case 'installing':
case 'updating':
case 'removing':
return 'bg-yellow-400'
default:
return 'bg-red-400'
}
})
const textClass = computed(() => {
switch (props.state) {
case 'running':
return 'text-green-400'
case 'stopped':
case 'exited':
case 'installed':
return 'text-gray-400'
case 'paused':
case 'starting':
case 'stopping':
case 'restarting':
case 'installing':
case 'updating':
case 'removing':
return 'text-yellow-400'
default:
return 'text-red-400'
}
})
const statusText = computed(() => {
switch (props.state) {
case 'running':
return 'Running'
case 'stopped':
return 'Stopped'
case 'exited':
return 'Exited'
case 'paused':
return 'Paused'
case 'created':
return 'Created'
case 'installed':
return 'Installed'
case 'starting':
return 'Starting…'
case 'stopping':
return 'Stopping…'
case 'restarting':
return 'Restarting…'
case 'installing':
return 'Installing…'
case 'updating':
return 'Updating…'
case 'removing':
return 'Removing…'
default:
return 'Unknown'
}
})
const healthClass = computed(() => {
switch (props.health) {
case 'healthy':
return 'bg-green-500/20 text-green-400 border border-green-500/30'
case 'unhealthy':
return 'bg-red-500/20 text-red-400 border border-red-500/30'
case 'starting':
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
default:
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
}
})
const healthText = computed(() => {
switch (props.health) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'starting':
return 'Starting'
default:
return 'Unknown'
}
})
</script>
@@ -0,0 +1,21 @@
<template>
<div
v-if="store.isActive"
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10"
title="Controller connected - use arrows & Enter to navigate"
>
<svg class="w-5 h-5 text-amber-400/90 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="8" width="16" height="10" rx="2" stroke-width="2" />
<circle cx="9" cy="13" r="1.5" fill="currentColor" />
<circle cx="15" cy="13" r="1.5" fill="currentColor" />
<path stroke-linecap="round" stroke-width="2" d="M12 10v2M11 11h2" />
</svg>
<span class="text-xs text-white/70 hidden sm:inline">Controller</span>
</div>
</template>
<script setup lang="ts">
import { useControllerStore } from '@/stores/controller'
const store = useControllerStore()
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-8 transition-opacity duration-300"
:class="{ 'opacity-0 pointer-events-none': !show }"
>
<RouterLink
v-for="(goal, idx) in goals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
class="goal-card glass-card p-6 block"
:class="{ 'home-card-animate': animate }"
:style="{ '--card-stagger': idx }"
>
<div class="flex items-start justify-between mb-4">
<!-- App icons for goals with required apps, emoji fallback otherwise -->
<div v-if="goalAppIcons(goal).length > 0" class="flex items-center gap-1.5 shrink-0">
<img
v-for="icon in goalAppIcons(goal)"
:key="icon.appId"
:src="icon.url"
:alt="icon.appId"
class="w-8 h-8 rounded-lg object-contain bg-white/5 border border-white/10 p-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</div>
<div v-else class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
</div>
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
<span v-if="goalStatuses[goal.id] === 'completed'" class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
<span v-else-if="goalStatuses[goal.id] === 'in-progress'" class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>
{{ statusLabel(goal.id) }}
</span>
</div>
<h3 class="text-lg font-semibold text-white mb-1">{{ goal.title }}</h3>
<p class="text-sm text-white/55 mb-4 leading-relaxed">{{ goal.subtitle }}</p>
<div class="flex items-center justify-between">
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
<span class="text-xs text-white/50 flex items-center gap-1">
{{ goal.difficulty === 'beginner' ? t('easyHome.beginner') : t('easyHome.intermediate') }}
</span>
</div>
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import type { GoalDefinition } from '@/types/goals'
defineProps<{
show: boolean
animate: boolean
}>()
const { t } = useI18n()
const goalStore = useGoalStore()
const goals = GOALS
const goalStatuses = goalStore.goalStatuses
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.png',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
filebrowser: '/assets/img/app-icons/file-browser.webp',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrumx.png',
electrumx: '/assets/img/app-icons/electrumx.png',
}
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
return goal.requiredApps
.filter((appId) => APP_ICON_MAP[appId] !== undefined)
.map((appId) => ({ appId, url: APP_ICON_MAP[appId] as string }))
}
function goalIcon(icon: string): string {
const icons: Record<string, string> = {
shop: '🏪',
payments: '⚡',
photos: '📸',
files: '📁',
lightning: '⚡',
identity: '🔑',
backup: '💾',
}
return icons[icon] || '📦'
}
function statusLabel(goalId: string): string {
const status = goalStatuses[goalId]
if (status === 'completed') return t('easyHome.done')
if (status === 'in-progress') return t('easyHome.inProgress')
return t('easyHome.start')
}
function statusBadgeClass(goalId: string): string {
const status = goalStatuses[goalId]
if (status === 'completed') return 'goal-status-badge-completed'
if (status === 'in-progress') return 'goal-status-badge-in-progress'
return 'goal-status-badge-not-started'
}
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<span class="text-3xl">{{ icon }}</span>
</div>
<h3 class="text-lg font-semibold text-white/80 mb-2">{{ title }}</h3>
<p class="text-sm text-white/50 max-w-sm mb-6">{{ description }}</p>
<button
v-if="actionLabel"
@click="$emit('action')"
class="glass-button px-5 py-2.5 rounded-lg text-sm font-medium"
>
{{ actionLabel }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
icon?: string
title: string
description: string
actionLabel?: string
}>()
defineEmits<{
action: []
}>()
</script>
@@ -0,0 +1,75 @@
<template>
<!-- z-3600: this consent prompt can be triggered from INSIDE another
modal (e.g. Wallet Settings Channels funding tx), so it must sit
above the standard modal layer (3000) but below the app overlay (4000). -->
<BaseModal
:show="!!explorer.pendingTx.value"
title="Open on an external explorer?"
max-width="max-w-md"
z-index="z-[3600]"
@close="explorer.cancelPending()"
>
<!-- Same visual language as the uninstall keep-your-data warning: amber
caution card, plain words about exactly what is shared, explicit
choice never a silent hand-off to a third-party server. -->
<div class="p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/90 text-sm">
<p class="font-medium mb-1"> This node doesn't run its own Mempool explorer</p>
<p class="text-amber-200/75 text-xs leading-relaxed">
(Pruned Bitcoin nodes can't index the full chain.) Your transaction will open on
<span class="font-medium">another node's mempool</span> — that server's operator can see
which transaction you looked up, along with your IP address. Use a server you trust,
or your own mempool instance if you have one elsewhere.
</p>
</div>
<div class="mt-4">
<label class="block text-sm text-white/80 mb-1">Explorer to use</label>
<input
v-model="url"
:placeholder="EXPLORER_PLACEHOLDER"
spellcheck="false"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
/>
<p class="text-[11px] text-white/40 mt-1">
Defaults to tx1138.com. Any Mempool-compatible instance works you can change this
any time in Settings System.
</p>
</div>
<label class="flex items-center gap-2 mt-4 text-sm text-white/70 cursor-pointer select-none">
<input type="checkbox" v-model="dontAskAgain" class="accent-orange-400" />
Remember my choice and don't ask again
</label>
<div class="flex gap-2 mt-6">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="explorer.cancelPending()">
Cancel
</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="explorer.confirmPending(url, dontAskAgain)"
>
Open Explorer
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const explorer = useTxExplorer()
const url = ref(explorer.prefs.value.url)
const dontAskAgain = ref(false)
// Re-seed the input each time the modal opens (prefs may have changed in
// Settings since the last time).
watch(explorer.pendingTx, (tx) => {
if (tx) {
url.value = explorer.prefs.value.url
dontAskAgain.value = false
}
})
</script>
@@ -0,0 +1,141 @@
<template>
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="audioPlayer.currentName.value"
ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar"
>
<!-- Progress bar (clickable) -->
<div
class="h-1 bg-white/10 cursor-pointer"
@click="onProgressClick"
>
<div
class="h-full bg-orange-500 transition-all duration-200"
:style="{ width: audioPlayer.progress.value + '%' }"
/>
</div>
<div class="flex items-center gap-3 px-4 py-2.5">
<!-- Play/Pause -->
<button
class="flex-shrink-0 w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
@click="togglePlay"
>
<svg v-if="audioPlayer.loading.value" class="w-5 h-5 animate-spin text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<svg v-else-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</button>
<!-- Track info -->
<div class="flex-1 min-w-0">
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
<p v-else class="text-sm font-medium text-white/90 truncate">{{ audioPlayer.currentName.value }}</p>
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
</div>
<!-- Close -->
<button
class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center transition-colors"
@click="audioPlayer.stop()"
>
<svg class="w-4 h-4 text-white/60" 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>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const audioPlayer = useAudioPlayer()
const barEl = ref<HTMLElement | null>(null)
// Publish the player's height as a CSS variable so page scroll containers can
// reserve space for it (the same mechanism the mobile tab bar uses). This is
// what pushes the rest of the site up instead of letting the fixed bar overlap
// and block the bottom controls — on desktop AND mobile, on every page.
function setPlayerHeightVar() {
if (typeof document === 'undefined') return
const h = barEl.value?.offsetHeight || 60
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
document.documentElement.classList.add('audio-active')
}
function clearPlayerHeightVar() {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--audio-player-height', '0px')
document.documentElement.classList.remove('audio-active')
}
watch(() => audioPlayer.currentName.value, (name) => {
if (name) {
nextTick(setPlayerHeightVar)
} else {
clearPlayerHeightVar()
}
}, { immediate: true })
onBeforeUnmount(clearPlayerHeightVar)
function togglePlay() {
if (audioPlayer.playing.value) {
audioPlayer.pause()
} else if (audioPlayer.currentSrc.value) {
audioPlayer.play(audioPlayer.currentSrc.value, audioPlayer.currentName.value)
}
}
function onProgressClick(e: MouseEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const time = ratio * audioPlayer.duration.value
audioPlayer.seek(time)
}
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.audio-player-bar {
/* Sit directly above the mobile tab bar (its height is published as
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
resolves to 0px and the bar docks flush to the bottom of the viewport. */
bottom: var(--mobile-tab-bar-height, 0px);
background: rgba(15, 15, 15, 0.55);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border-top: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.3);
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>
@@ -0,0 +1,34 @@
<template>
<BaseModal :show="show" :title="title" max-width="max-w-lg" content-class="max-h-[80vh] overflow-y-auto" @close="$emit('close')">
<div class="text-white/80 prose prose-invert max-w-none">
<p class="whitespace-pre-wrap">{{ content }}</p>
</div>
<div v-if="relatedPath" class="mt-4">
<router-link
:to="relatedPath"
class="inline-flex items-center gap-2 px-4 py-2 glass-button rounded-lg text-sm font-medium"
@click="$emit('close')"
>
Go to related page
<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="M9 5l7 7-7 7" />
</svg>
</router-link>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import BaseModal from '@/components/BaseModal.vue'
defineProps<{
show: boolean
title: string
content: string
relatedPath?: string
}>()
defineEmits<{
close: []
}>()
</script>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="relative" ref="pickerRef">
<button
@click="isOpen = !isOpen"
class="w-full flex items-center gap-3 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white hover:bg-white/10 transition-colors"
>
<!-- Selected Identity -->
<div v-if="selectedIdentity" class="flex items-center gap-2 flex-1 min-w-0">
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(selectedIdentity.purpose)">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span class="truncate">{{ selectedIdentity.name }}</span>
<span class="text-white/40 text-xs font-mono truncate">{{ truncateDid(selectedIdentity.did) }}</span>
</div>
<div v-else class="flex-1 text-white/50">Select identity...</div>
<!-- Chevron -->
<svg class="w-4 h-4 text-white/40 shrink-0 transition-transform" :class="{ 'rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="content-fade">
<div v-if="isOpen" class="absolute left-0 right-0 mt-1 z-20 glass-card p-1 rounded-lg max-h-48 overflow-y-auto">
<div v-if="loading" class="p-3 text-center text-white/50 text-sm">Loading...</div>
<div v-else-if="identities.length === 0" class="p-3 text-center text-white/50 text-sm">No identities</div>
<button
v-for="id in identities"
:key="id.id"
@click="selectIdentity(id)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/80 hover:bg-white/10 transition-colors"
:class="{ 'bg-white/10': modelValue === id.id }"
>
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(id.purpose)">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-1">
<span class="truncate">{{ id.name }}</span>
<span v-if="id.is_default" class="text-yellow-400 text-xs">&#9733;</span>
</div>
<p class="text-white/40 text-xs font-mono truncate">{{ truncateDid(id.did) }}</p>
</div>
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { rpcClient } from '@/api/rpc-client'
interface Identity {
id: string
name: string
purpose: string
pubkey: string
did: string
is_default: boolean
}
const props = defineProps<{
modelValue?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', id: string): void
(e: 'select', identity: Identity): void
}>()
const isOpen = ref(false)
const loading = ref(false)
const identities = ref<Identity[]>([])
const pickerRef = ref<HTMLElement | null>(null)
const selectedIdentity = computed(() =>
identities.value.find(i => i.id === props.modelValue)
)
function purposeColor(purpose: string): string {
switch (purpose) {
case 'personal': return 'bg-blue-500/20 text-blue-400'
case 'business': return 'bg-orange-500/20 text-orange-400'
case 'anonymous': return 'bg-purple-500/20 text-purple-400'
default: return 'bg-white/10 text-white/60'
}
}
function truncateDid(did: string): string {
if (did.length <= 30) return did
return did.slice(0, 18) + '...' + did.slice(-8)
}
function selectIdentity(id: Identity) {
emit('update:modelValue', id.id)
emit('select', id)
isOpen.value = false
}
function onClickOutside(e: MouseEvent) {
if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
async function loadIdentities() {
loading.value = true
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
// Auto-select default if no value set
if (!props.modelValue) {
const defaultId = identities.value.find(i => i.is_default)
if (defaultId) {
emit('update:modelValue', defaultId.id)
emit('select', defaultId)
}
}
} catch {
identities.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadIdentities()
document.addEventListener('pointerdown', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onClickOutside)
})
</script>
@@ -0,0 +1,120 @@
<template>
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
<div class="flex items-center gap-4 mb-5 -mt-2">
<img
v-if="app?.icon"
:src="app.icon"
:alt="app?.title || ''"
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
/>
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<h3 class="text-xl font-semibold text-white leading-snug">
{{ t('marketplace.installModalTitle', { name: app?.title || appId }) }}
</h3>
</div>
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
<div v-else class="space-y-2">
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<select
v-model="selected"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
</select>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div>
<template #footer>
<div class="flex gap-2 mt-6">
<button
type="button"
class="flex-1 glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-semibold py-2.5"
:disabled="loading || !selected"
@click="confirm"
>
{{ t('common.install') }}
</button>
<button
type="button"
class="rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium px-5 py-2.5 transition-colors"
@click="emit('close')"
>
{{ t('common.cancel') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from './BaseModal.vue'
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
import { displayVersion } from '@/utils/version'
const props = defineProps<{
show: boolean
appId: string
app: { id: string; title?: string; icon?: string } | null
}>()
const emit = defineEmits<{
close: []
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
confirm: [version: string]
}>()
const { t } = useI18n()
const loading = ref(false)
const versions = ref<CatalogVersionInfo[]>([])
const selected = ref('')
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
function optionLabel(v: CatalogVersionInfo): string {
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
let label = displayVersion(v.version)
if (v.deprecated) label += ' (deprecated)'
if (v.eol) label += ` · EOL ${v.eol}`
return label
}
async function load() {
loading.value = true
versions.value = []
selected.value = ''
try {
const info = await rpcClient.getPackageVersions(props.appId)
// catalog_versions() returns the list default(=latest)-first, so versions[0]
// is the latest — pre-select it.
versions.value = info.versions || []
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
} catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed.
selected.value = 'latest'
} finally {
loading.value = false
}
}
function confirm() {
if (!selected.value) return
emit('confirm', selected.value)
}
watch(
() => props.show,
(open) => {
if (open) void load()
},
{ immediate: true },
)
</script>
@@ -0,0 +1,509 @@
<template>
<div>
<!-- Liquidity Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6" :class="{ 'md:gap-3 mb-4': compact }">
<div class="glass-card p-4" :class="{ 'p-3 bg-white/5': compact }">
<p class="text-white/60 text-sm mb-1">Total Outbound</p>
<p class="text-white text-xl font-bold" :class="{ 'text-base': compact }">{{ formatSats(summary.total_outbound) }}</p>
</div>
<div class="glass-card p-4" :class="{ 'p-3 bg-white/5': compact }">
<p class="text-white/60 text-sm mb-1">Total Inbound</p>
<p class="text-white text-xl font-bold" :class="{ 'text-base': compact }">{{ formatSats(summary.total_inbound) }}</p>
</div>
<div class="glass-card p-4" :class="{ 'p-3 bg-white/5': compact }">
<p class="text-white/60 text-sm mb-1">Channels</p>
<p class="text-white text-xl font-bold" :class="{ 'text-base': compact }">{{ channels.length }}</p>
</div>
</div>
<!-- Zeus channel suggestion -->
<div class="glass-card p-4 mb-4 border border-orange-500/25">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<img
src="/assets/img/app-icons/zeus.webp"
alt="Zeus"
class="w-12 h-12 rounded-xl shrink-0 border border-white/10"
/>
<div class="flex-1 min-w-0">
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
<p class="text-white/55 text-xs leading-relaxed">
Open a channel with Zeus Olympus node and start sending and receiving Lightning
payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.
</p>
</div>
<div class="flex sm:flex-col items-center gap-2 shrink-0">
<button
@click="openZeusChannel"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap"
>
Open Channel
</button>
<a
href="https://zeusln.com"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-orange-400/80 hover:text-orange-300 whitespace-nowrap"
>Get Zeus </a>
</div>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Open Channel
</button>
</div>
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">Loading channels...</p>
</div>
<!-- Error -->
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-white/70 mb-2">No channels yet</p>
<p class="text-white/50 text-sm">Open a channel to start sending and receiving Lightning payments.</p>
</div>
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" 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>
Refreshing channels...
</div>
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ error }}
</div>
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
:class="{ 'bg-white/5': compact }"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': channelStatus(ch) === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
</div>
<button
v-if="channelStatus(ch) !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
Close
</button>
</div>
<!-- Peer -->
<p class="text-white/50 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<!-- Capacity Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-white/60 mb-1">
<span>Local: {{ formatSats(ch.local_balance) }}</span>
<span>Remote: {{ formatSats(ch.remote_balance) }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full overflow-hidden flex">
<div
class="bg-blue-400 h-full transition-all"
:style="{ width: capacityPercent(ch.local_balance, ch.capacity) + '%' }"
></div>
<div
class="bg-orange-400 h-full transition-all"
:style="{ width: capacityPercent(ch.remote_balance, ch.capacity) + '%' }"
></div>
</div>
<p class="text-white/40 text-xs mt-1 text-center">
Capacity: {{ formatSats(ch.capacity) }}
</p>
</div>
<!-- Funding tx -->
<div v-if="fundingTxid(ch)" class="flex justify-end">
<button
@click="openInMempool(fundingTxid(ch))"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="fundingTxid(ch)"
>
Funding tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Peer URI</label>
<input
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input
v-model.number="openForm.amount"
type="number"
min="20000"
placeholder="100000"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
<!-- Fee selection -->
<div>
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="openForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="openForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="openForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === openForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="openForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="openForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one sats per vByte takes precedence when both are set</p>
</div>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="openForm.private" type="checkbox" class="accent-blue-500" />
<span class="text-white/60 text-sm">Private channel (unannounced)</span>
</label>
<p class="text-white/40 text-xs mt-1">Some nodes (e.g. LSPs like Olympus) only accept unannounced channels</p>
</div>
</div>
<!-- "Still starting" is a wait-a-moment notice, not a failure show
it as calm amber info instead of the red error treatment. -->
<div
v-if="openError"
class="mt-3"
:class="isStartupNotice(openError)
? 'p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/90'
: 'alert-error'"
>
<p class="text-xs">
<span v-if="isStartupNotice(openError)" class="mr-1"></span>{{ openError }}
</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showOpenModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>()
interface Channel {
chan_id: string
remote_pubkey: string
capacity: number
local_balance: number
remote_balance: number
active: boolean
status?: string
channel_point?: string
}
/** Status with a fallback derived from `active` for backends that omit it */
function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' },
]
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats.
const OLYMPUS_PEER_URI =
'031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735'
const showOpenModal = ref(false)
const defaultOpenForm = () => ({
peerUri: '',
amount: 100000,
private: false,
feePreset: 'standard' as FeePreset,
customConfTarget: null as number | null,
customSatPerVbyte: null as number | null,
})
/** Prefill the open-channel modal for a Zeus (Olympus) channel */
function openZeusChannel() {
openForm.value = {
...defaultOpenForm(),
peerUri: OLYMPUS_PEER_URI,
amount: 150000,
// Olympus only accepts unannounced channels
private: true,
}
openError.value = null
showOpenModal.value = true
}
const openForm = ref(defaultOpenForm())
const openingChannel = ref(false)
const openError = ref<string | null>(null)
/** LND's transient post-unlock state ("still finishing its startup") is a
* wait-a-moment notice, not a failure — the template styles it amber. */
function isStartupNotice(msg: string | null): boolean {
if (!msg) return false
const m = msg.toLowerCase()
return m.includes('still finishing its startup') || m.includes('in the process of starting')
}
const closeTarget = ref<Channel | null>(null)
const closingChannel = ref(false)
const closeError = ref<string | null>(null)
function formatSats(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M sats`
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k sats`
return `${sats} sats`
}
function fundingTxid(ch: Channel): string {
const txid = ch.channel_point?.split(':')[0] || ''
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
}
const txExplorer = useTxExplorer()
function openInMempool(txid: string) {
if (!txid) return
// Same routing as every other tx link (missed in the first pass —
// 2026-07-22): local Mempool app when it's running, otherwise the saved
// external explorer, with the first-time consent modal setting it up and
// then opening the tx.
txExplorer.openTx(txid)
}
function capacityPercent(amount: number, capacity: number): number {
if (capacity <= 0) return 0
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
}
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
const form = openForm.value
if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
openError.value = 'Custom fee requires target confirmations or sats per vByte'
return null
}
async function openChannel() {
if (openingChannel.value) return
openError.value = null
const uri = openForm.value.peerUri.trim()
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
const fee = feeParams()
if (!fee) return
const parts = uri.split('@')
const pubkey = parts[0]
const address = parts[1] || undefined
openingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount, private: openForm.value.private, ...fee },
// Server may wait up to 35s for a synchronous peer connect before opening
timeout: 60000,
})
showOpenModal.value = false
openForm.value = defaultOpenForm()
await loadChannels()
} catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel'
} finally {
openingChannel.value = false
}
}
function confirmClose(ch: Channel) {
closeTarget.value = ch
closeError.value = null
}
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
})
closeTarget.value = null
await loadChannels()
} catch (err: unknown) {
closeError.value = err instanceof Error ? err.message : 'Failed to close channel'
} finally {
closingChannel.value = false
}
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>
+172
View File
@@ -0,0 +1,172 @@
<template>
<canvas
ref="canvasRef"
class="monitoring-chart"
:width="width"
:height="height"
></canvas>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
export interface ChartDataset {
label: string
data: number[]
color: string
}
const props = withDefaults(
defineProps<{
datasets: ChartDataset[]
labels?: string[]
width?: number
height?: number
yMax?: number
yLabel?: string
showGrid?: boolean
}>(),
{
width: 400,
height: 180,
showGrid: true,
},
)
const canvasRef = ref<HTMLCanvasElement | null>(null)
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = props.width * dpr
canvas.height = props.height * dpr
canvas.style.width = `${props.width}px`
canvas.style.height = `${props.height}px`
ctx.scale(dpr, dpr)
const w = props.width
const h = props.height
const pad = { top: 10, right: 12, bottom: 24, left: 44 }
const plotW = w - pad.left - pad.right
const plotH = h - pad.top - pad.bottom
// Clear
ctx.clearRect(0, 0, w, h)
if (!props.datasets.length || !props.datasets[0]?.data.length) {
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.font = '12px system-ui'
ctx.textAlign = 'center'
ctx.fillText('No data yet', w / 2, h / 2)
return
}
// Compute y range
let yMax = props.yMax ?? 0
if (!yMax) {
for (const ds of props.datasets) {
for (const v of ds.data) {
if (v > yMax) yMax = v
}
}
yMax = yMax * 1.1 || 1
}
const maxPoints = Math.max(...props.datasets.map((d) => d.data.length))
// Grid lines
if (props.showGrid) {
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
ctx.lineWidth = 1
const gridCount = 4
for (let i = 0; i <= gridCount; i++) {
const y = pad.top + (plotH / gridCount) * i
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(pad.left + plotW, y)
ctx.stroke()
}
// Y-axis labels
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '10px system-ui'
ctx.textAlign = 'right'
for (let i = 0; i <= gridCount; i++) {
const y = pad.top + (plotH / gridCount) * i
const val = yMax - (yMax / gridCount) * i
ctx.fillText(formatValue(val), pad.left - 6, y + 3)
}
}
// Draw each dataset
for (const ds of props.datasets) {
if (!ds.data.length) continue
ctx.strokeStyle = ds.color
ctx.lineWidth = 1.5
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.beginPath()
for (let i = 0; i < ds.data.length; i++) {
const x = pad.left + (i / Math.max(maxPoints - 1, 1)) * plotW
const y = pad.top + plotH - (ds.data[i]! / yMax) * plotH
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
// Area fill
ctx.globalAlpha = 0.08
ctx.fillStyle = ds.color
ctx.lineTo(pad.left + ((ds.data.length - 1) / Math.max(maxPoints - 1, 1)) * plotW, pad.top + plotH)
ctx.lineTo(pad.left, pad.top + plotH)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1.0
}
// X-axis labels (first, middle, last)
if (props.labels && props.labels.length > 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '10px system-ui'
ctx.textAlign = 'center'
const indices = [0, Math.floor(props.labels.length / 2), props.labels.length - 1]
for (const idx of indices) {
if (idx >= 0 && idx < props.labels.length) {
const x = pad.left + (idx / Math.max(props.labels.length - 1, 1)) * plotW
ctx.fillText(props.labels[idx]!, pad.left + plotW + pad.right > w ? x : x, h - 6)
}
}
}
}
function formatValue(val: number): string {
if (val >= 1_000_000_000) return `${(val / 1_000_000_000).toFixed(1)}G`
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`
return val.toFixed(val < 10 ? 1 : 0)
}
watch(
() => [props.datasets, props.labels, props.width, props.height],
() => draw(),
{ deep: true },
)
onMounted(() => {
draw()
window.addEventListener('resize', draw)
})
onUnmounted(() => {
window.removeEventListener('resize', draw)
})
</script>
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
// Global "back up your Lightning seed" nudge. The full backup flow lives on
// the LND app-details page (views/appDetails/LndSeedBackup.vue); this banner
// just surfaces it proactively once a wallet seed exists and hasn't been
// acknowledged as backed up yet.
//
// lnd.seed-backup-status → { available, acknowledged }:
// available=false → no captured seed (LND not installed / legacy wallet) → stay quiet
// acknowledged=true → user already confirmed the backup → stay quiet
// available && !acknowledged → prompt
const SNOOZE_KEY = 'lnd-seed-backup-prompt-snooze-until'
const SNOOZE_MS = 24 * 60 * 60 * 1000
const POLL_MS = 5 * 60 * 1000
const LND_DETAILS_PATH = '/dashboard/apps/lnd'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const needsBackup = ref(false)
const snoozedUntil = ref(readSnooze())
let pollTimer: ReturnType<typeof setInterval> | null = null
// Once the backend says acked (or there is no seed to back up), stop polling
// for the rest of the session — nothing left for this banner to do.
let settled = false
function readSnooze(): number {
try {
const raw = localStorage.getItem(SNOOZE_KEY)
const ts = raw ? Number(raw) : 0
return Number.isFinite(ts) ? ts : 0
} catch {
return 0
}
}
function isAuthed(): boolean {
// Same guard as useMessageToast — never poll unauthenticated (background
// 401s on the login page cause refresh loops).
try { return localStorage.getItem('neode-auth') === 'true' } catch { return false }
}
async function pollStatus() {
if (settled || !isAuthed()) return
try {
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
method: 'lnd.seed-backup-status',
timeout: 5000,
})
if (!res.available || res.acknowledged) {
needsBackup.value = false
settled = true
stopPolling()
} else {
needsBackup.value = true
}
} catch {
// Transient RPC failure — keep the current state, retry on next tick.
}
}
function startPolling() {
if (pollTimer || settled) return
void pollStatus()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') void pollStatus()
}, POLL_MS)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
watch(() => appStore.isAuthenticated, (authed) => {
if (authed) startPolling()
else {
stopPolling()
needsBackup.value = false
}
}, { immediate: true })
// The LND details page renders its own prominent backup card — hide the
// banner there, and re-check status when the user navigates away so an ack
// made on that page hides the banner immediately (not after the next tick).
const onLndPage = computed(() => route.path === LND_DETAILS_PATH)
watch(onLndPage, (now, was) => {
if (was && !now && !settled && isAuthed()) void pollStatus()
})
const show = computed(() =>
needsBackup.value && !onLndPage.value && Date.now() >= snoozedUntil.value,
)
function backUpNow() {
// seed-backup=1 makes the LND page open the reveal flow immediately —
// landing on the app page with the card below the fold read as
// "clicking the notification did nothing".
router.push({ path: LND_DETAILS_PATH, query: { 'seed-backup': '1' } }).catch(() => {})
}
function snooze() {
const until = Date.now() + SNOOZE_MS
snoozedUntil.value = until
try { localStorage.setItem(SNOOZE_KEY, String(until)) } catch { /* noop */ }
}
onBeforeUnmount(stopPolling)
</script>
<template>
<Teleport to="body">
<Transition name="seed-banner">
<div
v-if="show"
role="alert"
class="fixed bottom-4 left-4 right-4 z-[95] mx-auto w-auto max-w-lg rounded-xl border border-orange-400/30 p-4 seed-banner-glass md:left-auto md:right-6 md:bottom-6"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">Back up your Lightning wallet seed</p>
<p class="mt-0.5 text-sm text-white/70">
Your Lightning wallet has a recovery seed that hasn't been backed up yet.
Write it down now so your funds survive a disk failure.
</p>
<div class="mt-3 flex gap-2">
<button
type="button"
class="glass-button glass-button-warning rounded-lg px-4 py-2 text-sm font-medium"
@click="backUpNow"
>Back up now</button>
<button
type="button"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="snooze"
>Later</button>
</div>
</div>
<button
type="button"
aria-label="Dismiss for now"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
@click="snooze"
>
<svg class="h-4 w-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>
</Transition>
</Teleport>
</template>
<style scoped>
.seed-banner-glass {
background: rgba(15, 15, 20, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.seed-banner-enter-active,
.seed-banner-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.seed-banner-enter-from,
.seed-banner-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+666
View File
@@ -0,0 +1,666 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import type { NodePosition } from '@/stores/mesh'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const mesh = useMeshStore()
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
const markersLayer = ref<L.LayerGroup | null>(null)
const linesLayer = ref<L.LayerGroup | null>(null)
// Whether we have any position data to show
const hasPositions = computed(() => mesh.nodePositions.size > 0 || mesh.federatedPositions.size > 0)
// Location sharing state
const sharingLocation = ref(false)
const locationSource = ref<'browser' | 'device'>('browser')
const locationError = ref('')
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
let geoWatchId: number | null = null
function toggleLocationSharing() {
if (sharingLocation.value) {
stopSharing()
} else {
startSharing()
}
}
function switchSource(source: 'browser' | 'device') {
locationSource.value = source
if (sharingLocation.value) {
stopSharing()
startSharing()
}
}
function startSharing() {
locationError.value = ''
if (locationSource.value === 'browser') {
if (!navigator.geolocation) {
locationError.value = 'Geolocation not supported'
return
}
geoWatchId = navigator.geolocation.watchPosition(
(pos) => {
mesh.updateSelfPosition(pos.coords.latitude, pos.coords.longitude, 'This Node')
sharingLocation.value = true
locationError.value = ''
},
(err) => {
locationError.value = err.code === 1 ? 'Location permission denied' : err.message
sharingLocation.value = false
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 },
)
sharingLocation.value = true
} else {
// Device GPS — position data comes from deadman/mesh GPS module
sharingLocation.value = true
}
}
function stopSharing() {
if (geoWatchId !== null) {
navigator.geolocation.clearWatch(geoWatchId)
geoWatchId = null
}
sharingLocation.value = false
mesh.nodePositions.delete(-1)
}
function createMarkerIcon(type: 'self' | 'online' | 'offline'): L.DivIcon {
const colorMap = {
self: { bg: '#fb923c', border: '#f59e0b', shadow: 'rgba(251,146,60,0.5)' },
online: { bg: '#4ade80', border: '#22c55e', shadow: 'rgba(74,222,128,0.4)' },
offline: { bg: '#6b7280', border: '#4b5563', shadow: 'rgba(107,114,128,0.3)' },
}
const c = colorMap[type]
const size = type === 'self' ? 16 : 12
const pulse = type === 'self'
? `<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:${size + 12}px;height:${size + 12}px;border-radius:50%;background:${c.shadow};animation:mesh-map-pulse 2s infinite;"></div>`
: ''
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size + 12, size + 12],
iconAnchor: [(size + 12) / 2, (size + 12) / 2],
popupAnchor: [0, -(size / 2 + 6)],
html: `
${pulse}
<div style="
width:${size}px;height:${size}px;
border-radius:50%;
background:${c.bg};
border:2px solid ${c.border};
box-shadow:0 0 8px ${c.shadow};
position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%);
z-index:2;
"></div>
`,
})
}
/// Marker for an Archipelago node (federation peer) — the little Archy logo
/// in a glowing badge, distinct from the plain colored dots used for raw
/// LoRa mesh-radio peers. Trusted nodes get the warm orange ring/glow the
/// rest of the map already uses for "this is us/ours"; Observer nodes get a
/// cooler blue so the trust boundary is visible at a glance.
function createFederatedMarkerIcon(trusted: boolean): L.DivIcon {
const ring = trusted ? '#fb923c' : '#38bdf8'
const glow = trusted ? 'rgba(251,146,60,0.55)' : 'rgba(56,189,248,0.5)'
const size = 30
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 2)],
html: `
<div style="
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:${size + 14}px; height:${size + 14}px; border-radius:50%;
background:${glow}; animation:mesh-map-pulse 2.4s infinite;
"></div>
<div style="
width:${size}px; height:${size}px; border-radius:9px;
background:#0b0d14; border:2px solid ${ring};
box-shadow:0 0 10px ${glow}, 0 2px 6px rgba(0,0,0,0.5);
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
display:flex; align-items:center; justify-content:center;
z-index:3; overflow:hidden;
">
<img src="/assets/icon/apple-touch-icon-180x180-v2.png" width="${size - 6}" height="${size - 6}"
style="border-radius:6px;object-fit:cover;" alt="" />
</div>
`,
})
}
function buildFederatedPopupContent(name: string, onion: string, trusted: boolean): string {
const badge = trusted
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">TRUSTED</span>'
: '<span style="display:inline-block;background:rgba(56,189,248,0.2);color:#38bdf8;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">OBSERVER</span>'
const onionShort = onion.length > 20 ? `${onion.slice(0, 10)}...${onion.slice(-6)}` : onion
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:170px;">
<div style="display:flex;align-items:center;gap:6px;font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
📡 ${name}${badge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:6px;word-break:break-all;">
${onionShort}
</div>
<div style="font-size:0.78rem;color:rgba(255,255,255,0.6);">Archipelago node</div>
</div>
`
}
function getSignalBars(rssi: number | null): string {
if (rssi === null) return 'Unknown'
if (rssi >= -70) return 'Strong'
if (rssi >= -90) return 'Good'
if (rssi >= -110) return 'Weak'
return 'Very Weak'
}
function formatLastHeard(timestamp: string): string {
const now = Date.now()
const then = new Date(timestamp).getTime()
const diffSecs = Math.floor((now - then) / 1000)
if (diffSecs < 60) return 'Just now'
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`
return `${Math.floor(diffSecs / 86400)}d ago`
}
function truncatePubkey(pubkey: string | null): string {
if (!pubkey) return 'No pubkey'
if (pubkey.length <= 16) return pubkey
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
function buildPopupContent(
name: string,
pubkey: string | null,
rssi: number | null,
lastHeard: string,
hops: number,
isSelf: boolean,
): string {
const signal = getSignalBars(rssi)
const heard = formatLastHeard(lastHeard)
const truncPk = truncatePubkey(pubkey)
const selfBadge = isSelf
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">THIS NODE</span>'
: ''
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:160px;">
<div style="font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
${name}${selfBadge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:8px;word-break:break-all;">
${truncPk}
</div>
<div style="display:flex;flex-direction:column;gap:3px;font-size:0.78rem;">
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Signal: <span style="color:${rssi !== null && rssi >= -90 ? '#4ade80' : '#fbbf24'};">${signal}</span>${rssi !== null ? ` (${rssi} dBm)` : ''}</div>` : ''}
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Hops: <span style="color:rgba(255,255,255,0.9);">${hops}</span></div>` : ''}
<div style="color:rgba(255,255,255,0.7);">Last heard: <span style="color:rgba(255,255,255,0.9);">${heard}</span></div>
</div>
</div>
`
}
function initMap() {
if (!mapContainer.value || map) return
const el = mapContainer.value
const rect = el.getBoundingClientRect()
// If container has no height yet, retry
if (rect.height < 10) {
setTimeout(initMap, 150)
return
}
map = L.map(el, {
zoomControl: true,
attributionControl: true,
center: [30, 0],
zoom: 3,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19,
detectRetina: true,
}).addTo(map)
markersLayer.value = L.layerGroup().addTo(map)
linesLayer.value = L.layerGroup().addTo(map)
// Give Leaflet a frame to measure, then invalidate
requestAnimationFrame(() => {
if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
// Style attribution for dark theme
const attrib = el.querySelector('.leaflet-control-attribution')
if (attrib instanceof HTMLElement) {
attrib.style.background = 'rgba(0,0,0,0.6)'
attrib.style.color = 'rgba(255,255,255,0.4)'
attrib.style.fontSize = '0.65rem'
}
updateMarkers()
}
function updateMarkers() {
if (!map || !markersLayer.value || !linesLayer.value) return
markersLayer.value.clearLayers()
linesLayer.value.clearLayers()
const positions = mesh.nodePositions
const fedPositions = mesh.federatedPositions
if (positions.size === 0 && fedPositions.size === 0) return
const bounds: L.LatLngExpression[] = []
const selfPos = positions.get(-1)
// Find which contact_ids are in the peers list (for online status)
const peerMap = new Map(mesh.peers.map(p => [p.contact_id, p]))
positions.forEach((pos: NodePosition, contactId: number) => {
const isSelf = contactId === -1
const peer = peerMap.get(contactId)
const isOnline = isSelf || !!peer
const marker = L.marker([pos.lat, pos.lng], {
icon: createMarkerIcon(isSelf ? 'self' : isOnline ? 'online' : 'offline'),
})
const name = isSelf
? (mesh.status?.self_advert_name ?? 'This Node')
: (peer?.advert_name ?? pos.label ?? `Node ${contactId}`)
const pubkey = isSelf ? null : (peer?.pubkey_hex ?? null)
const rssi = peer?.rssi ?? null
const lastHeard = isSelf ? new Date().toISOString() : (peer?.last_heard ?? pos.timestamp)
const hops = peer?.hops ?? 0
marker.bindPopup(buildPopupContent(name, pubkey, rssi, lastHeard, hops, isSelf), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([pos.lat, pos.lng])
// Draw dashed line from self to each connected peer
if (!isSelf && selfPos) {
const line = L.polyline(
[[selfPos.lat, selfPos.lng], [pos.lat, pos.lng]],
{
color: isOnline ? 'rgba(74,222,128,0.4)' : 'rgba(107,114,128,0.3)',
weight: 1.5,
dashArray: '6, 8',
opacity: 0.7,
},
)
linesLayer.value!.addLayer(line)
}
})
// Archipelago nodes (federation peers who opted into location sharing) —
// own logo marker, no signal/hops (that's a radio-peer concept).
fedPositions.forEach((fed) => {
const marker = L.marker([fed.lat, fed.lng], {
icon: createFederatedMarkerIcon(fed.trusted),
zIndexOffset: 500,
})
marker.bindPopup(buildFederatedPopupContent(fed.name ?? 'Archipelago Node', fed.onion, fed.trusted), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([fed.lat, fed.lng])
})
// Fit map to show all markers
if (bounds.length > 1) {
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 })
} else if (bounds.length === 1 && bounds[0]) {
map.setView(bounds[0], 12)
}
}
function handleResize() {
if (map) {
map.invalidateSize()
}
}
// Watch for changes in node positions and peers
watch(
() => [mesh.nodePositions.size, mesh.peers.length, mesh.federatedPositions.size],
() => {
updateMarkers()
},
)
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
window.addEventListener('resize', handleResize)
if (mapContainer.value) {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { height } = entry.contentRect
if (!map && height > 10) {
initMap()
} else if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
resizeObserver.observe(mapContainer.value)
}
// Fallback init
setTimeout(initMap, 300)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stopSharing()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (map) {
map.remove()
map = null
}
markersLayer.value = null
linesLayer.value = null
})
</script>
<template>
<div class="mesh-map-outer">
<div ref="mapContainer" class="mesh-map-inner"></div>
<!-- Floating hint when no positions -->
<div v-if="!hasPositions && !sharingLocation" class="mesh-map-hint">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span>{{ locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' }}</span>
</div>
<!-- Location sharing overlay -->
<div class="mesh-map-location-bar">
<div class="mesh-map-location-row">
<svg class="mesh-map-location-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span class="mesh-map-location-label">Share Location</span>
<button
class="mesh-map-toggle"
:class="{ active: sharingLocation }"
role="switch"
:aria-checked="sharingLocation"
@click="toggleLocationSharing"
>
<span class="mesh-map-toggle-knob" />
</button>
</div>
<!-- Source selector (visible when sharing and device GPS available) -->
<div v-if="sharingLocation && hasDeviceGps" class="mesh-map-source-row">
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'browser' }"
@click="switchSource('browser')"
>This Machine</button>
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'device' }"
@click="switchSource('device')"
>Mesh Radio GPS</button>
</div>
<div v-if="locationError" class="mesh-map-location-error">
{{ locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError }}
</div>
</div>
</div>
</template>
<style>
/* Must be unscoped — Leaflet creates DOM nodes dynamically */
/* CRITICAL: Override Tailwind's img { max-width: 100% } which breaks Leaflet tiles */
.mesh-map-inner img {
max-width: none !important;
max-height: none !important;
}
.mesh-map-outer {
width: 100%;
height: 100%;
min-height: 420px;
position: relative;
overflow: hidden;
}
.mesh-map-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a2e;
}
/* Leaflet adds .leaflet-container to the element itself (not a child) */
.mesh-map-inner.leaflet-container {
background: #1a1a2e;
}
.mesh-map-hint {
position: absolute;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 400;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
white-space: nowrap;
pointer-events: none;
}
/* ─── Location sharing overlay ─── */
.mesh-map-location-bar {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 500;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mesh-map-location-row {
display: flex;
align-items: center;
gap: 8px;
}
.mesh-map-location-icon {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.mesh-map-location-label {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
flex: 1;
}
/* Toggle switch */
.mesh-map-toggle {
width: 36px;
height: 20px;
/* The global mobile rule forces buttons to min-height:44px, which stretches
this switch and pushes the knob off-centre. Pin it back to the pill size. */
min-height: 20px !important;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
position: relative;
padding: 0;
flex-shrink: 0;
transition: all 0.25s ease;
}
.mesh-map-toggle.active {
background: rgba(251, 146, 60, 0.35);
border-color: rgba(251, 146, 60, 0.5);
}
.mesh-map-toggle-knob {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
position: absolute;
top: 2px;
left: 2px;
transition: all 0.25s ease;
}
.mesh-map-toggle.active .mesh-map-toggle-knob {
left: 18px;
background: #fb923c;
}
/* Source selector */
.mesh-map-source-row {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 2px;
}
.mesh-map-source-btn {
flex: 1;
padding: 4px 8px;
font-size: 0.7rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.mesh-map-source-btn:hover {
color: rgba(255, 255, 255, 0.7);
}
.mesh-map-source-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.mesh-map-location-error {
font-size: 0.7rem;
color: #ef4444;
}
</style>
<style>
/* Global styles for Leaflet popup theming - must not be scoped */
.mesh-map-popup .leaflet-popup-content-wrapper {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: #fff;
}
.mesh-map-popup .leaflet-popup-tip {
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mesh-map-popup .leaflet-popup-close-button {
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
padding: 4px 8px;
}
.mesh-map-popup .leaflet-popup-close-button:hover {
color: rgba(255, 255, 255, 0.9);
}
/* Marker wrapper reset */
.mesh-map-marker-wrapper {
background: none !important;
border: none !important;
}
/* Pulse animation for self marker */
@keyframes mesh-map-pulse {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
}
/* Dark theme for Leaflet zoom controls */
.mesh-map-inner .leaflet-control-zoom a {
background: rgba(0, 0, 0, 0.7) !important;
color: rgba(255, 255, 255, 0.8) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.mesh-map-inner .leaflet-control-zoom a:hover {
background: rgba(0, 0, 0, 0.85) !important;
color: #fff !important;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<!-- Compact mode: small pill for chat fullscreen -->
<div v-if="compact" class="chat-mode-pill-inner" @click="handleCompactClick">
<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="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="text-xs font-medium">{{ currentLabel }}</span>
</div>
<!-- Full mode switcher -->
<div v-else class="mode-switcher mode-switcher-full">
<button
v-for="m in modes"
:key="m.id"
@click="uiMode.setMode(m.id)"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': uiMode.mode === m.id }"
>
{{ m.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUIModeStore } from '@/stores/uiMode'
import type { UIMode } from '@/types/api'
const props = defineProps<{
compact?: boolean
}>()
const uiMode = useUIModeStore()
const router = useRouter()
const modes: { id: UIMode; label: string }[] = [
{ id: 'easy', label: 'Easy' },
{ id: 'gamer', label: 'Pro' },
]
const currentLabel = computed(() => {
const found = modes.find(m => m.id === uiMode.mode)
return found ? found.label : 'Pro'
})
function handleCompactClick() {
const newMode = uiMode.cycleMode()
router.push(newMode === 'chat' ? '/dashboard/chat' : '/dashboard')
}
</script>
@@ -0,0 +1,322 @@
<template>
<Teleport to="body">
<Transition name="identity-picker">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<!-- Main panel -->
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
:aria-label="`Select identity for ${appName}`"
class="relative z-10 w-full max-w-lg"
>
<!-- Header: screensaver-style glass disc + radial viz ring -->
<div class="relative mb-6 flex flex-col items-center">
<div class="nostr-hero">
<!-- Radial viz segments exact screensaver pattern, 48 bars, #FAFAFA -->
<div class="nostr-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="nostr-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<!-- Glass disc exact logo-gradient-border from screensaver -->
<div class="nostr-glass-border">
<div class="nostr-glass-inner">
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<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" />
</svg>
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
</div>
<div v-else-if="identities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div>
<button
v-for="identity in identities"
:key="identity.id"
type="button"
role="radio"
:aria-checked="selectedId === identity.id"
:aria-label="`Identity: ${identity.name}`"
class="w-full text-left p-3 rounded-lg transition-all duration-200"
:class="selectedId === identity.id
? 'bg-white/10 ring-1 ring-white/20'
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
@click="selectedId = identity.id"
>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
:class="avatarClasses(identity.purpose)"
>
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ identity.name }}</span>
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">default</span>
</div>
<div class="mt-0.5">
<span v-if="identity.nostr_npub" class="text-white/35 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
<span v-else class="text-red-400/60 text-xs">No Nostr key</span>
</div>
</div>
<div class="shrink-0">
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
</div>
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
</div>
</div>
</button>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
Cancel
</button>
<button
@click="confirm"
:disabled="!selectedId || !hasNostrKey"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="selectedId && hasNostrKey
? 'bg-white/10 text-white hover:bg-white/15'
: 'bg-white/[0.03] text-white/40'"
>
Authenticate
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
NIP-07 &middot; SECP256K1 &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { rpcClient } from '@/api/rpc-client'
interface Identity {
id: string
name: string
purpose: string
pubkey: string
did: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
}
const props = defineProps<{
show: boolean
appName: string
}>()
const emit = defineEmits<{
select: [identity: Identity]
cancel: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const identities = ref<Identity[]>([])
const selectedId = ref<string | null>(null)
const loading = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null
})
watch(() => props.show, async (open) => {
if (open) await loadIdentities()
})
onMounted(() => {
if (props.show) loadIdentities()
})
async function loadIdentities() {
loading.value = true
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id
} catch {
identities.value = []
} finally {
loading.value = false
}
}
function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value)
if (selected) emit('select', selected)
}
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
function avatarClasses(purpose: string): string {
switch (purpose) {
case 'business': return 'bg-blue-500/15 text-blue-400'
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
default: return 'bg-white/10 text-white/80'
}
}
</script>
<style scoped>
/* ── Hero container ── */
.nostr-hero {
position: relative;
width: 148px;
height: 148px;
}
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
.nostr-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.nostr-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(-60px);
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
@keyframes seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
}
/* ── Glass disc — exact screensaver logo-gradient-border ── */
.nostr-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.nostr-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Cinema icon — breathing glow ── */
.nostr-cinema-svg {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
animation: cinema-breathe 4s ease-in-out infinite;
}
@keyframes cinema-breathe {
0%, 100% {
opacity: 0.7;
transform: scale(1);
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
}
50% {
opacity: 1;
transform: scale(1.08);
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
}
}
/* ── Modal transitions ── */
.identity-picker-enter-active,
.identity-picker-leave-active {
transition: opacity 0.4s ease;
}
.identity-picker-enter-active > .relative {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.identity-picker-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.identity-picker-enter-from { opacity: 0; }
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.identity-picker-leave-to { opacity: 0; }
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>
@@ -0,0 +1,152 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="deny"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-md w-full relative z-10"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Nostr Signing Request</h3>
<button
@click="deny"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3 mb-6">
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">App</p>
<p class="text-white text-sm font-medium">{{ appName }}</p>
</div>
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Method</p>
<p class="text-white text-sm font-medium">{{ method }}</p>
</div>
<div v-if="contentPreview" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Content</p>
<p class="text-white/80 text-sm font-mono break-all">{{ contentPreview }}</p>
</div>
<div v-if="eventKind !== undefined" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Event Kind</p>
<p class="text-white text-sm font-medium">{{ eventKind }} <span class="text-white/50">({{ eventKindLabel }})</span></p>
</div>
</div>
<label class="flex items-center gap-2 mb-4 cursor-pointer">
<input
v-model="rememberChoice"
type="checkbox"
class="w-4 h-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50"
/>
<span class="text-white/70 text-sm">Remember for this app</span>
</label>
<div class="flex gap-3">
<button @click="deny" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium">
Deny
</button>
<button @click="approve" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30">
Approve
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Contacts',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
9734: 'Zap Request',
9735: 'Zap Receipt',
10002: 'Relay List',
30023: 'Long-form Content',
}
const props = defineProps<{
show: boolean
appName: string
method: string
eventKind?: number
content?: string
}>()
const emit = defineEmits<{
approve: [remember: boolean]
deny: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const rememberChoice = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('deny'))
const contentPreview = computed(() => {
if (!props.content) return ''
return props.content.length > 200 ? props.content.slice(0, 200) + '...' : props.content
})
const eventKindLabel = computed(() => {
if (props.eventKind === undefined) return ''
return EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown'
})
function approve() {
emit('approve', rememberChoice.value)
}
function deny() {
emit('deny')
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>
@@ -0,0 +1,25 @@
<template>
<button
type="button"
data-controller-ignore
class="w-full flex items-center gap-2 text-white/80 hover:text-white transition-colors"
title="Open CLI (F)"
@click="openCLI"
>
<div class="relative shrink-0">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
</div>
<span class="text-xs font-medium">Online</span>
</button>
</template>
<script setup lang="ts">
import { useCLIStore } from '@/stores/cli'
const cliStore = useCLIStore()
function openCLI() {
cliStore.open()
}
</script>
@@ -0,0 +1,92 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="showInstallPrompt"
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-6 md:bottom-6 md:max-w-sm z-[9998]"
>
<div class="glass-card p-4 flex items-center gap-4 shadow-xl">
<img
src="/assets/icon/pwa-192x192-v2.png"
alt="Archipelago"
class="w-14 h-14 rounded-xl shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="text-white font-medium">Install Archipelago</p>
<p class="text-white/70 text-sm">Add to your home screen for quick access</p>
</div>
<div class="flex gap-2 shrink-0">
<button
@click="dismiss"
class="px-3 py-2 text-sm text-white/70 hover:text-white transition-colors"
>
Not now
</button>
<button
@click="install"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Install
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const showInstallPrompt = ref(false)
let deferredPrompt: { prompt: () => Promise<{ outcome: string }> } | null = null
const DISMISS_KEY = 'archipelago_pwa_install_dismissed'
onMounted(() => {
// Don't show in kiosk mode, if already dismissed, or if already installed
if (localStorage.getItem('kiosk') === 'true') return
if (sessionStorage.getItem(DISMISS_KEY) === '1') return
if (window.matchMedia('(display-mode: standalone)').matches) return
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
const handler = (e: Event) => {
e.preventDefault()
deferredPrompt = e as unknown as { prompt: () => Promise<{ outcome: string }> }
showInstallPrompt.value = true
}
window.addEventListener('beforeinstallprompt', handler)
;(window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler = handler
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', (window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler as EventListener)
})
function dismiss() {
showInstallPrompt.value = false
sessionStorage.setItem(DISMISS_KEY, '1')
}
async function install() {
if (!deferredPrompt) return
const result = await deferredPrompt.prompt()
const outcome = result?.outcome ?? 'dismissed'
showInstallPrompt.value = false
deferredPrompt = null
if (outcome === 'accepted') {
sessionStorage.removeItem(DISMISS_KEY)
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<template>
<BaseModal :show="showUpdatePrompt" title="Update Available" z-index="z-[9999]" @close="dismissUpdate">
<p class="text-white/80 mb-6">
A new version of Archipelago is available. Update now to get the latest features and fixes.
</p>
<template #footer>
<div class="flex gap-3 justify-end">
<button
@click="dismissUpdate"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
Later
</button>
<button
@click="handleUpdate"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Update Now
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { IS_DEMO } from '@/composables/useDemoIntro'
const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null
// Reload for a genuine SW update — but never mid-cinematic. The splash
// typing intro and the dashboard-reveal cinematic are the two moments a
// surprise reload reads as "the app just reset itself" (kiosk and demo
// replay the intro on every boot/visit, so an update landing during it
// restarted the whole sequence). Wait until both are over, then reload.
function reloadAfterCinematic() {
const loginTransition = useLoginTransitionStore()
const calm = () =>
document.body.classList.contains('splash-complete') &&
!loginTransition.introCinematicPlaying
if (calm()) {
window.location.reload()
return
}
const poll = setInterval(() => {
if (calm()) {
clearInterval(poll)
window.location.reload()
}
}, 1000)
}
onMounted(() => {
// The public demo has no version to update to — the prompt is noise, and
// both accept-paths end in a reload that replays the demo intro ("the site
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
// waiting worker is safe: this page keeps its complete old cache, and the
// new build activates on the next visit.
if (IS_DEMO) return
// Listen for service worker updates
if ('serviceWorker' in navigator) {
// On the very first visit the page loads with no controlling SW; the
// freshly-installed worker then activates and claims the page
// (autoUpdate → clientsClaim), firing controllerchange. Reloading on
// that first claim caused the fresh-install "loads, then reloads"
// jank (worst on kiosk). Only reload when an existing controller is
// REPLACED — i.e. a genuine update.
let hadController = !!navigator.serviceWorker.controller
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController) {
hadController = true
return
}
reloadAfterCinematic()
})
// Check for updates periodically
const checkForUpdates = async () => {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
await registration.update()
}
}
// Check for updates every 5 minutes
setInterval(checkForUpdates, 5 * 60 * 1000)
// Check when user returns to tab (helps with cached PWA)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkForUpdates()
}
})
// Listen for updatefound event
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration) {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
updateCallback = async () => {
if (newWorker.state === 'installed' && registration.waiting) {
// Skip waiting and activate the new service worker
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
}
}
// The kiosk is unattended — nobody can click "Update Now",
// so it kept running stale bundles forever (stale FIPS state,
// old assets). Auto-apply there; prompt everywhere else.
if (localStorage.getItem('kiosk')) {
updateCallback()
} else {
showUpdatePrompt.value = true
}
}
})
}
})
}
})
}
})
function dismissUpdate() {
showUpdatePrompt.value = false
}
async function handleUpdate() {
if (updateCallback) {
await updateCallback()
}
}
</script>
@@ -0,0 +1,198 @@
<template>
<BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] 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' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : m === 'ecash' ? t('receiveBitcoin.ecash') : 'Ark' }}</button>
</div>
<!-- Lightning -->
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.amountSats') }}</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div>
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</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">{{ t('common.copy') }}</button>
</div>
</div>
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</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">{{ t('common.copy') }}</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</div>
<!-- Ark -->
<div v-if="receiveMethod === 'ark'">
<div v-if="arkAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Ark address</p>
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
</div>
</div>
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 alert-error">{{ 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="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
Scan
</button>
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n()
const props = defineProps<{
show: boolean
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
/** Generate an on-chain address immediately when the modal opens */
autoGenerate?: boolean
}>()
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
void receive()
}
})
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')
const invoiceResult = ref('')
const onchainAddress = ref('')
const arkAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false)
const error = ref('')
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
if (!canvas || !data) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(canvas, prefix ? `${prefix}${data}` : data, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
} catch { /* QR rendering failed silently */ }
}
function close() {
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.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 = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
})
invoiceResult.value = res.payment_request
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
if (!res.address) {
throw new Error('LND did not return a Bitcoin address')
}
onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else if (receiveMethod.value === 'ark') {
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
if (!res.address) throw new Error('barkd did not return an Ark address')
arkAddress.value = res.address
nextTick(() => renderQr(res.address, arkQrCanvas.value))
} else {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
// redeemed at its mint, anything else is reissued as Fedimint notes.
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
})
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashResult.value = res.received_sats != null
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
: t('receiveBitcoin.tokenReceivedSuccess')
emit('received')
}
} catch (err: unknown) {
error.value = receiveMethod.value === 'onchain'
? explainReceiveAddressFailure(err)
: err instanceof Error ? err.message : 'Failed'
} finally {
processing.value = false
}
}
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<Teleport to="body">
<Transition name="screensaver">
<div
v-if="store.isActive"
class="screensaver-container fixed inset-0 z-[3000] bg-black cursor-pointer"
@click="store.deactivate()"
@keydown.escape="store.deactivate()"
>
<!-- ASCII variant (every 3rd activation) -->
<div v-if="store.isAsciiMode" class="screensaver-ascii-content">
<BitcoinFaceAscii />
</div>
<!-- Normal logo with audio viz ring -->
<div v-else class="screensaver-content">
<ScreensaverRing />
<!-- Logo in center -->
<div class="screensaver-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
import { useScreensaverStore } from '@/stores/screensaver'
const store = useScreensaverStore()
// Dismiss on any key (except when typing)
function onKeyDown(e: KeyboardEvent) {
if (store.isActive) {
store.deactivate()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.screensaver-enter-active,
.screensaver-leave-active {
transition: opacity 0.5s ease;
}
.screensaver-enter-from,
.screensaver-leave-to {
opacity: 0;
}
/* Explicit viewport centering */
.screensaver-container {
position: fixed;
inset: 0;
display: grid;
place-items: center;
}
.screensaver-content {
position: relative;
display: grid;
place-items: center;
}
.screensaver-logo-wrapper {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
/* ASCII variant — centered Bitcoin face animation */
.screensaver-ascii-content {
display: flex;
align-items: center;
justify-content: center;
transform: scale(2);
}
@media (min-width: 640px) {
.screensaver-ascii-content {
transform: scale(2.5);
}
}
@media (min-width: 768px) {
.screensaver-ascii-content {
transform: scale(3);
}
}
</style>
@@ -0,0 +1,20 @@
<template>
<div class="logo-gradient-border screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
<!-- Squares logo -->
<div class="screensaver-logo-squares absolute inset-[3px] flex items-center justify-center">
<AnimatedLogo size="xl" no-border fit />
</div>
</div>
</template>
<script setup lang="ts">
import AnimatedLogo from '@/components/AnimatedLogo.vue'
</script>
<style scoped>
.screensaver-logo-squares {
opacity: 1;
position: relative;
z-index: 1;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="viz-ring" :class="sizeClass">
<div
v-for="(_, i) in segmentCount"
:key="i"
class="viz-segment"
:style="getSegmentStyle(i)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
/** Visual size: 'default' matches the screensaver; 'compact' drops the
* min-width breakpoints (useful inside overlays on narrower canvases). */
size?: 'default' | 'compact'
/** Override segment count. Defaults to 48 (screensaver standard). */
segmentCount?: number
}>(), { size: 'default', segmentCount: 48 })
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
function getSegmentStyle(i: number) {
const deg = (i / props.segmentCount) * 360
return {
'--segment-index': i,
'--segment-deg': `${deg}deg`,
}
}
</script>
<style scoped>
.viz-ring {
position: relative;
pointer-events: none;
}
.viz-ring-default {
width: 280px;
height: 280px;
--viz-radius: 140px;
}
@media (min-width: 640px) {
.viz-ring-default {
width: 360px;
height: 360px;
--viz-radius: 180px;
}
}
@media (min-width: 768px) {
.viz-ring-default {
width: 400px;
height: 400px;
--viz-radius: 200px;
}
}
.viz-ring-compact {
width: 240px;
height: 240px;
--viz-radius: 120px;
}
@media (min-width: 768px) {
.viz-ring-compact {
width: 320px;
height: 320px;
--viz-radius: 160px;
}
}
.viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
border-radius: 2px;
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: segment-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--segment-index) * 0.02s);
}
@media (min-width: 768px) {
.viz-segment {
height: 28px;
margin-top: -14px;
}
}
/* 5 normal loops (10s) then stronger longer expression (4s) — total 14s */
@keyframes segment-pulse {
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
14.3%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
21.4%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
28.6%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
35.7%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
42.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
57.1%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
64.3%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
71.4%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
78.6%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
85.7%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
}
</style>
@@ -0,0 +1,402 @@
<template>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
<template v-if="confirming">
<div class="mb-3 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Method</span>
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
</div>
<div v-if="dest.trim()" class="mb-1">
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
</div>
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share sats leave your balance when it's redeemed.</p>
</div>
<!-- Live balance impact -->
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '' : confirmBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50 flex items-center gap-2">
Amount
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</span>
<span class="text-sm font-medium text-white/80">{{ confirmAmount.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
{{ confirmBalance === null ? '' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : 'Confirm & Send' }}
</button>
</div>
</template>
<template v-else>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['lightning', 'onchain', 'ecash', 'fedimint', 'ark'] 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' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">{{ t('sendBitcoin.autoMethodDesc') }}</p>
</div>
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
<button
v-if="sendMethod === 'onchain'"
@click="toggleSendAll"
class="text-xs px-2 py-0.5 rounded border transition-colors"
:class="sendAll
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
>
Send all funds
</button>
</div>
<input
v-model.number="amount"
type="number"
min="1"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
:disabled="sendAll || pastedInvoiceAmount !== null"
class="w-full input-glass disabled:opacity-50"
/>
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p>
<p v-else-if="pastedInvoiceAmount !== null" class="text-xs text-white/50 mt-1">
This invoice fixes the amount — nothing to type, just review and send.
</p>
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
Zero-amount invoice — enter how many sats to pay.
</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label>
<button
v-if="canReadClipboard"
@click="pasteFromClipboard"
class="text-xs px-2 py-0.5 rounded border bg-white/5 border-white/15 text-white/60 hover:text-white/90 transition-colors"
>
{{ effectiveMethod === 'lightning' ? 'Paste invoice' : 'Paste' }}
</button>
</div>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1 / lnbc / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
(animated multi-frame not needed: qrcode handles these sizes). -->
<div class="flex justify-center my-2">
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<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">{{ t('common.copy') }}</button>
</div>
<div v-if="resultTxid" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
</div>
<div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div>
<div v-if="resultArk" class="mb-3 alert-success">
<p class="text-xs">{{ resultArk }}</p>
</div>
<div v-if="error" class="mb-3 alert-error">{{ 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="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
Scan
</button>
<button @click="review" :disabled="processing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ t('common.send') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
// 'auto' remains in the type for the effectiveMethod logic but is no longer
// offered as a tab (hidden per operator request 2026-07-22).
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
const amount = ref<number>(0)
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const resultArk = ref('')
const ecashToken = ref('')
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
function toggleSendAll() {
sendAll.value = !sendAll.value
if (sendAll.value && onchainBalance.value === null) {
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
.catch(() => { /* balance hint is best-effort */ })
}
}
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats — make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work — the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
async function pasteFromClipboard() {
try {
const text = (await navigator.clipboard.readText()).trim()
if (text) dest.value = text
} catch {
// Permission denied — user can long-press/Ctrl+V into the field instead.
}
}
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'
})
// --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted.
const confirming = ref(false)
const confirmBalance = ref<number | null>(null)
const invoiceAmountSats = ref<number | null>(null)
const methodLabel = computed(() => ({
auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain',
ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark',
}[effectiveMethod.value]))
const methodColor = computed(() => ({
auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500',
ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400',
}[effectiveMethod.value]))
const destDisplay = computed(() => {
const d = dest.value.trim()
return d.length > 64 ? `${d.slice(0, 38)}${d.slice(-18)}` : d
})
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
function parseBolt11AmountSats(invoice: string): number | null {
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
if (!m || !m[1]) return null
const value = Number(m[1])
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
return Math.round(value * mult * 1e8)
}
// An invoice-fixed amount always wins over the typed one; a sweep is priced
// at the whole balance.
const confirmAmount = computed(() => {
if (isSweep.value) return confirmBalance.value ?? 0
if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value
return amount.value > 0 ? Math.floor(amount.value) : 0
})
const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value)
const insufficient = computed(() =>
confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value
)
async function loadConfirmBalance() {
confirmBalance.value = null
try {
if (effectiveMethod.value === 'ecash') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
confirmBalance.value = res.balance_sats ?? 0
} else if (effectiveMethod.value === 'fedimint') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
confirmBalance.value = res.balance_sats ?? 0
} else {
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
confirmBalance.value = effectiveMethod.value === 'onchain'
? (res.balance_sats ?? 0)
: (res.channel_balance_sats ?? 0)
}
} catch {
confirmBalance.value = null // balance preview is best-effort; send still guarded server-side
}
}
function review() {
error.value = ''
const method = effectiveMethod.value
const d = dest.value.trim()
if (method === 'lightning') {
if (!d) { error.value = t('web5.pasteInvoice'); return }
invoiceAmountSats.value = parseBolt11AmountSats(d)
} else {
invoiceAmountSats.value = null
if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return }
}
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
error.value = t('sendBitcoin.amountSats'); return
}
void loadConfirmBalance()
confirming.value = true
}
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
ecashToken.value = ''
confirming.value = false
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
await nextTick()
if (!tokenQrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
} catch { /* QR is a convenience — the copyable text is authoritative */ }
})
async function send() {
if (processing.value) return
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
processing.value = true
error.value = ''
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
const method = effectiveMethod.value
try {
if (method === 'ark') {
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
await rpcClient.call<{ sent: boolean }>({
method: 'wallet.ark-send',
params: { destination: dest.value.trim(), amount_sats: amount.value },
// Ark sends can wait on round participation.
timeout: 130000,
})
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
} else 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 === 'fedimint') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.fedimint-send',
params: { amount_sats: amount.value },
timeout: 60000,
})
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: isSweep.value
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
})
resultTxid.value = res.txid
}
emit('sent')
// Back to the form pane so the success/token panes are visible.
confirming.value = false
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
processing.value = false
}
}
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="glass-card p-6 animate-pulse" :class="className">
<!-- Header skeleton -->
<div v-if="showHeader" class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-lg bg-white/10 shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-white/10 rounded w-2/3"></div>
<div class="h-3 bg-white/5 rounded w-1/3"></div>
</div>
</div>
<!-- Content skeleton lines -->
<div class="space-y-3">
<div v-for="i in lines" :key="i" class="h-3 bg-white/8 rounded" :style="{ width: lineWidth(i) }"></div>
</div>
<!-- Stats grid skeleton -->
<div v-if="showStats" class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
<div v-for="s in 4" :key="s" class="bg-white/5 rounded-lg p-3">
<div class="h-2 bg-white/8 rounded w-1/2 mb-2"></div>
<div class="h-5 bg-white/10 rounded w-3/4"></div>
</div>
</div>
<!-- Action buttons skeleton -->
<div v-if="showActions" class="flex gap-3 mt-4">
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
lines?: number
showHeader?: boolean
showStats?: boolean
showActions?: boolean
className?: string
}>(), {
lines: 3,
showHeader: true,
showStats: false,
showActions: false,
className: '',
})
function lineWidth(index: number): string {
const widths = ['100%', '85%', '70%', '90%', '60%']
return widths[(index - 1) % widths.length] ?? '100%'
}
</script>
+783
View File
@@ -0,0 +1,783 @@
<template>
<Transition name="splash-fade">
<div v-if="showSplash" class="fixed inset-0 z-[2000] flex items-center justify-center bg-black" style="will-change: opacity, transform;">
<!-- Video background - shown during Welcome Noderunner and Logo (seamless, no zoom) -->
<video
v-if="showWelcome || showLogo"
ref="videoElement"
class="absolute inset-0 w-full h-full object-cover"
:style="{ opacity: backgroundOpacity, transform: 'scale(1)', transition: 'opacity 1.2s ease-out' }"
autoplay
loop
muted
playsinline
preload="auto"
poster="/assets/img/bg-intro.jpg"
>
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
<!-- Fallback to image if video fails -->
<div
class="absolute inset-0"
:style="{
backgroundImage: 'url(/assets/img/bg-intro.jpg)',
backgroundSize: 'auto 100vh',
backgroundPosition: 'center top',
backgroundRepeat: 'no-repeat',
}"
/>
</video>
<!-- Static image background - shown during alien intro -->
<div
v-else
class="absolute inset-0"
:style="{
backgroundImage: 'url(/assets/img/bg-intro.jpg)',
backgroundSize: 'auto 100vh',
backgroundPosition: 'center top',
backgroundRepeat: 'no-repeat',
opacity: backgroundOpacity,
transform: 'scale(1)',
transition: 'opacity 1.2s ease-out',
}"
/>
<!-- Alien Intro -->
<Transition name="fade">
<div
v-if="!alienIntroComplete"
class="absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-800"
:class="{ 'opacity-0': fadeAlienIntro }"
>
<div class="font-mono text-white px-4 sm:px-5 max-w-[95vw] sm:max-w-[90vw] md:max-w-[1200px] text-base sm:text-lg md:text-[24px] leading-relaxed break-words">
<div v-if="showLine1" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine1 }">
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
<span class="text-white break-words">{{ displayLine1 }}</span><span v-if="isTypingLine1" class="intro-typing-caret" aria-hidden="true"></span>
</div>
<div v-if="showLine2" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine2 }">
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
<span class="text-white break-words">{{ displayLine2 }}</span><span v-if="isTypingLine2" class="intro-typing-caret" aria-hidden="true"></span>
</div>
<div v-if="showLine3" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine3 }">
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
<span class="text-white break-words">{{ displayLine3 }}</span><span v-if="isTypingLine3" class="intro-typing-caret" aria-hidden="true"></span>
</div>
<div v-if="showLine4" class="flex items-start mb-8 sm:mb-12 opacity-0" :class="{ 'opacity-100': showLine4 }">
<span class="text-[#fbbf24] mr-3 sm:mr-6 flex-shrink-0">></span>
<span class="text-white break-words">{{ displayLine4 }}</span><span v-if="isTypingLine4" class="intro-typing-caret" aria-hidden="true"></span>
</div>
</div>
</div>
</Transition>
<!-- Welcome Message -->
<Transition name="welcome-fade">
<div
v-if="showWelcome"
class="absolute inset-0 z-[15] flex items-center justify-center font-mono text-3xl sm:text-4xl md:text-5xl px-4"
:class="{ 'welcome-fade-out': fadeWelcome }"
>
<div class="typing-container">
<span class="text-white" :class="{ 'typing-text': typingWelcome }">
Welcome Noderunner
</span>
</div>
</div>
</Transition>
<!-- Logo - Archipelago logo for splash -->
<Transition name="logo-zoom">
<div v-if="showLogo" class="relative z-20 logo-container">
<img
src="/assets/img/logo-archipelago.svg"
alt="Archipelago"
class="w-[min(80vw,900px)] max-w-[90vw] h-auto filter drop-shadow-[0_6px_24px_rgba(0,0,0,0.35)] m-5 object-contain logo-zoom-bounce"
/>
</div>
</Transition>
<!-- Tap to start - logo + "Enter the Exit" behind (like screensaver) -->
<div
v-if="showTapToStart"
class="absolute inset-0 z-[100] flex items-center justify-center cursor-pointer overflow-hidden"
:class="tapStartTransitioning ? 'tap-overlay-zoom-out' : 'bg-black/40'"
@click="handleTapToStart"
>
<div class="tap-to-start-content relative flex items-center justify-center perspective-1000">
<span
class="tap-to-start-text font-archipelago font-extrabold text-6xl sm:text-7xl md:text-8xl lg:text-9xl tracking-widest uppercase whitespace-nowrap select-none transition-opacity duration-300"
:class="{ 'opacity-0': tapStartTransitioning }"
>
Enter to Exit
</span>
<div
class="tap-to-start-logo absolute transition-transform duration-300 ease-out"
:class="[
{ 'tap-logo-launch': tapStartTransitioning },
{ 'scale-110': introLogoHover && !tapStartTransitioning }
]"
@mouseenter="onIntroLogoHover"
@mouseleave="introLogoHover = false"
>
<!-- Audio viz ring - visible on hover -->
<div
class="intro-logo-viz-ring"
:class="{ 'intro-logo-viz-visible': introLogoHover && !tapStartTransitioning }"
>
<div
v-for="i in 48"
:key="i - 1"
class="intro-logo-viz-segment"
:style="{ '--segment-deg': `${((i - 1) / 48) * 360}deg`, '--segment-index': i - 1 }"
></div>
</div>
<ScreensaverLogo />
</div>
</div>
</div>
<!-- Skip Button -->
<button
v-if="!alienIntroComplete && !showTapToStart"
@click="handleSkipClick"
class="absolute bottom-8 right-8 z-20 bg-black/60 border border-white/30 text-white/70 font-mono text-xs px-4 py-2 rounded backdrop-blur-[10px] hover:bg-black/80 hover:text-white/90 hover:border-white/50 hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300"
>
Skip Intro
</button>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import { playIntroTyping, playKeyboardTypingSound, playLoopStart, playPop, playWelcomeNoderunnerSpeech, resumeAudioContext, startSynthwave, stopIntroTyping } from '@/composables/useLoginSounds'
const emit = defineEmits<{
complete: []
}>()
const INTRO_LINES = [
'In the future there will be 3 types of humans',
'Government Employees',
'Corporate Employees',
'And Noderunners...',
] as const
const MS_PER_CHAR = 55
const BLINK_AFTER_TYPING = 1500
const showSplash = ref(true)
const showTapToStart = ref(true)
const tapStartTransitioning = ref(false)
const introLogoHover = ref(false)
const backgroundOpacity = ref(0)
const alienIntroComplete = ref(false)
const fadeAlienIntro = ref(false)
const showWelcome = ref(false)
const fadeWelcome = ref(false)
const typingWelcome = ref(false)
const showLogo = ref(false)
const showLine1 = ref(false)
const showLine2 = ref(false)
const showLine3 = ref(false)
const showLine4 = ref(false)
const displayLine1 = ref('')
const displayLine2 = ref('')
const displayLine3 = ref('')
const displayLine4 = ref('')
const isTypingLine1 = ref(false)
const isTypingLine2 = ref(false)
const isTypingLine3 = ref(false)
const isTypingLine4 = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
let introTypingTimeout: ReturnType<typeof setTimeout> | null = null
const pendingTimers: ReturnType<typeof setTimeout>[] = []
function scheduleTimer(fn: () => void, delay: number) {
const id = setTimeout(fn, delay)
pendingTimers.push(id)
return id
}
// Ensure video plays continuously from Welcome Noderunner through logo
let videoPauseHandler: ((e: Event) => void) | null = null
watch([showWelcome, showLogo], ([welcome, logo]) => {
if ((welcome || logo) && videoElement.value) {
if (videoElement.value.paused) {
videoElement.value.play().catch(err => {
if (import.meta.env.DEV) console.warn('Video autoplay failed:', err)
})
}
// Add pause prevention handler once, remove when no longer needed
if (!videoPauseHandler) {
videoPauseHandler = () => {
if ((showWelcome.value || showLogo.value) && videoElement.value) {
videoElement.value.play().catch(() => {})
}
}
videoElement.value.addEventListener('pause', videoPauseHandler)
}
} else if (videoPauseHandler && videoElement.value) {
videoElement.value.removeEventListener('pause', videoPauseHandler)
videoPauseHandler = null
}
})
// Start video as soon as welcome appears
watch(showWelcome, (isShowing) => {
if (isShowing && videoElement.value) {
// Start video immediately when welcome appears
videoElement.value.play().catch(err => {
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
})
}
})
// Store video currentTime continuously and before unmounting for seamless transition
watch(showSplash, (isShowing) => {
if (!isShowing && videoElement.value) {
// Store current video time for seamless transition
const currentTime = videoElement.value.currentTime
const wasPlaying = !videoElement.value.paused
sessionStorage.setItem('video_intro_currentTime', currentTime.toString())
sessionStorage.setItem('video_intro_wasPlaying', wasPlaying.toString())
// Store video playback rate to maintain smooth playback
sessionStorage.setItem('video_intro_playbackRate', videoElement.value.playbackRate.toString())
}
})
// Continuously update video time while playing (for more accurate restoration)
let videoTimeUpdateInterval: number | null = null
watch([showWelcome, showLogo], ([welcome, logo]) => {
if ((welcome || logo) && videoElement.value) {
// Update stored time every 50ms for better accuracy and smoother transition
videoTimeUpdateInterval = window.setInterval(() => {
if (videoElement.value && !videoElement.value.paused) {
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
sessionStorage.setItem('video_intro_wasPlaying', 'true')
sessionStorage.setItem('video_intro_playbackRate', videoElement.value.playbackRate.toString())
}
}, 50) // More frequent updates for smoother transition
} else {
if (videoTimeUpdateInterval) {
clearInterval(videoTimeUpdateInterval)
videoTimeUpdateInterval = null
}
}
})
// NOTE: whether the intro should play is decided ONCE by App.vue
// (shouldShowIntroSplash) — this component is only mounted when the answer was
// yes, so it must not re-check neode_intro_seen itself. The old internal
// re-check silently skipped the whole sequence for any browser that had seen
// the intro before, even when App.vue explicitly asked for a replay (demo
// fresh visits, the Replay Intro link).
function handleEnterKey(e: KeyboardEvent) {
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
e.preventDefault()
handleTapToStart()
}
}
function onIntroLogoHover() {
introLogoHover.value = true
if (!tapStartTransitioning.value) playKeyboardTypingSound()
}
function handleTapToStart() {
if (!showTapToStart.value || tapStartTransitioning.value) return
resumeAudioContext()
playPop()
tapStartTransitioning.value = true
// Logo: grow (150ms) then zoom out to background (850ms). Total 1s.
setTimeout(() => {
showTapToStart.value = false
tapStartTransitioning.value = false
startAlienIntro()
}, 1000)
}
function handleSkipClick() {
resumeAudioContext()
skipIntro()
}
function skipIntro() {
// Jump to "Welcome Noderunner" part
if (introTypingTimeout) {
clearTimeout(introTypingTimeout)
introTypingTimeout = null
}
alienIntroComplete.value = true
fadeAlienIntro.value = true
showWelcome.value = true
typingWelcome.value = true
stopIntroTyping()
playLoopStart()
startSynthwave()
playWelcomeNoderunnerSpeech()
// Stop alien intro typing and any playing typing sound
stopIntroTyping()
isTypingLine1.value = false
isTypingLine2.value = false
isTypingLine3.value = false
isTypingLine4.value = false
// Start background fade in at 0.3 opacity when welcome appears
scheduleTimer(() => {
backgroundOpacity.value = 0.3
}, 0)
// Continue with welcome fade out after typing (2s) + cursor continues (1.5s) + 3 blinks (1.35s)
scheduleTimer(() => {
fadeWelcome.value = true
typingWelcome.value = false
}, 4850)
// Show logo - no zoom, just fade
scheduleTimer(() => {
showLogo.value = true
}, 5500)
// Hide welcome after logo starts appearing
scheduleTimer(() => {
showWelcome.value = false
}, 6000)
// Fade background to full opacity just before completing (for smooth transition to modal)
scheduleTimer(() => {
backgroundOpacity.value = 1
}, 9000)
// Complete splash with smooth transition
scheduleTimer(() => {
scheduleTimer(() => {
showSplash.value = false
document.body.classList.add('splash-complete')
localStorage.setItem('neode_intro_seen', '1')
emit('complete')
}, 500)
}, 9500)
}
function startAlienIntro() {
function typeLine(
lineIndex: number,
displayRef: { value: string },
isTypingRef: { value: boolean },
onDone: () => void
) {
const text = INTRO_LINES[lineIndex]!
let i = 0
displayRef.value = ''
isTypingRef.value = true
function tick() {
if (i === 0) {
playIntroTyping()
}
if (i < text.length) {
displayRef.value = text.slice(0, i + 1)
i++
introTypingTimeout = setTimeout(tick, MS_PER_CHAR)
} else {
stopIntroTyping()
isTypingRef.value = false
introTypingTimeout = setTimeout(onDone, BLINK_AFTER_TYPING)
}
}
tick()
}
function scheduleLine1() {
showLine1.value = true
typeLine(0, displayLine1, isTypingLine1, scheduleLine2)
}
function scheduleLine2() {
showLine2.value = true
typeLine(1, displayLine2, isTypingLine2, scheduleLine3)
}
function scheduleLine3() {
showLine3.value = true
typeLine(2, displayLine3, isTypingLine3, scheduleLine4)
}
function scheduleLine4() {
showLine4.value = true
typeLine(3, displayLine4, isTypingLine4, () => {
isTypingLine4.value = false
fadeAlienIntro.value = true
introTypingTimeout = setTimeout(showWelcomePhase, 800)
})
}
function showWelcomePhase() {
alienIntroComplete.value = true
showWelcome.value = true
typingWelcome.value = true
stopIntroTyping()
playLoopStart()
startSynthwave()
playWelcomeNoderunnerSpeech()
if (videoElement.value) {
videoElement.value.play().catch(err => {
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
})
}
backgroundOpacity.value = 0.3
scheduleTimer(() => {
fadeWelcome.value = true
typingWelcome.value = false
}, 4850)
scheduleTimer(() => {
showLogo.value = true
}, 5500)
scheduleTimer(() => {
showWelcome.value = false
}, 6000)
scheduleTimer(() => {
backgroundOpacity.value = 1
}, 9000)
scheduleTimer(() => {
if (videoElement.value && !videoElement.value.paused) {
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
sessionStorage.setItem('video_intro_wasPlaying', 'true')
}
showSplash.value = false
document.body.classList.add('splash-complete')
localStorage.setItem('neode_intro_seen', '1')
emit('complete')
}, 9500)
}
introTypingTimeout = setTimeout(scheduleLine1, 500)
}
onMounted(() => {
window.addEventListener('keydown', handleEnterKey)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleEnterKey)
if (introTypingTimeout) {
clearTimeout(introTypingTimeout)
introTypingTimeout = null
}
// Clear all scheduled timers to prevent firing on unmounted component
for (const id of pendingTimers) clearTimeout(id)
pendingTimers.length = 0
// Clear video time update interval
if (videoTimeUpdateInterval) {
clearInterval(videoTimeUpdateInterval)
videoTimeUpdateInterval = null
}
})
</script>
<style scoped>
.splash-fade-enter-active {
transition: opacity 0.5s ease;
}
.splash-fade-leave-active {
transition: opacity 1s ease-out, transform 1s ease-out;
}
.splash-fade-enter-from,
.splash-fade-leave-to {
opacity: 0;
}
.splash-fade-leave-to {
transform: scale(1.1);
}
/* Wide logo zooms out towards user when login modal comes in */
.splash-fade-leave-active .logo-container {
transition: transform 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
}
.splash-fade-leave-to .logo-container {
transform: scale(1.4);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Welcome message fade out */
.welcome-fade-enter-active {
transition: opacity 0.8s ease-out;
}
.welcome-fade-leave-active {
transition: opacity 0.6s ease-in;
}
.welcome-fade-enter-from,
.welcome-fade-leave-to {
opacity: 0;
}
.welcome-fade-out {
opacity: 0;
transition: opacity 0.6s ease-in;
}
/* Logo zoom bounce animation - smooth and buttery */
.logo-zoom-enter-active {
transition: all 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.logo-zoom-leave-active {
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.logo-zoom-enter-from {
opacity: 0;
transform: scale(0.7);
}
.logo-zoom-enter-to {
opacity: 1;
transform: scale(1);
}
.logo-zoom-leave-from {
opacity: 1;
transform: scale(1);
}
.logo-zoom-leave-to {
opacity: 0;
transform: scale(1.05);
}
.logo-zoom-bounce {
animation: logoZoomBounce 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes logoZoomBounce {
0% {
transform: scale(0.85);
opacity: 0;
}
50% {
transform: scale(1.02);
opacity: 0.9;
}
75% {
transform: scale(0.98);
opacity: 0.95;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Container to keep the typing text centered */
.typing-container {
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
}
/* Intro typing cursor - block style, yellow blink (Archipelago style) */
.intro-typing-caret {
display: inline-block;
width: 4px;
min-width: 4px;
height: 1.2em;
background: #fbbf24;
margin-left: 2px;
vertical-align: text-bottom;
animation: intro-caret-blink 0.5s step-end infinite;
}
@keyframes intro-caret-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Ensure text wraps smoothly on mobile */
.font-mono {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Smooth line transitions for mobile */
@media (max-width: 640px) {
.font-mono span {
display: inline-block;
max-width: 100%;
}
}
/* Background zoom transition - matches OnboardingWrapper style */
.bg-zoom-transition {
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 1.2s ease-out;
transform: scale(1);
transform-origin: center center;
will-change: transform, opacity;
}
.bg-zoom-transition.bg-zoom-in {
transform: scale(1.15);
}
/* Tap to start - logo grow then zoom out to background */
.tap-overlay-zoom-out {
background-color: rgba(0, 0, 0, 0.4);
transition: background-color 0.6s cubic-bezier(0.4, 0, 0.2, 1);
animation: tap-overlay-fade 1s ease-out forwards;
}
@keyframes tap-overlay-fade {
0% { background-color: rgba(0, 0, 0, 0.4); }
30% { background-color: rgba(0, 0, 0, 0.35); }
100% { background-color: rgba(0, 0, 0, 0); }
}
.perspective-1000 {
perspective: 1000px;
}
.tap-logo-launch {
animation: tap-logo-launch 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
transform-origin: center center;
will-change: transform, opacity;
}
@keyframes tap-logo-launch {
0% { transform: scale(1); opacity: 1; }
15% { transform: scale(1.2); opacity: 1; }
25% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
}
/* Tap to start - "Enter the Exit" big behind logo */
.tap-to-start-content {
min-height: 12rem;
}
.tap-to-start-text {
position: absolute;
z-index: 0;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.35) 0%,
rgba(0, 0, 0, 0.35) 38%,
rgba(0, 0, 0, 0.35) 40%,
rgba(255, 255, 255, 0.5) 48%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.5) 52%,
rgba(0, 0, 0, 0.35) 60%,
rgba(0, 0, 0, 0.35) 100%
);
background-size: 250% 100%;
background-position: 0% 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: tap-to-start-flare-wipe 14s ease-in-out infinite;
}
@keyframes tap-to-start-flare-wipe {
0%, 82%, 100% {
background-position: 0% 0;
}
88% {
background-position: 100% 0;
}
}
.tap-to-start-logo {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
overflow: visible;
}
.intro-logo-viz-ring {
position: absolute;
left: 50%;
top: 50%;
width: 1px;
height: 1px;
transform: translate(-50%, -50%);
z-index: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
--viz-radius: 7rem;
}
.intro-logo-viz-ring.intro-logo-viz-visible {
opacity: 1;
}
.intro-logo-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.15));
border-radius: 2px;
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: intro-viz-pulse 2.5s ease-in-out infinite;
animation-delay: calc(var(--segment-index, 0) * 0.02s);
}
@keyframes intro-viz-pulse {
0%, 100% { opacity: 0.4; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.5); }
50% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
}
@media (min-width: 640px) {
.intro-logo-viz-ring { --viz-radius: 8rem; }
.intro-logo-viz-segment { height: 26px; margin-top: -13px; }
}
@media (min-width: 768px) {
.intro-logo-viz-ring { --viz-radius: 9rem; }
.intro-logo-viz-segment { height: 28px; margin-top: -14px; }
}
.tap-to-start-logo :deep(.logo-gradient-border) {
width: 12rem;
height: 12rem;
position: relative;
z-index: 1;
}
@media (min-width: 640px) {
.tap-to-start-content {
min-height: 14rem;
}
.tap-to-start-logo :deep(.logo-gradient-border) {
width: 14rem;
height: 14rem;
}
}
@media (min-width: 768px) {
.tap-to-start-content {
min-height: 16rem;
}
.tap-to-start-logo :deep(.logo-gradient-border) {
width: 16rem;
height: 16rem;
}
}
</style>
+405
View File
@@ -0,0 +1,405 @@
<template>
<Teleport to="body">
<Transition name="spotlight">
<div
v-if="spotlightStore.isOpen"
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
@click.self="spotlightStore.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="panelRef"
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
:style="panelStyle"
@mousedown="onPanelMouseDown"
>
<!-- Header: drag handle grip + search -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<div class="flex-1 flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search or type a command..."
class="flex-1 bg-transparent text-white placeholder-white/50 outline-none text-base"
@keydown="onInputKeydown"
/>
</div>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<div class="flex-1 overflow-y-auto max-h-[60vh] min-h-[200px]">
<!-- Recent items (when no query and we have recent) -->
<div v-if="!query.trim() && spotlightStore.recentItems.length > 0" class="p-2 border-b border-white/10">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">Recent</div>
<button
v-for="(item, idx) in spotlightStore.recentItems"
:key="`recent-${item.id}-${item.timestamp}`"
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(idx)"
@click="selectRecent(item)"
>
<span class="text-white/90">{{ item.label }}</span>
<span class="text-xs text-white/40">{{ item.type }}</span>
</button>
</div>
<!-- Search results or help tree -->
<template v-if="query.trim()">
<div v-if="filteredItems.length > 0" class="p-2">
<button
v-for="(item, idx) in filteredItems"
:key="item.id + item.section"
type="button"
class="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(idx)"
@click="selectItem(item)"
>
<span class="text-white/90">{{ item.label }}</span>
<span class="text-xs text-white/40">{{ item.section }}</span>
</button>
</div>
<div v-else class="p-8 text-center text-white/50">
No results for "{{ query }}"
</div>
</template>
<template v-else>
<!-- Help tree when no search -->
<div v-for="section in helpTree" :key="section.id" class="p-2">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">{{ section.label }}</div>
<button
v-for="(item, idx) in section.items"
:key="item.id"
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(recentOffset + getFlatIndex(section.id, idx))"
@click="selectHelpItem(section, item)"
>
<span class="text-white/90">{{ item.label }}</span>
</button>
</div>
</template>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import Fuse from 'fuse.js'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
const router = useRouter()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const appLauncherStore = useAppLauncherStore()
const inputRef = ref<HTMLInputElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const query = ref('')
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const staticItems = flattenForSearch()
// Build dynamic app items from installed packages
const dynamicAppItems = computed<SearchableItem[]>(() => {
const pkgs = appStore.packages
return Object.entries(pkgs).map(([id, pkg]) => ({
id: `app-${id}`,
label: pkg.manifest?.title || id,
path: `__launch_app__:${id}`,
type: 'action' as const,
section: 'Installed Apps',
}))
})
const allSearchableItems = computed(() => [...staticItems, ...dynamicAppItems.value])
const fuse = computed(() => new Fuse(allSearchableItems.value, {
keys: ['label', 'section'],
threshold: 0.4,
}))
const filteredItems = computed(() => {
const q = query.value.trim()
if (!q) return []
const results = fuse.value.search(q)
return results.map((r) => r.item)
})
const recentOffset = computed(() =>
!query.value.trim() && spotlightStore.recentItems.length > 0 ? spotlightStore.recentItems.length : 0
)
const selectableCount = computed(() => {
if (query.value.trim()) return filteredItems.value.length
return recentOffset.value + allSearchableItems.value.length
})
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
const SAVED_POSITION_KEY = 'archipelago-spotlight-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
function loadSavedPosition() {
try {
const raw = localStorage.getItem(SAVED_POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw)
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
} else {
savedPosition.value = null
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved spotlight position', e)
savedPosition.value = null
}
}
function savePosition(x: number, y: number) {
savedPosition.value = { x, y }
try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to save spotlight position', e)
}
}
function getFlatIndex(sectionId: string, itemIdx: number): number {
let idx = 0
for (const s of helpTree) {
if (s.id === sectionId) return idx + itemIdx
idx += s.items.length
}
return -1
}
function getItemClass(index: number) {
const selected = spotlightStore.selectedIndex
return index === selected
? 'bg-amber-500/20 text-amber-200'
: 'hover:bg-white/10 text-white/90'
}
function launchInstalledApp(appId: string) {
const pkg = appStore.packages[appId]
if (!pkg) return
let lanAddress = pkg.installed?.['interface-addresses']?.main?.['lan-address']
if (lanAddress && lanAddress.includes('localhost')) {
lanAddress = lanAddress.replace('localhost', window.location.hostname)
}
if (lanAddress) {
appLauncherStore.open({ url: lanAddress, title: pkg.manifest?.title || appId })
} else {
router.push(`/dashboard/apps/${appId}`).catch(() => {})
}
}
function selectItem(item: SearchableItem) {
spotlightStore.addRecentItem({
id: item.id,
label: item.label,
path: item.path,
type: item.type,
})
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
} else if (item.path === '__cli__') {
cliStore.open()
} else if (item.path) {
router.push(item.path)
} else if (item.content) {
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
}
}
function selectHelpItem(section: { id: string }, item: { id: string; label: string; path?: string; content?: string; relatedPath?: string }) {
const type = section.id === 'navigate' ? 'navigate' : section.id === 'learn' ? 'learn' : 'action'
spotlightStore.addRecentItem({
id: item.id,
label: item.label,
path: item.path,
type,
})
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
} else if (item.path === '__cli__') {
cliStore.open()
} else if (item.path) {
router.push(item.path)
} else if (item.content) {
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
}
}
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' | 'goal' }) {
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
return
}
if (item.path === '__cli__') {
cliStore.open()
return
}
if (item.path) {
router.push(item.path)
return
}
if (item.type === 'learn') {
for (const s of helpTree) {
const helpItem = s.items.find((i) => i.id === item.id)
if (helpItem?.content) {
spotlightStore.showHelpModal({ title: helpItem.label, content: helpItem.content, relatedPath: helpItem.relatedPath })
return
}
}
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
spotlightStore.close()
e.preventDefault()
e.stopPropagation()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
spotlightStore.setSelectedIndex(
Math.min(spotlightStore.selectedIndex + 1, Math.max(0, selectableCount.value - 1))
)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
spotlightStore.setSelectedIndex(Math.max(spotlightStore.selectedIndex - 1, 0))
return
}
if (e.key === 'Enter') {
e.preventDefault()
const idx = spotlightStore.selectedIndex
if (query.value.trim()) {
const item = filteredItems.value[idx]
if (item) selectItem(item)
return
}
if (idx < recentOffset.value) {
const item = spotlightStore.recentItems[idx]
if (item) selectRecent(item)
return
}
const helpIdx = idx - recentOffset.value
let count = 0
for (const s of helpTree) {
for (const item of s.items) {
if (count === helpIdx) {
selectHelpItem(s, item)
return
}
count++
}
}
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
watch(
() => spotlightStore.isOpen,
(open) => {
if (open) {
query.value = ''
loadSavedPosition()
nextTick(() => {
inputRef.value?.focus()
spotlightStore.setSelectedIndex(0)
})
}
}
)
watch(
[query, filteredItems],
() => {
spotlightStore.setSelectedIndex(0)
}
)
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<style scoped>
.spotlight-enter-active,
.spotlight-leave-active {
transition: opacity 0.2s ease;
}
.spotlight-enter-from,
.spotlight-leave-to {
opacity: 0;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<Teleport to="body">
<div class="fixed right-4 z-[9999] flex flex-col gap-2 pointer-events-none max-w-sm w-full" style="top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);">
<TransitionGroup name="toast-stack">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast-stack-item pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer"
:class="variantClass(toast.variant)"
@click="dismiss(toast.id)"
>
<div class="w-5 h-5 shrink-0 flex items-center justify-center">
<!-- Success -->
<svg v-if="toast.variant === 'success'" class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<!-- Error -->
<svg v-else-if="toast.variant === 'error'" class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<!-- Info -->
<svg v-else class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<span class="text-sm text-white/90">{{ toast.message }}</span>
<button
v-if="toast.action"
@click.stop="runAction(toast)"
class="block mt-1 text-sm font-semibold text-orange-400 hover:text-orange-300 transition-colors"
>{{ toast.action.label }} </button>
</div>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
import type { ToastItem, ToastVariant } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
function runAction(toast: ToastItem | Readonly<ToastItem>) {
toast.action?.onClick()
dismiss(toast.id)
}
function variantClass(variant: ToastVariant): string {
switch (variant) {
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
case 'error': return 'bg-black/70 border-red-500/30 backdrop-blur-md'
default: return 'bg-black/70 border-blue-500/30 backdrop-blur-md'
}
}
</script>
<style scoped>
.toast-stack-enter-active {
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-stack-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-stack-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-stack-leave-to {
opacity: 0;
transform: translateX(50%);
}
.toast-stack-move {
transition: transform 0.3s ease;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
tabindex="-1"
data-controller-ignore
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="[modelValue ? 'bg-orange-500' : 'bg-white/15', disabled ? 'opacity-40 cursor-not-allowed' : '']"
@click="$emit('update:modelValue', !modelValue)"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="modelValue ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
disabled?: boolean
ariaLabel?: string
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
@@ -0,0 +1,192 @@
<template>
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh see
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. -->
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
<button
v-for="f in filters"
:key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@click="activeFilter = f.key"
>
{{ f.label }}<span v-if="countFor(f.key)" class="text-white/35"> · {{ countFor(f.key) }}</span>
</button>
</div>
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div>
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
<div
v-for="tx in filteredTransactions"
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
@click="openInMempool(tx)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
: 'bg-red-500/10'"
>
<svg
class="w-4 h-4"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
: 'text-red-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
<path v-else 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"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
</span>
<span
v-if="isOnchain(tx)"
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'
: tx.num_confirmations < 3
? 'bg-green-500/15 text-green-400'
: 'bg-white/10 text-white/50'"
>
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : tx.kind === 'ark' ? 'bg-teal-500/15 text-teal-400' : 'bg-blue-500/15 text-blue-400'"
>
{{ kindLabel(tx) }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg v-if="isOnchain(tx)" 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>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useTxExplorer } from '@/composables/useTxExplorer'
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
// Which rail the transaction happened on; absent = onchain (older backends)
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
const props = defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
// The Ark chip only appears once an Ark transaction exists — most nodes
// don't run the barkd sidecar.
const filters = computed<Array<{ key: FilterKey; label: string }>>(() => [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
...(props.transactions.some(tx => tx.kind === 'ark') ? [{ key: 'ark' as const, label: 'Ark' }] : []),
])
const activeFilter = ref<FilterKey>('all')
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
if (f === 'all') return true
if (f === 'onchain') return isOnchain(tx)
if (f === 'lightning') return tx.kind === 'lightning'
if (f === 'ark') return tx.kind === 'ark'
return tx.kind === 'cashu' || tx.kind === 'fedimint'
}
const filteredTransactions = computed(() => props.transactions.filter(tx => matchesFilter(tx, activeFilter.value)))
function countFor(f: FilterKey): number {
if (f === 'all') return 0
return props.transactions.filter(tx => matchesFilter(tx, f)).length
}
function close() {
emit('close')
}
// Only on-chain transactions exist in mempool; Lightning/ecash rows don't link
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
function kindLabel(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Lightning'
if (tx.kind === 'cashu') return 'Cashu'
if (tx.kind === 'fedimint') return 'Fedimint'
if (tx.kind === 'ark') return 'Ark'
return ''
}
const txExplorer = useTxExplorer()
function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return
// Local Mempool app when running (overlaid above this modal); external
// explorer with consent otherwise (pruned nodes can't run Mempool).
txExplorer.openTx(tx.tx_hash)
}
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 diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('transactions.justNow')
if (diffMins < 60) return t('transactions.minutesAgo', { count: diffMins })
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return t('transactions.hoursAgo', { count: diffHours })
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return t('transactions.daysAgo', { count: diffDays })
return date.toLocaleDateString()
}
</script>
+852
View File
@@ -0,0 +1,852 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="modalRef"
class="glass-card p-6 w-full max-w-md relative z-10 overflow-hidden"
role="dialog"
aria-modal="true"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between gap-4 mb-4">
<div class="flex items-center gap-2 min-w-0">
<button
v-if="pane !== 'scan'"
@click="goBack"
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Back"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 class="text-xl font-semibold text-white truncate">{{ paneTitle }}</h3>
</div>
<button
@click="close"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
<!-- ============ SCAN PANE ============ -->
<div v-if="pane === 'scan'" key="scan">
<!-- Chooser interstitial every open: the user picks live camera
or a photo upload; neither starts until chosen. -->
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
the native bridge still provides a live camera -->
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Scan with camera
</button>
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Upload / take a photo of the QR
</button>
</div>
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
source-less <video> flashes a native play glyph in Android WebViews -->
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
<!-- Viewfinder -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="scan-viewfinder"></div>
</div>
<!-- While auto-start is settling, show only a quiet spinner the
Start/Take-photo buttons are the FALLBACK, not a splash screen -->
<div v-if="!isScanning && autoStarting" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
<svg class="w-8 h-8 text-white/40 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v3a5 5 0 00-5 5H4z" />
</svg>
</div>
<div v-else-if="!isScanning" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<button v-if="!liveCameraUnavailable" @click="startScanning" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ cameraError ? 'Retry camera' : 'Start camera' }}
</button>
<!-- Insecure-context fallback: live getUserMedia preview needs
HTTPS, but the native camera via a file input does not
snap a photo of the QR and decode it locally. -->
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
Take photo of QR
</button>
</div>
</div>
<!-- Single always-mounted picker input, shared by the chooser and
the in-camera fallback button -->
<input
ref="photoInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhotoPicked"
/>
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
</p>
</div>
<!-- Paste fallback (also the path on camera-less nodes) -->
<div class="flex gap-2">
<input
v-model="pasteInput"
type="text"
placeholder="…or paste an invoice / address / token"
class="flex-1 input-glass font-mono text-xs"
@keydown.enter="submitPaste"
/>
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
Use
</button>
</div>
</div>
<!-- ============ AMOUNT / CONFIRM PANE ============ -->
<div v-else-if="pane === 'amount'" key="amount">
<!-- Rail + destination summary -->
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg mb-4">
<span class="w-9 h-9 rounded-lg bg-white/10 flex items-center justify-center shrink-0" :class="railColor">
<span v-if="rail === 'onchain'" class="text-lg font-bold">&#x20bf;</span>
<svg v-else-if="rail === 'lightning'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<!-- SVG, not 🥜: kiosk images ship no color-emoji font -->
<svg v-else-if="rail === 'cashu'" class="w-5 h-5" role="img" aria-label="Cashu" 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>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ railLabel }}</p>
<p class="text-[11px] text-white/40 font-mono truncate">{{ destDisplay }}</p>
</div>
</div>
<!-- Federation join is a confirm, not an amount entry -->
<template v-if="action === 'fedimint-join'">
<p class="text-sm text-white/70 mb-4">
This is a Fedimint federation invite. Join it to hold and send ecash backed by this federation.
</p>
</template>
<template v-else>
<!-- Amount -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">Amount (sats)</label>
<span v-if="amountLocked" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</div>
<input
v-model.number="amount"
type="number"
min="1"
inputmode="numeric"
placeholder="0"
:disabled="amountLocked || sendMax"
class="w-full input-glass text-2xl font-semibold text-center disabled:opacity-70"
/>
</div>
<!-- Presets + Max -->
<div v-if="!amountLocked" class="grid grid-cols-5 gap-2 mb-4">
<button
v-for="preset in PRESETS"
:key="preset"
@click="applyPreset(preset)"
:disabled="balance !== null && preset > balance"
class="px-1 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30"
:class="!sendMax && amount === preset ? 'bg-white/20 text-white' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
>{{ formatPreset(preset) }}</button>
<button
@click="toggleMax"
class="px-1 py-1.5 rounded-lg text-xs font-semibold transition-colors"
:class="sendMax ? 'bg-orange-500/25 text-orange-300 border border-orange-500/40' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
>Max</button>
</div>
<!-- Live balance impact -->
<div class="rounded-lg border border-white/10 divide-y divide-white/5 mb-4">
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">{{ railLabel }} balance</span>
<span class="text-sm font-medium" :class="railColor">
{{ balance === null ? '…' : balance.toLocaleString() + ' sats' }}
</span>
</div>
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">This send</span>
<span class="text-sm font-medium text-white/80">{{ effectiveAmount.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-semibold" :class="insufficient ? 'text-red-400' : 'text-white'">
{{ balance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ railLabel }} balance for this amount.</p>
<p v-else-if="sendMax && rail === 'onchain'" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
</template>
<div v-if="error" class="mb-3 alert-error text-sm">{{ error }}</div>
<div class="flex gap-3">
<button @click="goBack" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button
@click="confirmSend"
:disabled="processing || (action !== 'fedimint-join' && (insufficient || (effectiveAmount <= 0 && !sendMax)))"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ processing ? 'Sending…' : confirmLabel }}
</button>
</div>
</div>
<!-- ============ SUCCESS PANE ============ -->
<div v-else key="success" class="text-center py-2">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full mx-auto mb-5 success-ring">
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successAmount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-2">{{ successVerb }}</div>
<p class="text-sm text-white/50 mb-1">{{ successDetail }}</p>
<p v-if="successRef" class="text-[11px] text-white/30 font-mono break-all px-4 mb-4">{{ successRef }}</p>
<div v-if="balance !== null" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 text-xs text-white/60 mb-5">
<span :class="railColor">{{ railLabel }}</span>
<span>balance now {{ balance.toLocaleString() }} sats</span>
</div>
<button
@click="close"
class="w-full py-3 rounded-xl font-semibold text-base bg-white/10 hover:bg-white/20 text-white transition-colors"
>
Done
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import QrScanner from 'qr-scanner'
import { rpcClient } from '@/api/rpc-client'
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
type Pane = 'scan' | 'amount' | 'success'
// JS bridge the Android companion injects: when present, live scanning is
// delegated to a native camera modal (styled like this one) — the WebView's
// getUserMedia preview lags, and over plain http it doesn't exist at all.
// Decodes come back through the window.__archyQr* callbacks; status lines
// (animated-QR progress, errors) mirror out to the native modal's strip.
interface ArchipelagoQrBridge {
open(): void
setStatus(message: string, isError: boolean): void
close(): void
}
interface NativeWindow extends Window {
ArchipelagoQr?: ArchipelagoQrBridge
__archyQrResult?: (text: string) => void
__archyQrCancelled?: () => void
}
const nativeWin = window as NativeWindow
const PRESETS = [21, 2100, 21000, 100000]
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
// --- Pane state ---
const pane = ref<Pane>('scan')
const direction = ref<'forward' | 'back'>('forward')
const paneTitle = computed(() => {
if (pane.value === 'scan') return 'Scan to send'
if (pane.value === 'success') return 'Success'
if (action.value === 'fedimint-join') return 'Join federation'
if (action.value === 'redeem-token') return 'Redeem'
return 'Send'
})
function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') {
direction.value = dir
pane.value = p
}
function goBack() {
if (pane.value === 'amount') {
error.value = ''
goTo('scan', 'back')
// Only relight the camera when that's what the user chose; otherwise
// they land back on the scan/upload chooser.
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
} else if (pane.value === 'success') {
close()
}
}
// --- Scanner ---
const videoElement = ref<HTMLVideoElement | null>(null)
const isScanning = ref(false)
// True while a camera-start attempt is in flight (incl. the automatic one on
// open) — the fallback buttons stay hidden until it resolves, so they don't
// flash for a second on every open.
const autoStarting = ref(false)
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
// nothing starts until the user picks, and Back from a later pane returns to
// the live camera only if that's what they chose.
const scanChoice = ref<'unset' | 'camera'>('unset')
function chooseCamera() {
if (startNativeScan()) return
scanChoice.value = 'camera'
void nextTick(() => startScanning())
}
// --- Native scanner (companion app) ---
const hasNativeQr = !!nativeWin.ArchipelagoQr
const nativeScanActive = ref(false)
function startNativeScan(): boolean {
const bridge = nativeWin.ArchipelagoQr
if (!bridge) return false
nativeWin.__archyQrResult = (text: string) => handleScanned(text)
nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false }
nativeScanActive.value = true
bridge.open()
return true
}
const scanStatus = ref('')
const scanStatusIsError = ref(false)
const cameraError = ref(false)
const pasteInput = ref('')
const qrScanner = ref<QrScanner | null>(null)
const animatedDecoder = useAnimatedQRDecoder()
async function startScanning() {
cameraError.value = false
scanStatusIsError.value = false
autoStarting.value = true
try {
if (!videoElement.value) return
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
location.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(location.hostname)
? 'Camera needs HTTPS — paste the code below instead'
: 'No camera available — paste the code below instead'
)
}
qrScanner.value = new QrScanner(
videoElement.value,
(result) => handleScanned(result.data),
{
returnDetailedScanResult: true,
highlightScanRegion: false,
preferredCamera: 'environment',
// 4/s decodes plenty fast for a hand-held QR while keeping the
// preview smooth on phone WebViews (10/s visibly lagged the video).
maxScansPerSecond: 4,
}
)
await qrScanner.value.start()
isScanning.value = true
scanStatus.value = ''
} catch (err) {
cameraError.value = true
scanStatusIsError.value = true
scanStatus.value = err instanceof Error ? err.message : 'Unable to access camera'
} finally {
autoStarting.value = false
}
}
function stopScanning() {
qrScanner.value?.stop()
qrScanner.value?.destroy()
qrScanner.value = null
isScanning.value = false
if (nativeScanActive.value) {
nativeScanActive.value = false
nativeWin.ArchipelagoQr?.close()
}
}
// Mirror status lines onto the native modal while it's up — it covers the
// page, so this strip is the only feedback the user can see.
watch([scanStatus, scanStatusIsError], () => {
if (nativeScanActive.value) {
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
}
})
function submitPaste() {
const text = pasteInput.value.trim()
if (!text) return
handleScanned(text)
}
// --- Photo-capture fallback (works without a secure context) ---
// getUserMedia is unreachable over plain http (navigator.mediaDevices is
// undefined), but <input capture> opens the native camera in any browser,
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
const photoInput = ref<HTMLInputElement | null>(null)
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
async function onPhotoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
scanStatusIsError.value = false
scanStatus.value = 'Reading photo…'
try {
handleScanned(await decodePhotoRobust(file))
} catch {
scanStatusIsError.value = true
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
} finally {
input.value = ''
}
}
/** Decode a QR photo with every engine we have. On the companion app the
* photo path IS the scan path (plain-http LAN = no secure context = no
* live camera), and Lightning invoices make DENSE codes that the wasm
* engine's single pass often misses (reported 2026-07-22: "camera not
* picking up the invoice"). Android WebView's native BarcodeDetector is
* far stronger on dense codes, so try it first; fall back to qr-scanner. */
async function decodePhotoRobust(file: File): Promise<string> {
try {
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).BarcodeDetector
if (Detector) {
const bmp = await createImageBitmap(file)
const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp)
const hit = codes.find(c => c.rawValue)
if (hit) return hit.rawValue
}
} catch {
// Native detector unavailable/failed — wasm engine below.
}
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
return result.data
}
// --- Detection (ported from k484's scanner) ---
const rail = ref<Rail>('lightning')
const action = ref<Action>('pay-invoice')
const dest = ref('')
const amount = ref<number>(0)
const amountLocked = ref(false)
const sendMax = ref(false)
const railLabel = computed(() => ({
onchain: 'On-chain',
lightning: 'Lightning',
cashu: 'Cashu',
fedimint: 'Fedimint',
}[rail.value]))
const railColor = computed(() => ({
onchain: 'text-orange-500',
lightning: 'text-yellow-400',
cashu: 'text-purple-400',
fedimint: 'text-blue-400',
}[rail.value]))
const destDisplay = computed(() =>
dest.value.length > 46 ? `${dest.value.slice(0, 26)}${dest.value.slice(-14)}` : dest.value
)
const confirmLabel = computed(() => {
if (action.value === 'fedimint-join') return 'Join'
if (action.value === 'redeem-token') return 'Redeem'
return 'Send'
})
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
function parseBolt11AmountSats(invoice: string): number | null {
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
if (!m || !m[1]) return null
const value = Number(m[1])
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
return Math.round(value * mult * 1e8)
}
function isLightningInvoice(text: string): boolean {
const t = text.toLowerCase()
return t.startsWith('lnbc') || t.startsWith('lntb') || t.startsWith('lnbcrt')
}
function isOnchainAddress(text: string): boolean {
return /^(bc1|tb1|bcrt1)[a-z0-9]{20,90}$/i.test(text) || /^[13][a-km-zA-HJ-NP-Z1-9]{25,40}$/.test(text)
}
function isCashuToken(text: string): boolean {
return text.startsWith('cashuA') || text.startsWith('cashuB')
}
function isFedimintInvite(text: string): boolean {
return text.startsWith('fed11')
}
function isFedimintToken(text: string): boolean {
return /^AwE/.test(text)
}
function isAnimatedFrame(text: string): boolean {
return text.startsWith('B$') || text.startsWith('AAAH') || text.startsWith('ZAAE') || text.startsWith('AAA')
}
function handleScanned(raw: string) {
let text = raw.trim()
scanStatusIsError.value = false
// Animated multi-frame QR (large Fedimint tokens): keep collecting frames
if (isAnimatedFrame(text)) {
animatedDecoder.addFrame(text)
if (animatedDecoder.isComplete.value && animatedDecoder.decodedData.value) {
const token = animatedDecoder.decodedData.value
animatedDecoder.reset()
acceptDetected(token)
} else {
scanStatus.value = `Animated code… ${animatedDecoder.progressText()}`
}
return
}
acceptDetected(text)
}
function acceptDetected(raw: string) {
let text = raw.trim()
let bip21AmountSats: number | null = null
// URI schemes
if (text.toLowerCase().startsWith('lightning:')) text = text.slice(10)
if (text.toLowerCase().startsWith('bitcoin:')) {
try {
const uri = new URL(text)
const lnParam = uri.searchParams.get('lightning')
const amt = uri.searchParams.get('amount')
if (amt && !Number.isNaN(Number(amt))) bip21AmountSats = Math.round(Number(amt) * 1e8)
// Prefer the unified lightning param when present, else the address
text = lnParam || text.slice(8).split('?')[0] || ''
} catch {
text = text.slice(8).split('?')[0] || ''
}
}
if (isLightningInvoice(text)) {
rail.value = 'lightning'
action.value = 'pay-invoice'
dest.value = text
const invoiceAmount = parseBolt11AmountSats(text)
// Zero-amount invoice inside a unified BIP21 URI: prefill from amount=
amount.value = invoiceAmount ?? bip21AmountSats ?? 0
amountLocked.value = invoiceAmount !== null
} else if (isCashuToken(text)) {
rail.value = 'cashu'
action.value = 'redeem-token'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isFedimintInvite(text)) {
rail.value = 'fedimint'
action.value = 'fedimint-join'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isFedimintToken(text)) {
rail.value = 'fedimint'
action.value = 'redeem-token'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isOnchainAddress(text)) {
rail.value = 'onchain'
action.value = 'send-onchain'
dest.value = text
amount.value = bip21AmountSats ?? 0
amountLocked.value = false
} else if (text.toLowerCase().startsWith('lno1')) {
// BOLT12 offer — LND (this node's Lightning backend) can't pay offers yet
scanStatusIsError.value = true
scanStatus.value = 'BOLT12 offers aren\'t supported yet — ask the recipient for a BOLT11 invoice'
return
} else if (text.toLowerCase().startsWith('lnurl1') || /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/.test(text)) {
// LNURL-pay / lightning address — needs an HTTP callback flow we don't do yet
scanStatusIsError.value = true
scanStatus.value = 'LNURL and lightning addresses aren\'t supported yet — ask for a BOLT11 invoice'
return
} else {
scanStatusIsError.value = true
scanStatus.value = 'Not a recognised invoice, address or token'
return
}
sendMax.value = false
error.value = ''
stopScanning()
loadBalance()
// Tokens carry their own value — redeem straight away, success screen follows
if (action.value === 'redeem-token') {
goTo('amount')
confirmSend()
} else {
goTo('amount')
}
}
// --- Balance for the active rail ---
const balance = ref<number | null>(null)
async function loadBalance() {
balance.value = null
try {
if (rail.value === 'cashu') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
balance.value = res.balance_sats ?? 0
} else if (rail.value === 'fedimint') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
balance.value = res.balance_sats ?? 0
} else {
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
balance.value = rail.value === 'onchain' ? (res.balance_sats ?? 0) : (res.channel_balance_sats ?? 0)
}
} catch {
balance.value = null
}
}
const effectiveAmount = computed(() => {
if (sendMax.value) return balance.value ?? 0
return amount.value > 0 ? Math.floor(amount.value) : 0
})
const balanceAfter = computed(() => (balance.value ?? 0) - effectiveAmount.value)
const insufficient = computed(() =>
action.value !== 'fedimint-join' && action.value !== 'redeem-token' &&
balance.value !== null && effectiveAmount.value > balance.value
)
function applyPreset(preset: number) {
sendMax.value = false
amount.value = preset
}
function toggleMax() {
sendMax.value = !sendMax.value
if (sendMax.value && balance.value !== null && rail.value !== 'onchain') {
amount.value = balance.value
}
}
function formatPreset(preset: number): string {
return preset >= 1000 ? `${(preset / 1000).toLocaleString()}k` : String(preset)
}
// --- Send / redeem / join ---
const processing = ref(false)
const error = ref('')
const successAmount = ref(0)
const successVerb = ref('SENT')
const successDetail = ref('')
const successRef = ref('')
async function confirmSend() {
if (processing.value) return
processing.value = true
error.value = ''
try {
if (action.value === 'pay-invoice') {
const params: Record<string, unknown> = { payment_request: dest.value }
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params,
timeout: 60000,
})
successAmount.value = res.amount_sats || effectiveAmount.value
successVerb.value = 'PAID'
successDetail.value = 'Lightning invoice paid'
successRef.value = res.payment_hash
} else if (action.value === 'send-onchain') {
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: sendMax.value
? { addr: dest.value, send_all: true }
: { addr: dest.value, amount: effectiveAmount.value },
timeout: 60000,
})
successAmount.value = sendMax.value ? (balance.value ?? 0) : effectiveAmount.value
successVerb.value = 'SENT'
successDetail.value = 'On-chain transaction broadcast'
successRef.value = res.txid
} else if (action.value === 'redeem-token') {
const res = await rpcClient.call<{ received_sats: number; kind: string }>({
method: 'wallet.ecash-receive',
params: { token: dest.value },
timeout: 60000,
})
rail.value = res.kind === 'fedimint' ? 'fedimint' : 'cashu'
successAmount.value = res.received_sats || 0
successVerb.value = 'RECEIVED'
successDetail.value = `${res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'} token redeemed`
successRef.value = ''
} else {
await rpcClient.call({
method: 'wallet.fedimint-join',
params: { invite_code: dest.value },
timeout: 60000,
})
successAmount.value = 0
successVerb.value = 'JOINED'
successDetail.value = 'Federation joined — you can now hold its ecash'
successRef.value = ''
}
await loadBalance()
goTo('success')
emit('sent')
} catch (err) {
error.value = err instanceof Error ? err.message : 'Send failed'
// Token redeems jump straight from scan; surface the failure on the amount pane
if (pane.value !== 'amount') goTo('amount')
} finally {
processing.value = false
}
}
// --- Lifecycle ---
function resetAll() {
stopScanning()
animatedDecoder.reset()
pane.value = 'scan'
direction.value = 'forward'
scanStatus.value = ''
scanStatusIsError.value = false
cameraError.value = false
pasteInput.value = ''
dest.value = ''
amount.value = 0
amountLocked.value = false
sendMax.value = false
balance.value = null
processing.value = false
error.value = ''
}
function close() {
resetAll()
emit('close')
}
watch(() => props.show, (open) => {
if (open) {
resetAll()
// No auto-start: the chooser interstitial owns the first move every time.
scanChoice.value = 'unset'
} else {
stopScanning()
}
})
onUnmounted(stopScanning)
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
}
/* Horizontal pane slide: forward = new pane arrives from the right */
.pane-forward-enter-active,
.pane-forward-leave-active,
.pane-back-enter-active,
.pane-back-leave-active {
transition: transform 0.22s ease, opacity 0.22s ease;
}
.pane-forward-enter-from {
transform: translateX(40px);
opacity: 0;
}
.pane-forward-leave-to {
transform: translateX(-40px);
opacity: 0;
}
.pane-back-enter-from {
transform: translateX(-40px);
opacity: 0;
}
.pane-back-leave-to {
transform: translateX(40px);
opacity: 0;
}
.scan-viewfinder {
width: 62%;
height: 62%;
border-radius: 1rem;
border: 2px solid rgba(249, 115, 22, 0.85);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
.success-ring {
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
}
</style>
@@ -0,0 +1,584 @@
<template>
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh]" @close="close">
<!-- Protocol tabs pinned via the header slot; only the pane below
scrolls (2026-07-22 modal contract). -->
<template #header>
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
</template>
<!-- ===================== Lightning Channels ===================== -->
<div v-show="activeTab === 'channels'">
<p class="text-white/60 text-sm mb-4">
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
</p>
<LightningChannelsPanel v-if="show" compact />
</div>
<!-- ===================== Cashu Mints ===================== -->
<div v-show="activeTab === 'cashu'">
<p class="text-white/60 text-sm mb-4">
Cashu ecash tokens can only be received from mints in this list. Add a mint's URL to accept tokens issued by it.
</p>
<div v-if="loadingMints" class="py-6 text-center text-white/50 text-sm">Loading mints…</div>
<template v-else>
<div class="space-y-2 mb-4">
<div
v-for="(mint, idx) in mints"
:key="mint + idx"
class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-purple-400 shrink-0" 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 font-mono text-white/90 truncate">{{ mint }}</span>
</div>
<button
@click="removeMint(idx)"
:disabled="mints.length <= 1"
class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-red-400 transition-colors disabled:opacity-30 disabled:hover:text-white/50 disabled:hover:bg-transparent shrink-0"
aria-label="Remove mint"
title="Remove mint"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<p v-if="mints.length === 0" class="text-white/40 text-sm text-center py-2">No mints configured.</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Add a mint</label>
<div class="flex gap-2">
<input
v-model="newMint"
type="text"
placeholder="https://mint.example.com"
class="flex-1 input-glass font-mono"
@keydown.enter.prevent="addMint"
/>
<button @click="addMint" class="glass-button px-4 py-2 rounded-lg text-sm font-medium shrink-0">Add</button>
</div>
</div>
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
</template>
</div>
<!-- ===================== Fedimint Federations ===================== -->
<div v-show="activeTab === 'fedimint'">
<div class="flex items-start gap-2 mb-4">
<p class="text-white/60 text-sm flex-1">
Join a Fedimint federation by pasting its invite code. Federated ecash is held by a group of guardians rather than a single mint.
</p>
<span v-if="!fedimintBackendReady" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Coming soon</span>
</div>
<!-- Joined federations -->
<div class="space-y-2 mb-4">
<div
v-for="fed in federations"
:key="fed.federation_id"
class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm text-white/90 truncate">{{ fed.name || fed.federation_id }}</p>
<p class="text-[11px] text-white/40 font-mono truncate">{{ fed.federation_id }}</p>
</div>
</div>
<span class="text-sm text-blue-400 font-medium shrink-0">{{ fed.balance_sats.toLocaleString() }} sats</span>
</div>
<p v-if="federations.length === 0" class="text-white/40 text-sm text-center py-2">No federations joined yet.</p>
</div>
<!-- Join by invite code -->
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Invite code</label>
<textarea
v-model="inviteCode"
rows="3"
:disabled="!fedimintBackendReady"
placeholder="fed11jpr3lgm8t…"
class="w-full input-glass font-mono disabled:opacity-50"
></textarea>
</div>
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
Joining federations lands with the Fedimint client backend.
</p>
</div>
<!-- ===================== On-chain ===================== -->
<div v-show="activeTab === 'onchain'">
<p class="text-white/60 text-sm mb-4">
Where "view transaction" links open. When the Mempool app is installed and running
on this node, it is always used — private, no third parties. Without it (pruned
Bitcoin nodes can't run Mempool), links open on the explorer below.
</p>
<label class="block text-sm text-white/80 mb-1">Transaction explorer</label>
<input
v-model="explorerUrlInput"
:placeholder="EXPLORER_PLACEHOLDER"
spellcheck="false"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
@change="saveExplorer"
/>
<p class="text-[11px] text-white/40 mt-1">
Any Mempool-compatible instance works. Default: tx1138.com.
</p>
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
Opening a transaction on an external explorer tells that server's operator which
transaction you're interested in, plus your IP address. Use a server you trust.
</div>
<label class="flex items-center gap-2 mt-4 text-sm text-white/70 cursor-pointer select-none">
<input type="checkbox" v-model="explorerAcknowledged" class="accent-orange-400" @change="saveExplorer" />
Don't warn me each time before opening the external explorer
</label>
</div>
<!-- ===================== Ark ===================== -->
<div v-show="activeTab === 'ark'">
<div class="flex items-start gap-2 mb-4">
<p class="text-white/60 text-sm flex-1">
Ark holds self-custodial off-chain bitcoin via an Ark server. Funds stay recoverable on-chain even if the server disappears.
</p>
<span v-if="arkStatus && !arkStatus.available" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Not installed</span>
<span v-else-if="arkStatus?.config?.network && arkStatus.config.network !== 'mainnet'" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-teal-500/15 text-teal-400">{{ arkStatus.config.network }}</span>
</div>
<div v-if="loadingArk" class="py-6 text-center text-white/50 text-sm">Checking Ark wallet…</div>
<template v-else>
<p v-if="arkStatus && !arkStatus.available" class="text-white/40 text-sm text-center py-2 mb-4">
Install the <span class="text-white/70">Ark Wallet</span> app from the app store to enable Ark payments.
</p>
<!-- Balances -->
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Pending</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
</div>
</div>
<!-- Receive address -->
<div v-if="arkStatus?.available" class="mb-4">
<div class="flex gap-2">
<button @click="fetchArkAddress(false)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">Ark address</button>
<button @click="fetchArkAddress(true)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">On-chain (boarding) address</button>
</div>
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<!-- Board / offboard -->
<div v-if="arkStatus?.available" class="flex gap-2 mb-4">
<button
@click="boardArk"
:disabled="arkBusy || (arkBalance?.onchain_sats ?? 0) === 0"
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
title="Lift on-chain funds into Ark"
>{{ arkBoarding ? 'Boarding' : 'Board on-chain funds' }}</button>
<button
@click="offboardArk"
:disabled="arkBusy || (arkBalance?.spendable_sats ?? 0) === 0"
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
title="Move all Ark funds back on-chain"
>{{ arkOffboarding ? 'Offboarding' : 'Offboard to on-chain' }}</button>
</div>
<!-- Server configuration -->
<div class="mb-3 space-y-2">
<label class="text-white/60 text-sm block">Ark server</label>
<input v-model="arkConfig.ark_server" type="text" placeholder="https://ark.signet.2nd.dev" class="w-full input-glass font-mono" />
<label class="text-white/60 text-sm block">Esplora (chain source)</label>
<input v-model="arkConfig.esplora" type="text" placeholder="https://esplora.signet.2nd.dev" class="w-full input-glass font-mono" />
<label class="text-white/60 text-sm block">Network</label>
<select v-model="arkConfig.network" class="w-full input-glass">
<option value="signet">signet</option>
<option value="mainnet">mainnet</option>
<option value="regtest">regtest</option>
</select>
<p class="text-[11px] text-white/40">
Applied when the Ark wallet is created — an existing wallet stays bound to its server.
</p>
</div>
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
</template>
</div>
<!-- Pinned footer (2026-07-22 modal contract): Close always, plus the
active tab's primary action the buttons never scroll away. -->
<template #footer>
<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
v-if="activeTab === 'cashu'"
@click="saveMints"
:disabled="savingMints || mints.length === 0"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingMints ? 'Saving…' : 'Save' }}</button>
<button
v-else-if="activeTab === 'fedimint'"
@click="joinFederation"
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ joiningFed ? 'Joining…' : 'Join federation' }}</button>
<button
v-else-if="activeTab === 'ark' && arkStatus?.available"
@click="saveArkConfig"
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>()
// Short labels on purpose — five tabs share one row ("Fedi", not
// "Fedimint Federations") so On-chain fits.
const tabs = [
{ key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu' },
{ key: 'fedimint' as const, label: 'Fedi' },
{ key: 'ark' as const, label: 'Ark' },
{ key: 'onchain' as const, label: 'On-chain' },
]
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark' | 'onchain'>('channels')
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
const fedimintBackendReady = true
// ---- On-chain: external transaction explorer ----
const txExplorer = useTxExplorer()
const explorerUrlInput = ref(txExplorer.prefs.value.url)
const explorerAcknowledged = ref(txExplorer.prefs.value.acknowledged)
function saveExplorer() {
txExplorer.setExplorer(explorerUrlInput.value, explorerAcknowledged.value)
// Reflect normalization (empty input falls back to the default).
explorerUrlInput.value = txExplorer.prefs.value.url
}
// ---- Cashu mints ----
const mints = ref<string[]>([])
const newMint = ref('')
const loadingMints = ref(false)
const savingMints = ref(false)
const mintError = ref('')
const mintsSavedOk = ref(false)
// ---- Fedimint federations ----
interface Federation {
federation_id: string
name?: string
balance_sats: number
}
const federations = ref<Federation[]>([])
const inviteCode = ref('')
const joiningFed = ref(false)
const fedError = ref('')
const fedJoinedOk = ref(false)
// ---- Ark (barkd sidecar, wallet.ark-* RPCs) ----
interface ArkStatus {
available: boolean
wallet_ready: boolean
config?: { network: string; ark_server: string; esplora: string }
}
interface ArkBalance {
spendable_sats: number
pending_sats: number
onchain_sats: number
}
const arkStatus = ref<ArkStatus | null>(null)
const arkBalance = ref<ArkBalance | null>(null)
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
const arkAddress = ref('')
const arkCopied = ref(false)
const loadingArk = ref(false)
const savingArk = ref(false)
const arkBoarding = ref(false)
const arkOffboarding = ref(false)
const arkError = ref('')
const arkOk = ref('')
const arkBusy = computed(() => savingArk.value || arkBoarding.value || arkOffboarding.value)
watch(
() => props.show,
(open) => {
if (open) {
loadMints()
if (fedimintBackendReady) loadFederations()
loadArk()
}
},
)
async function loadMints() {
loadingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
newMint.value = ''
try {
const res = await rpcClient.call<{ mints: string[] }>({ method: 'streaming.list-mints' })
mints.value = res.mints || []
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to load mints'
mints.value = []
} finally {
loadingMints.value = false
}
}
function addMint() {
mintError.value = ''
mintsSavedOk.value = false
const url = newMint.value.trim().replace(/\/+$/, '')
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
mintError.value = 'Mint URL must start with http:// or https://'
return
}
if (mints.value.some((m) => m.replace(/\/+$/, '') === url)) {
mintError.value = 'That mint is already in the list'
return
}
mints.value.push(url)
newMint.value = ''
}
function removeMint(idx: number) {
if (mints.value.length <= 1) return
mints.value.splice(idx, 1)
mintsSavedOk.value = false
}
async function saveMints() {
if (mints.value.length === 0) return
savingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
try {
await rpcClient.call<{ mints: string[]; updated: boolean }>({
method: 'streaming.configure-mints',
params: { mints: mints.value },
})
mintsSavedOk.value = true
emit('changed')
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to save mints'
} finally {
savingMints.value = false
}
}
async function loadFederations() {
fedError.value = ''
try {
const res = await rpcClient.call<{ federations: Federation[] }>({ method: 'wallet.fedimint-list' })
federations.value = res.federations || []
} catch {
federations.value = []
}
}
async function joinFederation() {
if (!fedimintBackendReady || !inviteCode.value.trim()) return
const before = federations.value.length
joiningFed.value = true
fedError.value = ''
fedJoinedOk.value = false
try {
await rpcClient.call<{ federation_id: string }>({
method: 'wallet.fedimint-join',
params: { invite_code: inviteCode.value.trim() },
// Joining a federation is heavy (downloads the federation config + joins
// the consensus); it routinely takes longer than the default 15s. Give it
// headroom past the backend's own 60s clientd timeout.
timeout: 90000,
})
inviteCode.value = ''
await loadFederations()
fedJoinedOk.value = true
emit('changed')
} catch (err: unknown) {
// A slow join often still completes server-side after the client gives up,
// so don't cry failure blindly — re-check the list. If a new federation
// appeared, the join actually worked; surface success instead of a scary
// (and wrong) timeout error.
await loadFederations()
if (federations.value.length > before) {
inviteCode.value = ''
fedJoinedOk.value = true
emit('changed')
} else {
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
}
} finally {
joiningFed.value = false
}
}
async function loadArk() {
loadingArk.value = true
arkError.value = ''
arkOk.value = ''
arkAddress.value = ''
try {
const status = await rpcClient.call<ArkStatus>({ method: 'wallet.ark-status' })
arkStatus.value = status
if (status.config) arkConfig.value = { ...status.config }
if (status.available) {
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
} else {
arkBalance.value = null
}
} catch {
arkStatus.value = { available: false, wallet_ready: false }
arkBalance.value = null
} finally {
loadingArk.value = false
}
}
async function fetchArkAddress(onchain: boolean) {
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ address: string }>({
method: 'wallet.ark-address',
params: { onchain },
})
arkAddress.value = res.address
arkCopied.value = false
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
}
}
async function copyArkAddress() {
if (!arkAddress.value) return
try {
await navigator.clipboard.writeText(arkAddress.value)
arkCopied.value = true
} catch {
/* clipboard unavailable (http) — the address is selectable */
}
}
async function boardArk() {
arkBoarding.value = true
arkError.value = ''
arkOk.value = ''
try {
// Boards everything; waits on Ark round participation, so give it room.
await rpcClient.call({ method: 'wallet.ark-board', timeout: 130000 })
arkOk.value = 'Boarding started — funds appear as spendable once the round confirms.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to board funds'
} finally {
arkBoarding.value = false
}
}
async function offboardArk() {
arkOffboarding.value = true
arkError.value = ''
arkOk.value = ''
try {
await rpcClient.call({ method: 'wallet.ark-offboard', timeout: 130000 })
arkOk.value = 'Offboard requested — funds return on-chain with the next round.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to offboard funds'
} finally {
arkOffboarding.value = false
}
}
async function saveArkConfig() {
savingArk.value = true
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ config: ArkStatus['config'] }>({
method: 'wallet.ark-configure',
params: { ...arkConfig.value },
})
if (res.config) arkConfig.value = { ...res.config }
arkOk.value = 'Ark configuration saved.'
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to save Ark configuration'
} finally {
savingArk.value = false
}
}
function close() {
mintError.value = ''
mintsSavedOk.value = false
fedError.value = ''
arkError.value = ''
arkOk.value = ''
emit('close')
}
</script>
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
afterEach(() => {
document.body.style.overflow = ''
})
it('locks page scroll while open and restores it when closed', async () => {
const wrapper = mount(BaseModal, {
props: { show: true, title: 'Test modal' },
slots: { default: '<p>Modal content</p>' },
attachTo: document.body,
})
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('')
wrapper.unmount()
})
})
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import LineChart from '../LineChart.vue'
// Mock canvas context
const mockContext = {
clearRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
closePath: vi.fn(),
setLineDash: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
scale: vi.fn(),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn(),
}),
canvas: { width: 600, height: 200 },
strokeStyle: '',
fillStyle: '',
lineWidth: 0,
font: '',
textAlign: '',
textBaseline: '',
globalAlpha: 1,
}
beforeEach(() => {
vi.clearAllMocks()
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockContext)
})
describe('LineChart', () => {
const sampleDatasets = [
{ label: 'CPU', data: [10, 20, 30, 40, 50], color: '#fb923c' },
]
it('renders a canvas element', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('accepts datasets prop', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.exists()).toBe(true)
})
it('renders with empty datasets', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: [] },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('renders with multiple datasets', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [
{ label: 'CPU', data: [10, 20, 30], color: '#fb923c' },
{ label: 'Memory', data: [50, 60, 70], color: '#4ade80' },
],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts optional height and width props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
height: 300,
width: 600,
},
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('600')
expect(canvas.attributes('height')).toBe('300')
})
it('uses default width of 400 and height of 180', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('400')
expect(canvas.attributes('height')).toBe('180')
})
it('renders with dataset containing single data point', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [{ label: 'Test', data: [42], color: '#3b82f6' }],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts yMax and yLabel props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
yMax: 100,
yLabel: 'Percent',
},
})
expect(wrapper.exists()).toBe(true)
})
})
@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshMap from '../MeshMap.vue'
const meshState = vi.hoisted(() => ({
nodePositions: new Map(),
federatedPositions: new Map(),
peers: [],
status: null,
deadmanStatus: null,
updateSelfPosition: vi.fn(),
}))
vi.mock('@/stores/mesh', () => ({
useMeshStore: () => meshState,
}))
vi.mock('leaflet', () => ({
default: {
map: vi.fn(() => ({
invalidateSize: vi.fn(),
fitBounds: vi.fn(),
remove: vi.fn(),
})),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
divIcon: vi.fn((opts) => opts),
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
polyline: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
},
}))
describe('MeshMap', () => {
beforeEach(() => {
meshState.nodePositions.clear()
meshState.federatedPositions.clear()
meshState.peers = []
meshState.status = null
meshState.deadmanStatus = null
meshState.updateSelfPosition.mockClear()
})
it('treats denied browser location as optional for peer positions', async () => {
let errorHandler!: (error: { code: number; message: string }) => void
const watchPosition = vi.fn((_success, error) => {
errorHandler = error
return 7
})
const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
}))
vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch },
})
vi.stubGlobal('ResizeObserver', resizeObserver)
const wrapper = mount(MeshMap)
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
await wrapper.get('[role="switch"]').trigger('click')
errorHandler({ code: 1, message: 'denied' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
expect(wrapper.text()).not.toContain('location sharing is required')
vi.unstubAllGlobals()
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import PWAInstallPrompt from '../PWAInstallPrompt.vue'
describe('PWAInstallPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
// Mock matchMedia to return non-standalone
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})
it('renders without errors', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.exists()).toBe(true)
})
it('does not show prompt initially', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('shows prompt after beforeinstallprompt event', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire the beforeinstallprompt event
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Install Archipelago')
})
it('hides prompt when dismissed', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Show prompt
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
// Click dismiss button
const dismissBtn = wrapper.findAll('button').find(b => b.text().includes('Not now'))
expect(dismissBtn).toBeDefined()
await dismissBtn!.trigger('click')
expect(sessionStorage.getItem('archipelago_pwa_install_dismissed')).toBe('1')
})
it('does not show if already dismissed this session', async () => {
sessionStorage.setItem('archipelago_pwa_install_dismissed', '1')
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire beforeinstallprompt — should not show
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('does not show in standalone mode', async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
})
@@ -0,0 +1,99 @@
<template>
<div class="cloud-toolbar">
<!-- Breadcrumbs -->
<nav class="cloud-breadcrumbs">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="cloud-breadcrumb-item"
:class="{ 'cloud-breadcrumb-active': i === breadcrumbs.length - 1 }"
@click="i < breadcrumbs.length - 1 && $emit('navigate', crumb.path)"
>
<svg v-if="i === 0" 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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span v-else>{{ crumb.name }}</span>
<svg v-if="i < breadcrumbs.length - 1" class="w-3 h-3 text-white/30 mx-1" 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>
</button>
</nav>
<!-- Actions -->
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="cloud-view-toggle">
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'grid' }"
title="Grid view"
@click="$emit('update:viewMode', 'grid')"
>
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'list' }"
title="List view"
@click="$emit('update:viewMode', 'list')"
>
<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="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden md:inline">Upload</span>
</button>
<button class="glass-button cloud-toolbar-btn" title="Refresh" @click="$emit('refresh')">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
@change="handleFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid'
}>()
const emit = defineEmits<{
navigate: [path: string]
refresh: []
upload: [files: File[]]
'update:viewMode': [mode: 'list' | 'grid']
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function triggerUpload() {
fileInput.value?.click()
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files.length > 0) {
emit('upload', Array.from(input.files))
input.value = ''
}
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<button
class="cloud-file-item group"
data-controller-container
data-controller-primary
tabindex="0"
@click="handleClick"
>
<!-- Thumbnail / Icon -->
<div class="cloud-file-item-thumb">
<img
v-if="isImage && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<div v-else class="w-full h-full rounded-[6px] flex items-center justify-center bg-white/8">
<svg class="w-5 h-5" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(d, i) in iconPaths"
:key="i"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="d"
/>
</svg>
</div>
</div>
<!-- Content -->
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate text-white/90">{{ item.name }}</p>
<p class="text-xs mt-0.5 text-white/40">
<span v-if="!item.isDir">{{ formatSize(item.size) }}</span>
<span v-if="!item.isDir"> · </span>
<span>{{ formatDate(item.modified) }}</span>
</p>
<!-- Type badge -->
<div class="flex items-center gap-1.5 mt-1.5">
<span class="cloud-file-badge" :class="badgeClass">
{{ badgeLabel }}
</span>
<span v-if="item.extension && !item.isDir" class="cloud-file-badge bg-white/8 text-white/50">
.{{ item.extension }}
</span>
</div>
</div>
<!-- Actions -->
<div class="cloud-file-item-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="$emit('share', item.path, item.name, item.isDir)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="$emit('delete', item.path)"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<svg v-if="item.isDir" class="w-4 h-4 text-white/30" 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>
</div>
</button>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
const props = defineProps<{
item: FileBrowserItem
}>()
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
play: [path: string, name: string]
}>()
const cloudStore = useCloudStore()
const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { isImage, isVideo, isAudio, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (!isImage.value || imgFailed.value) return null
return cloudStore.downloadUrl(props.item.path)
})
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isAudio.value) {
// Music goes to the global bottom-bar player, not the lightbox.
emit('play', props.item.path, props.item.name)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path)
} else {
// Non-media files open by downloading — a click should always act on the file.
window.location.href = downloadHref.value
}
}
</script>
@@ -0,0 +1,180 @@
<template>
<button
class="cloud-grid-card group"
data-controller-container
tabindex="0"
@click="handleClick"
>
<!-- Cover / Thumbnail area -->
<div class="cloud-grid-card-cover">
<!-- Image thumbnail -->
<img
v-if="isImage && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="imgFailed = true"
/>
<!-- Video thumbnail (try to show, fallback to icon) -->
<img
v-else-if="isVideo && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="imgFailed = true"
/>
<!-- Icon fallback -->
<div v-else class="w-full h-full flex items-center justify-center" :class="coverBg">
<svg class="w-10 h-10" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(d, i) in iconPaths"
:key="i"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="d"
/>
</svg>
</div>
<!-- Gradient overlay -->
<div class="cloud-grid-card-gradient"></div>
<!-- Play button overlay for audio/video -->
<div
v-if="isAudio || isVideo"
class="cloud-grid-card-play"
:class="{ 'cloud-grid-card-play-active': isCurrentlyPlaying }"
@click.stop="isVideo ? emit('preview', item.path) : emit('play', item.path, item.name)"
>
<span class="cloud-grid-card-play-btn">
<svg v-if="!isCurrentlyPlaying" class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</span>
</div>
<!-- Info overlay at bottom -->
<div class="cloud-grid-card-info">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ item.name }}
</p>
<div class="flex items-center gap-1 mt-0.5">
<span v-if="!item.isDir" class="text-xs text-white/40">{{ formatSize(item.size) }}</span>
<span v-if="!item.isDir" class="text-xs text-white/40">·</span>
<span class="text-xs text-white/40">{{ formatDate(item.modified) }}</span>
</div>
</div>
<!-- Badge at top-right -->
<div class="cloud-grid-card-badges">
<span class="cloud-grid-card-badge" :class="badgeClass">
{{ badgeLabel }}
</span>
</div>
<!-- Actions overlay at top-left (visible on hover) -->
<div class="cloud-grid-card-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="emit('share', item.path, item.name, item.isDir)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="emit('delete', item.path)"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const props = defineProps<{
item: FileBrowserItem
}>()
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
}>()
const cloudStore = useCloudStore()
const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (imgFailed.value) return null
if (isImage.value || isVideo.value) return cloudStore.downloadUrl(props.item.path)
return null
})
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
// Uniform card cover ratio across every file type so folders, images, videos
// and documents all render at the same height in the grid (previously images/
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
// Aspect is now driven entirely by .cloud-grid-card-cover CSS (4/3 desktop,
// square on mobile) so the ratio is deterministic regardless of Tailwind layer
// ordering.
const coverBg = computed(() => {
if (props.item.isDir) return 'bg-amber-500/10'
if (isAudio.value) return 'bg-orange-500/10'
if (isVideo.value) return 'bg-purple-500/10'
if (isImage.value) return 'bg-blue-500/10'
if (category.value === 'document') return 'bg-green-500/10'
if (category.value === 'spreadsheet') return 'bg-emerald-500/10'
if (category.value === 'archive') return 'bg-yellow-500/10'
return 'bg-white/5'
})
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path)
}
}
</script>
@@ -0,0 +1,84 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto">
<!-- Loading skeleton -->
<div v-if="loading" :class="viewMode === 'grid' ? 'cloud-card-grid' : 'cloud-file-list'">
<div
v-for="i in 6"
:key="i"
:class="viewMode === 'grid' ? 'cloud-grid-card-skeleton' : 'cloud-file-item cloud-file-item-skeleton'"
>
<template v-if="viewMode === 'grid'">
<div class="aspect-square rounded-[10px] bg-white/8 animate-pulse"></div>
</template>
<template v-else>
<div class="cloud-file-item-thumb">
<div class="w-full h-full rounded-[6px] bg-white/8 animate-pulse"></div>
</div>
<div class="min-w-0 flex-1 py-0.5">
<div class="h-4 w-32 rounded bg-white/8 animate-pulse mb-1.5"></div>
<div class="h-3 w-20 rounded bg-white/5 animate-pulse"></div>
</div>
</template>
</div>
</div>
<!-- Empty state -->
<div v-else-if="items.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<svg class="w-16 h-16 text-white/10 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p class="text-white/50 text-sm">This folder is empty</p>
<p class="text-white/30 text-xs mt-1">Upload files to get started</p>
</div>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="cloud-card-grid">
<FileCardGrid
v-for="item in items"
:key="item.path"
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
@preview="$emit('preview', $event)"
/>
</div>
<!-- List view -->
<div v-else class="cloud-file-list">
<FileCard
v-for="item in items"
:key="item.path"
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
@preview="$emit('preview', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { FileBrowserItem } from '@/api/filebrowser-client'
import FileCard from './FileCard.vue'
import FileCardGrid from './FileCardGrid.vue'
withDefaults(defineProps<{
items: FileBrowserItem[]
loading: boolean
viewMode?: 'list' | 'grid'
}>(), {
viewMode: 'grid',
})
defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
}>()
</script>
@@ -0,0 +1,449 @@
<template>
<Teleport to="body">
<Transition name="lightbox-fade">
<div
v-if="show"
class="lightbox-backdrop"
@click.self="close"
@keydown="onKeydown"
tabindex="0"
ref="backdropEl"
>
<!-- Top bar -->
<div class="lightbox-topbar">
<div class="flex items-center gap-3 min-w-0">
<span v-if="mediaItems.length > 1" class="text-sm text-white/50">
{{ currentIndex + 1 }} / {{ mediaItems.length }}
</span>
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
</div>
<div class="flex items-center gap-1">
<button
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture"
@click.stop="togglePip(videoEl)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<button class="lightbox-btn" @click="close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Navigation arrows -->
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-prev"
@click.stop="prev"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-next"
@click.stop="next"
>
<svg class="w-7 h-7" 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>
</button>
<!-- Media content -->
<div class="lightbox-content" @click.stop>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="w-12 h-12 border-3 border-white/10 border-t-white/70 rounded-full animate-spin"></div>
</div>
<!-- Image -->
<img
v-else-if="currentItem && currentUrl && isImageFile(currentItem)"
:src="currentUrl"
:alt="currentItem.name"
class="lightbox-media-img"
@error="onMediaError"
/>
<!-- Video -->
<video
v-else-if="currentItem && currentUrl && isVideoFile(currentItem)"
ref="videoEl"
:src="currentUrl"
:key="currentUrl"
class="lightbox-media-video"
controls
autoplay
@dblclick="toggleFullscreen"
@error="onMediaError"
/>
<!-- Audio -->
<div
v-else-if="currentItem && currentUrl && isAudioFile(currentItem)"
class="lightbox-audio-container"
>
<div class="lightbox-audio-artwork">
<svg class="w-20 h-20 text-orange-400/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<p class="text-white/70 text-sm mt-4 truncate max-w-xs">{{ currentItem.name }}</p>
<audio
:src="currentUrl"
:key="currentUrl"
controls
autoplay
class="lightbox-audio-player"
@error="onMediaError"
/>
</div>
<!-- Error -->
<div v-else-if="mediaError" class="flex flex-col items-center justify-center gap-3">
<svg class="w-12 h-12 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-white/40 text-sm">Failed to load media</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
import { pipSupported, togglePip } from '@/utils/pip'
const props = defineProps<{
items: FileBrowserItem[]
startIndex: number
show: boolean
fetchBlobUrl: (path: string) => Promise<string>
streamUrl?: (path: string) => Promise<string>
}>()
const emit = defineEmits<{
close: []
}>()
const currentIndex = ref(0)
const loading = ref(false)
const mediaError = ref(false)
const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
const videoEl = ref<HTMLVideoElement | null>(null)
const urlCache = new Map<string, string>()
const mediaItems = computed(() =>
props.items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
)
const currentItem = computed(() => mediaItems.value[currentIndex.value] ?? null)
function isImageFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'image'
}
function isVideoFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'video'
}
function isAudioFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'audio'
}
async function loadMedia(item: FileBrowserItem) {
loading.value = true
mediaError.value = false
currentUrl.value = null
try {
const cached = urlCache.get(item.path)
if (cached) {
currentUrl.value = cached
} else {
// Use streaming URL for video/audio (avoids downloading entire file into blob)
// Use blob URL for images (needed for rendering)
const isStreamable = isVideoFile(item) || isAudioFile(item)
if (isStreamable && props.streamUrl) {
const url = await props.streamUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
} else {
const url = await props.fetchBlobUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
}
}
} catch {
mediaError.value = true
} finally {
loading.value = false
}
}
function preloadAdjacent() {
const items = mediaItems.value
if (items.length <= 1) return
const prevIdx = (currentIndex.value - 1 + items.length) % items.length
const nextIdx = (currentIndex.value + 1) % items.length
for (const idx of [prevIdx, nextIdx]) {
const item = items[idx]
if (item && !urlCache.has(item.path) && isImageFile(item)) {
props.fetchBlobUrl(item.path).then(url => {
urlCache.set(item.path, url)
}).catch(() => {})
}
}
}
function prev() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value - 1 + len) % len
}
function next() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value + 1) % len
}
function close() {
emit('close')
}
function toggleFullscreen() {
const el = videoEl.value
if (!el) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
el.requestFullscreen().catch(() => {})
}
}
function onMediaError() {
mediaError.value = true
loading.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
}
watch(currentItem, (item) => {
if (item) {
loadMedia(item)
preloadAdjacent()
}
})
watch(() => props.show, async (visible) => {
if (visible) {
currentIndex.value = props.startIndex
const item = mediaItems.value[props.startIndex]
if (item) {
await loadMedia(item)
preloadAdjacent()
}
await nextTick()
backdropEl.value?.focus()
}
}, { immediate: true })
onUnmounted(() => {
for (const url of urlCache.values()) {
URL.revokeObjectURL(url)
}
urlCache.clear()
})
</script>
<style scoped>
.lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
outline: none;
}
.lightbox-topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 100%);
z-index: 10;
}
.lightbox-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s;
cursor: pointer;
}
.lightbox-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 3rem;
height: 3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s;
cursor: pointer;
z-index: 10;
}
.lightbox-nav:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
border-color: rgba(255, 255, 255, 0.15);
}
.lightbox-nav-prev { left: 1rem; }
.lightbox-nav-next { right: 1rem; }
.lightbox-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 3.5rem 1rem;
}
.lightbox-media-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
}
.lightbox-media-video {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 0;
background: black;
cursor: pointer;
}
.lightbox-audio-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
}
.lightbox-audio-artwork {
width: 12rem;
height: 12rem;
border-radius: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(251, 146, 60, 0.12) 0%, rgba(251, 146, 60, 0.04) 100%);
border: 1px solid rgba(251, 146, 60, 0.15);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.lightbox-audio-player {
width: 100%;
max-width: 24rem;
margin-top: 1rem;
border-radius: 2rem;
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9);
}
/* Transitions */
.lightbox-fade-enter-active,
.lightbox-fade-leave-active {
transition: opacity 0.25s ease;
}
.lightbox-fade-enter-from,
.lightbox-fade-leave-to {
opacity: 0;
}
/* Mobile */
@media (max-width: 768px) {
.lightbox-content { padding: 3rem 0; }
.lightbox-nav { width: 2.5rem; height: 2.5rem; }
.lightbox-nav-prev { left: 0.5rem; }
.lightbox-nav-next { right: 0.5rem; }
.lightbox-audio-artwork { width: 8rem; height: 8rem; }
.lightbox-media-video { border-radius: 0; }
/* The close button used to sit in the top bar, which lands under the
status bar / notch safe area on most phones and is awkward to reach.
Detach it from the top bar and pin it bottom-center, under the media,
for mobile only — desktop keeps it in the top bar. */
.lightbox-topbar { padding-right: 1rem; }
.lightbox-topbar .lightbox-btn {
position: fixed;
top: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
left: 50%;
right: auto;
transform: translateX(-50%);
z-index: 20;
}
}
</style>
@@ -0,0 +1,402 @@
<template>
<Teleport to="body">
<div class="share-modal-backdrop" @click.self="$emit('close')">
<div class="share-modal glass-card">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-orange-500/15 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">Share with Peers</h3>
<p class="text-xs text-white/50 truncate max-w-[200px]">{{ filename }}</p>
</div>
</div>
<button class="share-modal-close" @click="$emit('close')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Share Toggle -->
<div class="share-modal-row">
<div class="flex-1">
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
</div>
<ToggleSwitch v-model="shared" />
</div>
<!-- Access Type (only when shared) -->
<div v-if="shared" class="mt-4 space-y-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider">Access Type</p>
<div class="share-access-options">
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'free' }"
@click="accessType = 'free'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium">Free</span>
<span class="text-xs text-white/40">Open access</span>
</button>
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'peers_only' }"
@click="accessType = 'peers_only'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span class="text-sm font-medium">Peers Only</span>
<span class="text-xs text-white/40">Authenticated</span>
</button>
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'paid' }"
@click="accessType = 'paid'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-sm font-medium">Paid</span>
<span class="text-xs text-white/40">Earn sats</span>
</button>
</div>
<!-- Price Input (only for paid) -->
<div v-if="accessType === 'paid'" class="share-price-input-wrap">
<div class="share-price-icon">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<input
v-model.number="priceSats"
type="number"
min="1"
max="1000000"
placeholder="Price in sats"
class="share-price-input"
/>
<span class="share-price-unit">sats</span>
</div>
<!-- Accepted payment methods (only for paid) gated on what this
node can actually receive; unavailable rails are disabled with
an that explains how to enable them. -->
<div v-if="accessType === 'paid'" class="mt-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
<div class="space-y-2">
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
<div class="flex-1 flex items-center gap-2 min-w-0">
<p class="text-sm text-white/90">{{ m.label }}</p>
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking</span>
<button
v-else-if="!capability[m.key]"
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
title="Why is this unavailable?"
@click="adviceFor = m.key"
>i</button>
</div>
<ToggleSwitch
:model-value="acceptedSet.has(m.key)"
:disabled="!capability[m.key]"
:aria-label="`Accept ${m.label}`"
@update:model-value="toggleMethod(m.key, $event)"
/>
</div>
</div>
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
Pick at least one payment method buyers can use.
</p>
</div>
</div>
<!-- Advice modal for an unavailable payment method -->
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
</div>
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
</ul>
</div>
<!-- Status messages -->
<div v-if="saving" class="share-modal-status mt-4">
<div class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/60">Saving...</span>
</div>
<div v-if="errorMsg" class="share-modal-error mt-4">
<span class="text-sm text-red-400">{{ errorMsg }}</span>
</div>
<div v-if="successMsg" class="share-modal-success mt-4">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm text-green-400">{{ successMsg }}</span>
</div>
<!-- Action -->
<div class="flex justify-end gap-3 mt-5">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
<button
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
@click="save"
>
{{ shared ? 'Share' : 'Stop Sharing' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const props = defineProps<{
filename: string
filepath: string
isDir: boolean
existingItemId?: string
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const shared = ref(false)
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
const priceSats = ref<number>(100)
const saving = ref(false)
const errorMsg = ref<string | null>(null)
const successMsg = ref<string | null>(null)
// --- Accepted payment methods, gated on what this node can actually receive ---
const PAY_METHODS = [
{ key: 'lightning', label: 'Lightning' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'ecash', label: 'Cashu ecash' },
{ key: 'fedimint', label: 'Fedimint' },
] as const
type PayMethod = (typeof PAY_METHODS)[number]['key']
// undefined = probe in flight; then true/false per method.
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
const acceptedSet = ref<Set<PayMethod>>(new Set())
const adviceFor = ref<PayMethod | null>(null)
// Only default-select capable methods when the item had no saved list.
let acceptedLoadedFromItem = false
function toggleMethod(key: PayMethod, on: boolean) {
const next = new Set(acceptedSet.value)
if (on) next.add(key)
else next.delete(key)
acceptedSet.value = next
}
/** Probe the node's rails and build advice for the unavailable ones. */
async function probeCapabilities() {
// Lightning + on-chain both live on LND.
try {
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
method: 'lnd.getinfo', timeout: 8000,
})
capability.value.onchain = true
const channels = info?.num_active_channels ?? 0
capability.value.lightning = channels > 0
if (channels === 0) {
adviceLines.value.lightning = [
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
'Once the channel is active, come back and enable Lightning here.',
]
}
} catch {
capability.value.lightning = false
capability.value.onchain = false
adviceLines.value.lightning = [
'The Lightning (LND) app isn\'t running on this node.',
'Install/start Lightning from the App Store, let it sync, then open a channel.',
]
adviceLines.value.onchain = [
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
'Install/start Lightning from the App Store — no channel needed for on-chain.',
]
}
try {
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
capability.value.ecash = true
} catch {
capability.value.ecash = false
adviceLines.value.ecash = [
'The Cashu ecash wallet isn\'t set up on this node.',
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
]
}
try {
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
capability.value.fedimint = true
} catch {
capability.value.fedimint = false
adviceLines.value.fedimint = [
'This node hasn\'t joined a Fedimint federation.',
'Install the Fedimint app and join (or create) a federation, then enable it here.',
]
}
// Defaults: everything the node can receive — unless the item already
// carried an explicit list. Never auto-enable an incapable rail.
if (!acceptedLoadedFromItem) {
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
} else {
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
}
}
// If we have an existing item, load its state
/** Catalog entries store the slash-stripped path; props carry a leading
* slash (filepath) or just the basename (filename). Normalize both sides —
* the old exact compare never matched, so every re-share created a brand
* new priced entry and buyers could pay twice for one file (2026-07-22). */
function matchesThisFile(catalogFilename: string): boolean {
const strip = (v: string) => v.replace(/^\/+/, '')
return (
strip(catalogFilename) === strip(props.filepath || '') ||
strip(catalogFilename) === strip(props.filename || '')
)
}
onMounted(async () => {
try {
const res = await rpcClient.call<{ items: Array<{
id: string
filename: string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
availability: string | { allpeers?: unknown; nobody?: unknown }
}> }>({ method: 'content.list-mine' })
const match = res.items.find(
(i) => matchesThisFile(i.filename)
)
if (match) {
shared.value = true
const access = match.access
if (typeof access === 'string') {
if (access === 'free') accessType.value = 'free'
else if (access === 'peersonly') accessType.value = 'peers_only'
} else if (access && typeof access === 'object') {
if ('paid' in access && access.paid) {
accessType.value = 'paid'
priceSats.value = access.paid.price_sats || 100
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
acceptedLoadedFromItem = true
acceptedSet.value = new Set(
access.paid.accepted.filter((m): m is PayMethod =>
PAY_METHODS.some((p) => p.key === m),
),
)
}
} else if ('peersonly' in access) {
accessType.value = 'peers_only'
}
}
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
}
void probeCapabilities()
})
async function save() {
saving.value = true
errorMsg.value = null
successMsg.value = null
try {
if (!shared.value) {
// Find and remove from catalog
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
method: 'content.list-mine',
})
const match = res.items.find(
(i) => matchesThisFile(i.filename)
)
if (match) {
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
}
successMsg.value = 'Sharing disabled'
} else {
// Check if already in catalog
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
method: 'content.list-mine',
})
let itemId = res.items.find(
(i) => matchesThisFile(i.filename)
)?.id
// Add if not in catalog
if (!itemId) {
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
const mimeMap: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', wav: 'audio/wav',
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
}
const addRes = await rpcClient.call<{ item: { id: string } }>({
method: 'content.add',
params: {
filename: (props.filepath || props.filename).replace(/^\/+/, ''),
mime_type: mimeMap[ext] || 'application/octet-stream',
description: '',
},
})
itemId = addRes.item.id
}
// Set pricing
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value
pricingParams.accepted_methods = [...acceptedSet.value]
}
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
// Set availability to all peers
await rpcClient.call({
method: 'content.set-availability',
params: { id: itemId, availability: 'all_peers' },
})
const label =
accessType.value === 'paid'
? `Shared for ${priceSats.value} sats`
: accessType.value === 'peers_only'
? 'Shared with peers'
: 'Shared (free)'
successMsg.value = label
}
setTimeout(() => emit('saved'), 800)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,182 @@
<template>
<div ref="containerRef" class="network-map-container">
<svg ref="svgRef" class="w-full h-full"></svg>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
did: string
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
app_count: number
is_self: boolean
}
interface MapLink {
source: string
target: string
}
const props = defineProps<{
nodes: MapNode[]
links: MapLink[]
}>()
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
type SimNode = MapNode & d3.SimulationNodeDatum
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
case 'observer': return '#fb923c'
case 'untrusted': return '#ef4444'
default: return '#9ca3af'
}
}
function nodeRadius(n: MapNode): number {
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
const container = containerRef.value!
const width = container.clientWidth
const height = container.clientHeight
svg.attr('viewBox', `0 0 ${width} ${height}`)
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
// Center the self-node
const selfNode = simNodes.find(n => n.is_self)
if (selfNode) {
selfNode.fx = width / 2
selfNode.fy = height / 2
}
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
const g = svg.append('g')
// Links
const link = g.append('g')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
})
.attr('stroke-width', 2)
.attr('stroke-dasharray', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
})
// Node groups
const node = g.append('g')
.selectAll<SVGGElement, SimNode>('g')
.data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) simulation!.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation!.alphaTarget(0)
if (!d.is_self) { d.fx = null; d.fy = null }
})
)
// Node circles
node.append('circle')
.attr('r', d => nodeRadius(d))
.attr('fill', d => trustColor(d.trust_level))
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
// Node labels
node.append('text')
.text(d => d.label)
.attr('dy', d => nodeRadius(d) + 14)
.attr('text-anchor', 'middle')
.attr('fill', 'rgba(255,255,255,0.7)')
.attr('font-size', '11px')
.attr('font-family', "'Avenir Next', sans-serif")
// Tooltip
node.append('title')
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimNode).x!)
.attr('y1', d => (d.source as SimNode).y!)
.attr('x2', d => (d.target as SimNode).x!)
.attr('y2', d => (d.target as SimNode).y!)
node.attr('transform', d => `translate(${d.x},${d.y})`)
})
}
onMounted(() => {
render()
resizeObserver = new ResizeObserver(() => render())
if (containerRef.value) resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
simulation?.stop()
resizeObserver?.disconnect()
})
watch(graphSignature, () => render())
</script>
<style scoped>
.network-map-container {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
min-height: 400px;
width: 100%;
}
</style>
@@ -0,0 +1,66 @@
<template>
<BaseModal
:show="show"
title="Request to Peer"
max-width="max-w-md"
:z-index="'z-[3200]'"
@close="$emit('cancel')"
>
<p class="text-white/70 text-sm mb-1">
Send a connection request to
<span class="text-white font-medium">{{ targetLabel }}</span>
</p>
<p class="text-white/45 text-xs mb-4">
They'll see your request and approve or decline it. Approved peers connect
at the Peer level — never trusted automatically.
</p>
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
<textarea
v-model="message"
rows="3"
maxlength="280"
placeholder="Hey — mind if we peer?"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60 resize-none"
></textarea>
<p class="text-[11px] text-white/30 text-right mt-1">{{ message.length }}/280</p>
<div class="flex gap-2 mt-4">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('cancel')">
Cancel
</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="sending"
@click="$emit('send', message.trim() || undefined)"
>
{{ sending ? 'Sending' : 'Send Request' }}
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
const props = defineProps<{
show: boolean
targetLabel: string
sending?: boolean
}>()
defineEmits<{
send: [message: string | undefined]
cancel: []
}>()
const message = ref('')
watch(
() => props.show,
(visible) => {
if (visible) message.value = ''
},
)
</script>
@@ -0,0 +1,583 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Apply Archipelago Settings' : 'Flash Firmware'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
>
<!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-40 h-40 relative">
<!-- pulsing signal waves behind the board image -->
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
<path d="M130 44 a30 30 0 0 1 0 28" />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
<span class="font-mono text-orange-300">{{ devicePath }}</span>
</p>
<!-- What's currently flashed / configured on it -->
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
Reading what's on the radio…
</div>
<template v-else-if="probe">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded-md text-[11px] font-semibold"
:class="{
'bg-emerald-400/15 text-emerald-300': probe.kind === 'meshcore',
'bg-sky-400/15 text-sky-300': probe.kind === 'meshtastic',
'bg-violet-400/15 text-violet-300': probe.kind === 'reticulum',
}">{{ kindLabel }}</span>
<span v-if="probe.firmware_version" class="text-white/50 text-xs truncate">{{ probe.firmware_version }}</span>
</div>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<template v-if="probe.advert_name">
<dt class="text-white/40">Name</dt><dd class="text-white/80">{{ probe.advert_name }}</dd>
</template>
<template v-if="probe.node_id != null">
<dt class="text-white/40">Node ID</dt><dd class="text-white/80 font-mono">{{ probe.node_id }}</dd>
</template>
<template v-if="probe.region">
<dt class="text-white/40">Region</dt><dd class="text-white/80">{{ probe.region }}</dd>
</template>
<template v-if="probe.modem_preset">
<dt class="text-white/40">Preset</dt><dd class="text-white/80">{{ probe.modem_preset }}</dd>
</template>
<template v-if="probe.primary_channel">
<dt class="text-white/40">Channel</dt><dd class="text-white/80">{{ probe.primary_channel }}</dd>
</template>
<template v-if="probe.secondary_channel">
<dt class="text-white/40">2nd channel</dt><dd class="text-white/80">{{ probe.secondary_channel }}</dd>
</template>
</dl>
</template>
<p v-else class="text-white/50 text-xs py-1">
Couldn't identify the firmware on this radio{{ probeError ? ` (${probeError})` : '' }}
you can still set it up (auto-detect will keep trying) or use it as-is.
</p>
</div>
<!-- INTEGRATION POINT (other developer, in progress): the web flasher
for all three firmwares (MeshCore / Meshtastic / RNode) belongs
HERE as a third action on this screen e.g. "Flash different
firmware" driven by the probe result above. Per the operator:
the flasher must live in this detection UI. -->
<div class="flex gap-2 mt-5">
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
:disabled="!!connecting"
@click="keepAsIs"
>
{{ connecting === 'keep' ? 'Connecting…' : 'Keep As Is' }}
</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!!connecting"
@click="step = 2"
>
Set Up with Archipelago Settings
</button>
</div>
<p class="text-white/40 text-[11px] mt-3">
"Keep As Is" uses the radio exactly as it is nothing on it is changed,
and you can hot-swap radios any time.
</p>
<button
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
:disabled="!!connecting"
@click="openFlashStep"
>
Flash Firmware
</button>
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
</div>
<!-- Step 3: erase + reflash destructive, opt-in only -->
<div v-else-if="step === 'flash'">
<!-- Once a job exists (started via startFlash), ALWAYS show the
progress/result view below including on failure. The old
condition (`!active && stage !== 'done'`) was also true for a
FAILED job (active:false, stage:'failed'), which silently sent
the user back to this picker instead of showing the error. -->
<template v-if="!flashJob">
<p class="text-white/60 text-xs mb-3">
Downloads the latest firmware from upstream and writes it to
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
<select v-model="flashFamily" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum RNode</option>
</select>
</div>
<div>
<label class="block text-sm text-white/80 mb-1">Board</label>
<select v-model="flashBoard" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically double check before flashing.
Flashing the wrong board's image can brick it.
</p>
</div>
</div>
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
<label class="flex items-start gap-2 text-xs text-red-300">
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
<span>
This <strong>erases the entire chip</strong>, including any existing
keys, identity, and contacts. This cannot be undone.
</span>
</label>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
@click="startFlash"
>
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
</button>
</div>
</template>
<!-- Progress -->
<template v-else>
<div class="text-center py-2">
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full bg-orange-400 transition-all"
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
></div>
</div>
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
</div>
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
</div>
<div class="flex gap-2 mt-4">
<button
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
class="glass-button px-4 py-2 rounded-lg text-sm"
@click="cancelFlash"
>
Cancel
</button>
<button
v-if="!flashJob?.active"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="closeFlashStep"
>
Done
</button>
</div>
</template>
</div>
<!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else>
<p class="text-white/60 text-xs mb-3">
These are the latest Archipelago settings nothing is written to the
radio until you confirm.
</p>
<!-- Summary of what will be applied -->
<div class="rounded-xl bg-white/[0.05] border border-white/10 p-3 mb-4">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt class="text-white/40">Channel</dt>
<dd class="text-white/80">{{ form.channel || 'archipelago' }} <span class="text-white/40">(+ public default kept for interop)</span></dd>
<dt class="text-white/40">Name</dt>
<dd class="text-white/80">{{ form.name || '(radio keeps its name)' }}</dd>
<dt class="text-white/40">Region</dt>
<dd class="text-white/80">{{ form.region || "(radio keeps its region)" }}</dd>
<template v-if="effectiveKind === 'meshcore' && rfPreset">
<dt class="text-white/40">RF params</dt>
<dd class="text-white/80">{{ rfPreset.freqMhz }} MHz · {{ rfPreset.bwKhz }} kHz · SF{{ rfPreset.sf }} · CR4/{{ rfPreset.cr }}</dd>
</template>
</dl>
</div>
<div class="space-y-4">
<!-- Region the meaning adapts to the firmware: Meshtastic takes a
region enum directly; MeshCore/RNode take raw RF params owned by
the firmware/daemon, so the region only drives displayed guidance. -->
<div>
<label class="block text-sm text-white/80 mb-1">LoRa region / frequency plan</label>
<select v-model="form.region" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Keep the radio's current region</option>
<option v-for="r in LORA_REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p v-if="suggestedRegion && form.region === suggestedRegion.code" class="text-[11px] text-green-400/80 mt-1">
Suggested from your node's location
</p>
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
{{ selectedRegion.code }} limits airtime to {{ selectedRegion.dutyCyclePct }}% duty cycle ({{ selectedRegion.band }} MHz) the radio enforces this automatically.
</p>
<p v-if="effectiveKind === 'meshcore' && rfPreset" class="text-[11px] text-sky-300/80 mt-1">
MeshCore RF params for {{ selectedRegion?.code }} are applied to the radio automatically on connect.
</p>
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
</p>
</div>
<!-- Node name -->
<div>
<label class="block text-sm text-white/80 mb-1">Name on the mesh</label>
<input v-model="form.name" maxlength="24" placeholder="e.g. basement-node"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<!-- Channel (Meshtastic secondary channel / MeshCore group channel;
Reticulum has no channel concept IFAC lives in the daemon config) -->
<div v-if="effectiveKind !== 'reticulum'">
<label class="block text-sm text-white/80 mb-1">Channel</label>
<input v-model="form.channel" maxlength="11"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
<p class="text-[11px] text-white/40 mt-1">
Archipelago nodes find each other on the "archipelago" channel; the public default channel stays active for interop.
</p>
</div>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!!connecting"
@click="applySetup"
>
{{ connecting === 'setup' ? 'Applying…' : 'Apply Settings & Connect' }}
</button>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
const mesh = useMeshStore()
const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2 | 'flash'>(1)
const connecting = ref<false | 'keep' | 'setup'>(false)
const error = ref('')
const probing = ref(false)
const probe = ref<MeshDeviceProbe | null>(null)
const probeError = ref('')
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
)
const kindLabel = computed(() => {
switch (probe.value?.kind) {
case 'meshcore': return 'MeshCore'
case 'meshtastic': return 'Meshtastic'
case 'reticulum': return 'Reticulum RNode'
default: return ''
}
})
const suggestedRegion = computed(() => {
const info = appStore.serverInfo as { lat?: number | null; lon?: number | null } | undefined
if (info?.lat != null && info?.lon != null) {
return suggestRegionFromLatLon(info.lat, info.lon)
}
return undefined
})
const form = ref({
region: '',
name: '',
channel: 'archipelago',
})
const selectedRegion = computed(() => regionByCode(form.value.region))
// The firmware whose options we surface: the probe result wins, else the
// last connected type, else meshtastic-style (where presets apply).
const effectiveKind = computed(() => {
if (probe.value) return probe.value.kind
const t = (mesh.status?.device_type ?? '').toLowerCase()
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
})
// The node's persisted RF params (e.g. the validated Portugal preset) ARE
// "our latest parameters" — the generic per-region community plan is only
// the fallback for nodes that never configured RF params.
const rfPreset = computed(() => {
const cfg = mesh.status?.lora_radio_params
if (cfg) {
return {
freqMhz: cfg.freq_khz / 1000,
bwKhz: cfg.bw_hz / 1000,
sf: cfg.sf,
cr: cfg.cr,
}
}
return meshcorePlanFor(form.value.region)
})
// (Re)probe + (re)apply presets each time a new device surfaces the modal
watch([show, devicePath], async ([visible]) => {
if (!visible) {
stopFlashPoll()
return
}
step.value = 1
error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
// Read-only probe of what's on the stick — drives the details card.
probe.value = null
probeError.value = ''
probing.value = true
const path = devicePath.value
try {
const res = await mesh.probeDevice(path)
if (devicePath.value === path) probe.value = res
} catch (e) {
if (devicePath.value === path) {
probeError.value = e instanceof Error ? e.message : String(e)
}
} finally {
if (devicePath.value === path) probing.value = false
}
}, { immediate: false })
function dismiss() {
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
}
/** Use the radio exactly as flashed: no config writes, firmware pinned to
* what the probe saw (auto if the probe failed), hot-swappable from here. */
async function keepAsIs() {
connecting.value = 'keep'
error.value = ''
try {
const path = devicePath.value
await mesh.configure({
enabled: true,
device_path: path,
device_kind: probe.value?.kind ?? 'auto',
manage_radio: false,
})
mesh.dismissDetectedDevice(path)
// The Mesh view lives under the dashboard shell — a bare /mesh 404s.
void router.push('/dashboard/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to connect the mesh radio'
} finally {
connecting.value = false
}
}
/** Apply the Archipelago parameters shown on this screen, then connect. */
async function applySetup() {
connecting.value = 'setup'
error.value = ''
try {
const path = devicePath.value
const params: MeshConfigureParams = {
enabled: true,
device_path: path,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
...(form.value.region ? { lora_region: form.value.region } : {}),
device_kind: probe.value?.kind ?? 'auto',
manage_radio: true,
}
// MeshCore radios get their RF params written directly (freq/bw/sf/cr in
// firmware units) — same conversion as the Mesh settings panel.
if (effectiveKind.value === 'meshcore' && rfPreset.value) {
params.lora_radio_params = {
freq_khz: Math.round(rfPreset.value.freqMhz * 1000),
bw_hz: Math.round(rfPreset.value.bwKhz * 1000),
sf: rfPreset.value.sf,
cr: rfPreset.value.cr,
}
}
await mesh.configure(params)
mesh.dismissDetectedDevice(path)
void router.push('/dashboard/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
connecting.value = false
}
}
// ─── Step 3: erase + reflash ─────────────────────────────────────────────
const flashFamily = ref<FlashFirmwareFamily | ''>('')
const flashBoard = ref<FlashBoard | ''>('')
const flashConfirmed = ref(false)
const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly — matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
case 'done': return 'Flash complete'
case 'failed': return 'Flash failed'
default: return ''
}
})
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value
flashConfirmed.value = false
flashJob.value = null
error.value = ''
step.value = 'flash'
}
function stopFlashPoll() {
if (flashPollTimer) {
clearInterval(flashPollTimer)
flashPollTimer = null
}
}
async function pollFlashStatus() {
try {
const status = await mesh.flashStatus()
flashJob.value = status
if (!status.active) {
stopFlashPoll()
if (status.done && !status.error) {
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
// card reflects whatever firmware is actually on the board now.
const path = devicePath.value
probing.value = true
try {
probe.value = await mesh.probeDevice(path)
} catch {
probe.value = null
} finally {
probing.value = false
}
}
}
} catch {
stopFlashPoll()
}
}
async function startFlash() {
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
starting.value = true
error.value = ''
try {
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
stopFlashPoll()
flashPollTimer = setInterval(pollFlashStatus, 1500)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
} finally {
starting.value = false
}
}
async function cancelFlash() {
try {
await mesh.flashCancel()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to cancel'
}
}
function closeFlashStep() {
stopFlashPoll()
step.value = 1
}
</script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAudioPlayer } from '../useAudioPlayer'
// Mock HTMLAudioElement
let lastMockAudio: MockAudio | undefined
class MockAudio {
src = ''
currentTime = 0
duration = 120
paused = true
private listeners: Record<string, Array<() => void>> = {}
constructor() {
lastMockAudio = this
}
addEventListener(event: string, handler: () => void) {
if (!this.listeners[event]) this.listeners[event] = []
this.listeners[event].push(handler)
}
removeEventListener() {
// no-op for tests
}
shouldRejectPlay = false
play() {
if (this.shouldRejectPlay) {
return Promise.reject(new DOMException('no supported source', 'NotSupportedError'))
}
this.paused = false
this.emit('play')
return Promise.resolve()
}
pause() {
this.paused = true
this.emit('pause')
}
private emit(event: string) {
const handlers = this.listeners[event] || []
handlers.forEach(h => h())
}
// Helper to simulate events in tests
simulateEvent(event: string) {
this.emit(event)
}
}
vi.stubGlobal('Audio', MockAudio)
describe('useAudioPlayer', () => {
beforeEach(() => {
// Reset singleton state by stopping any active playback
const player = useAudioPlayer()
player.stop()
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
})
it('returns all expected properties', () => {
const player = useAudioPlayer()
expect(player.play).toBeTypeOf('function')
expect(player.pause).toBeTypeOf('function')
expect(player.seek).toBeTypeOf('function')
expect(player.stop).toBeTypeOf('function')
expect(player.playing).toBeDefined()
expect(player.currentName).toBeDefined()
expect(player.currentTime).toBeDefined()
expect(player.duration).toBeDefined()
expect(player.progress).toBeDefined()
expect(player.currentSrc).toBeDefined()
expect(player.error).toBeDefined()
})
it('starts in stopped state', () => {
const player = useAudioPlayer()
expect(player.playing.value).toBe(false)
expect(player.currentSrc.value).toBeNull()
expect(player.currentName.value).toBe('')
})
it('play sets playing state and current source', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test Track')
expect(player.playing.value).toBe(true)
expect(player.currentSrc.value).toBe('/audio/test.mp3')
expect(player.currentName.value).toBe('Test Track')
})
it('play toggles pause when same source is playing', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
expect(player.playing.value).toBe(true)
// Play same source again — should pause
player.play('/audio/test.mp3', 'Test')
expect(player.playing.value).toBe(false)
})
it('play switches to new source', () => {
const player = useAudioPlayer()
player.play('/audio/first.mp3', 'First')
player.play('/audio/second.mp3', 'Second')
expect(player.currentSrc.value).toBe('/audio/second.mp3')
expect(player.currentName.value).toBe('Second')
})
it('pause pauses playback', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
player.pause()
expect(player.playing.value).toBe(false)
})
it('stop resets all state', () => {
const player = useAudioPlayer()
player.play('/audio/test.mp3', 'Test')
player.stop()
expect(player.playing.value).toBe(false)
expect(player.currentSrc.value).toBeNull()
expect(player.currentName.value).toBe('')
})
it('progress computes correctly', () => {
const player = useAudioPlayer()
expect(player.progress.value).toBe(0) // duration is 0
player.currentTime.value = 30
player.duration.value = 120
expect(player.progress.value).toBe(25) // 30/120 * 100
})
it('progress is 0 when duration is 0', () => {
const player = useAudioPlayer()
player.duration.value = 0
player.currentTime.value = 10
expect(player.progress.value).toBe(0)
})
it('play() rejection is caught, not left as an unhandled promise rejection', async () => {
// Regression: play() rejects independently of the 'error' event (e.g. a
// peer-content 404 with no decodable source) — this used to be an
// unhandled rejection in the browser console even though the 'error'
// listener already set a friendly message (2026-07-01).
const player = useAudioPlayer()
// Initialize the singleton Audio element first (a no-op play call).
player.play('/audio/warmup.mp3', 'Warmup')
player.stop()
lastMockAudio!.shouldRejectPlay = true
// Calling play() must not throw synchronously nor leave a rejected
// promise unhandled — if useAudioPlayer's play() didn't .catch() the
// rejection, `loading` would never flip back to false, since nothing
// else resets it on this path (that's the real regression signal).
expect(() => player.play('/audio/broken.mp3', 'Broken')).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(player.loading.value).toBe(false)
})
it('shared state across multiple useAudioPlayer calls', () => {
const p1 = useAudioPlayer()
const p2 = useAudioPlayer()
p1.play('/audio/shared.mp3', 'Shared')
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
expect(p2.playing.value).toBe(true)
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { useContainersScanTimeout } from '../useContainersScanTimeout'
describe('useContainersScanTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects the real scanned flag when it arrives before the timeout', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
expect(effectiveContainersScanned.value).toBe(false)
scanned.value = true
await nextTick()
expect(effectiveContainersScanned.value).toBe(true)
expect(scanTimedOut.value).toBe(false)
})
it('does not start the timeout until initial data has loaded', async () => {
const scanned = ref(false)
const loaded = ref(false)
const { effectiveContainersScanned } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(60_000)
expect(effectiveContainersScanned.value).toBe(false)
loaded.value = true
await nextTick()
vi.advanceTimersByTime(20_000)
expect(effectiveContainersScanned.value).toBe(true)
})
it('falls through after the timeout even if the flag never arrives', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(19_999)
expect(effectiveContainersScanned.value).toBe(false)
vi.advanceTimersByTime(1)
expect(effectiveContainersScanned.value).toBe(true)
expect(scanTimedOut.value).toBe(true)
})
it('cancels the escape hatch when the real flag arrives', async () => {
const scanned = ref(false)
const loaded = ref(true)
const { scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
vi.advanceTimersByTime(10_000)
scanned.value = true
await nextTick()
vi.advanceTimersByTime(60_000)
expect(scanTimedOut.value).toBe(false)
})
})
@@ -0,0 +1,671 @@
/**
* Tests for useControllerNav — validates against GAMEPAD-NAV-MAP.md
*
* Tests the navigation logic (element queries, spatial nav, zone detection)
* without mounting the composable (which needs Vue lifecycle).
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
// ─── Mocks ─────────────────────────────────────────────────────
vi.mock('vue-router', () => ({
useRoute: () => ({ path: '/dashboard' }),
useRouter: () => ({ push: vi.fn().mockResolvedValue(undefined) }),
}))
vi.mock('@/stores/controller', () => ({ useControllerStore: () => ({ setActive: vi.fn(), setGamepadCount: vi.fn() }) }))
vi.mock('@/stores/spotlight', () => ({ useSpotlightStore: () => ({ isOpen: false, close: vi.fn() }) }))
vi.mock('@/stores/cli', () => ({ useCLIStore: () => ({ isOpen: false, close: vi.fn() }) }))
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ isOpen: false, close: vi.fn() }) }))
vi.mock('@/composables/useNavSounds', () => ({ playNavSound: vi.fn() }))
// ─── Helpers ───────────────────────────────────────────────────
const FOCUSABLE_SELECTOR = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])', '[data-controller-focus]',
'[data-controller-container]',
].join(', ')
function queryFocusable(root: HTMLElement | Document = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
)
}
function queryContainers(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]')
if (!zone) return []
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]'))
}
function queryNavBarItems(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]')
if (!zone) return []
return queryFocusable(zone as HTMLElement).filter(el =>
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
)
}
function querySidebar(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="sidebar"]')
return zone ? queryFocusable(zone as HTMLElement) : []
}
// ─── Module Export ──────────────────────────────────────────────
describe('module', () => {
it('exports useControllerNav', async () => {
const mod = await import('../useControllerNav')
expect(typeof mod.useControllerNav).toBe('function')
})
})
// ─── SIDEBAR: Up/Down wrap, Right→container, Left→nothing ──────
describe('sidebar navigation (NAV-MAP: Sidebar)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('finds all sidebar nav items', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/dashboard">Home</a>
<a href="/dashboard/apps">Apps</a>
<a href="/dashboard/cloud">Cloud</a>
<button>AIUI</button>
<button>Logout</button>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
expect(querySidebar().length).toBe(5)
})
it('wraps down: Logout → Home', () => {
const items = ['Home', 'Apps', 'Cloud', 'Logout']
const lastIdx = items.length - 1
expect((lastIdx + 1) % items.length).toBe(0) // wraps to Home
})
it('wraps up: Home → Logout', () => {
const items = ['Home', 'Apps', 'Cloud', 'Logout']
expect((0 - 1 + items.length) % items.length).toBe(items.length - 1) // wraps to Logout
})
it('right from sidebar targets first container, not nav bar items', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
<div data-controller-zone="main">
<button class="mode-switcher-btn" id="tab">Tab</button>
<div data-controller-container tabindex="0" id="card1">Card</div>
</div>
`
const containers = queryContainers()
expect(containers[0]?.id).toBe('card1')
})
it('left from sidebar does nothing (no target exists)', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
`
const sidebar = querySidebar()
const el = sidebar[0]!
// Nothing to the left of sidebar
expect(el.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
})
})
// ─── HOME: 2-col grid + nav bar ────────────────────────────────
describe('HOME grid (NAV-MAP: HOME /dashboard)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has Dashboard and Setup nav bar items', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div role="tablist">
<button role="tab" class="mode-switcher-btn" id="dashTab">Dashboard</button>
<button role="tab" class="mode-switcher-btn" id="setupTab">Setup</button>
</div>
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
</div>
`
const navItems = queryNavBarItems()
expect(navItems.length).toBe(2)
expect(navItems[0]?.id).toBe('dashTab')
expect(navItems[1]?.id).toBe('setupTab')
})
it('containers exclude nav bar items', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<button class="mode-switcher-btn">Dashboard</button>
<button class="mode-switcher-btn">Setup</button>
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
<div data-controller-container tabindex="0" id="network">Network</div>
<div data-controller-container tabindex="0" id="wallet">Wallet</div>
<div data-controller-container tabindex="0" id="system">System</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(5)
expect(containers.map(c => c.id)).toEqual(['myApps', 'cloud', 'network', 'wallet', 'system'])
// Nav bar items are separate
const navItems = queryNavBarItems()
expect(navItems.length).toBe(2)
})
it('inner controls are not in the container grid', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="myApps">
<a href="/dashboard/apps">Go</a>
<button id="browseStore">Browse Store</button>
<button id="manageApps">Manage Apps</button>
</div>
</div>
`
// Only 1 container in grid
expect(queryContainers().length).toBe(1)
// Nav bar is empty (all focusables are inside the container)
expect(queryNavBarItems().length).toBe(0)
})
})
// ─── APPS: 3-col grid + nav bar with tabs/filters/search ───────
describe('APPS grid (NAV-MAP: APPS /dashboard/apps)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('nav bar has tabs, filters, and search', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div class="mode-switcher">
<button class="mode-switcher-btn" id="myAppsTab">My Apps</button>
<a href="/dashboard/discover" class="mode-switcher-btn" id="storeTab">App Store</a>
<button class="mode-switcher-btn" id="servicesTab">Services</button>
</div>
<div class="mode-switcher">
<button class="mode-switcher-btn" id="allFilter">All</button>
<button class="mode-switcher-btn" id="btcFilter">Bitcoin</button>
</div>
<input type="text" id="search" />
<div data-controller-container tabindex="0" id="app1">App1</div>
<div data-controller-container tabindex="0" id="app2">App2</div>
<div data-controller-container tabindex="0" id="app3">App3</div>
</div>
`
const navItems = queryNavBarItems()
// 3 tabs + 2 filters + 1 search = 6 nav bar items
expect(navItems.length).toBe(6)
expect(navItems.map(el => el.id)).toEqual(['myAppsTab', 'storeTab', 'servicesTab', 'allFilter', 'btcFilter', 'search'])
// 3 containers
expect(queryContainers().length).toBe(3)
})
it('app cards with launch attribute are containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container data-controller-launch tabindex="0" id="app1">
<button data-controller-launch-btn>Launch</button>
</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(1)
expect(containers[0]?.hasAttribute('data-controller-launch')).toBe(true)
const launchBtn = containers[0]?.querySelector('[data-controller-launch-btn]')
expect(launchBtn).toBeTruthy()
})
})
// ─── CLOUD: 3-col, no nav bar ──────────────────────────────────
describe('CLOUD grid (NAV-MAP: CLOUD /dashboard/cloud)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has section cards as containers, no nav bar', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="photos">Photos</div>
<div data-controller-container tabindex="0" id="music">Music</div>
<div data-controller-container tabindex="0" id="docs">Documents</div>
<div data-controller-container tabindex="0" id="files">Files</div>
</div>
`
expect(queryContainers().length).toBe(4)
expect(queryNavBarItems().length).toBe(0)
})
})
// ─── NETWORK: 2-col ────────────────────────────────────────────
describe('NETWORK grid (NAV-MAP: NETWORK /dashboard/server)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has Local Network and Web3 containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="localNet">Local Network</div>
<div data-controller-container tabindex="0" id="web3">Web3</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(2)
expect(containers[0]?.id).toBe('localNet')
expect(containers[1]?.id).toBe('web3')
})
})
// ─── SETTINGS: vertical stack ──────────────────────────────────
describe('SETTINGS grid (NAV-MAP: SETTINGS /dashboard/settings)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has stacked section containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="account">Account Info</div>
<div data-controller-container tabindex="0" id="password">Change Password</div>
<div data-controller-container tabindex="0" id="twofa">Two-Factor</div>
<div data-controller-container tabindex="0" id="system">System Info</div>
<div data-controller-container tabindex="0" id="danger">Danger Zone</div>
</div>
`
const containers = queryContainers()
expect(containers.length).toBe(5)
// No nav bar
expect(queryNavBarItems().length).toBe(0)
})
})
// ─── ENTER behavior ────────────────────────────────────────────
describe('enter key behavior (NAV-MAP: Rules 5)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('container with primary link: Enter should navigate', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<a href="/dashboard/apps" id="link">Go</a>
<button>Browse</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
const link = container.querySelector('a[href]')
expect(link).toBeTruthy()
expect(link?.getAttribute('href')).toBe('/dashboard/apps')
})
it('container without link: Enter drills into inner [Y] controls', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<button id="btn1">Open Shop</button>
<button id="btn2">Accept Payments</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.querySelector('a[href]')).toBeNull()
const inner = Array.from(container.querySelectorAll('button'))
expect(inner.length).toBe(2)
})
it('install container: Enter clicks install button', () => {
document.body.innerHTML = `
<div data-controller-container data-controller-install tabindex="0">
<button data-controller-install-btn>Install</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.hasAttribute('data-controller-install')).toBe(true)
expect(container.querySelector('[data-controller-install-btn]')).toBeTruthy()
})
it('launch container: Enter clicks launch button', () => {
document.body.innerHTML = `
<div data-controller-container data-controller-launch tabindex="0">
<button data-controller-launch-btn>Launch</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.hasAttribute('data-controller-launch')).toBe(true)
expect(container.querySelector('[data-controller-launch-btn]')).toBeTruthy()
})
})
// ─── INSIDE CONTAINER [Y] ──────────────────────────────────────
describe('inside container navigation (NAV-MAP: Rules 6)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('inner controls are isolated from other containers', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0" id="card1">
<button id="stop">Stop</button>
<button id="restart">Restart</button>
</div>
<div data-controller-container tabindex="0" id="card2">
<button id="other">Other</button>
</div>
`
const card1 = document.getElementById('card1')!
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
expect(inner.length).toBe(2)
expect(inner.map(el => el.id)).toEqual(['stop', 'restart'])
// "other" is NOT in card1's inner controls
expect(inner.find(el => el.id === 'other')).toBeUndefined()
})
it('escape from inner control returns to container', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0" id="card">
<button id="inner">Action</button>
</div>
`
const inner = document.getElementById('inner')!
const container = inner.closest('[data-controller-container]')
expect(container).toBeTruthy()
expect(container?.id).toBe('card')
expect(container?.getAttribute('tabindex')).toBe('0')
})
it('isInsideContainer is true for nested, false for container itself', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0" id="card">
<button id="inside">In</button>
</div>
<button id="outside">Out</button>
`
const inside = document.getElementById('inside')!
const outside = document.getElementById('outside')!
const card = document.getElementById('card')!
// inside: has container ancestor that isn't itself
const insideContainer = inside.closest('[data-controller-container]')
expect(insideContainer && insideContainer !== inside).toBe(true)
// card: IS the container
expect(card.hasAttribute('data-controller-container')).toBe(true)
// outside: no container ancestor
expect(outside.closest('[data-controller-container]')).toBeNull()
})
})
// ─── TEXT INPUT handling ───────────────────────────────────────
describe('text input handling (NAV-MAP: text inputs)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('up/down exits input, left/right stays', () => {
const exitKeys = ['ArrowUp', 'ArrowDown']
const stayKeys = ['ArrowLeft', 'ArrowRight']
exitKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(true))
stayKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(false))
})
it('enter on password clicks next button (submit)', () => {
document.body.innerHTML = `
<input id="pass" type="password" />
<button id="login">Login</button>
`
const all = queryFocusable()
const passIdx = all.findIndex(el => el.id === 'pass')
const next = all[passIdx + 1]
expect(next?.tagName).toBe('BUTTON')
expect(next?.id).toBe('login')
})
})
// ─── FOCUS MEMORY ──────────────────────────────────────────────
describe('focus memory (NAV-MAP: zone transitions)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('remembers and recalls elements', () => {
document.body.innerHTML = `<button id="btn">Test</button>`
const memory = new Map<string, HTMLElement>()
const btn = document.getElementById('btn')!
memory.set('main', btn)
expect(memory.get('main')).toBe(btn)
expect(document.contains(btn)).toBe(true)
})
it('detects stale (removed) elements', () => {
document.body.innerHTML = `<button id="btn">Test</button>`
const memory = new Map<string, HTMLElement>()
const btn = document.getElementById('btn')!
memory.set('main', btn)
btn.remove()
expect(document.contains(memory.get('main')!)).toBe(false)
})
it('clears on route change', () => {
const memory = new Map<string, HTMLElement>()
document.body.innerHTML = `<button id="btn">Test</button>`
memory.set('main', document.getElementById('btn')!)
memory.delete('main')
expect(memory.get('main')).toBeUndefined()
})
})
// ─── SPATIAL NAVIGATION ────────────────────────────────────────
describe('spatial navigation', () => {
it('overlap scoring: aligned > offset', () => {
const from = { top: 50, bottom: 200, left: 0, right: 150 }
const aligned = { top: 50, bottom: 200, left: 200, right: 350 }
const offset = { top: 160, bottom: 310, left: 200, right: 350 }
const alignedOv = Math.max(0, Math.min(from.bottom, aligned.bottom) - Math.max(from.top, aligned.top))
const offsetOv = Math.max(0, Math.min(from.bottom, offset.bottom) - Math.max(from.top, offset.top))
expect(alignedOv).toBe(150)
expect(offsetOv).toBe(40)
expect(alignedOv).toBeGreaterThan(offsetOv)
})
it('tiebreaker: up/down prefers leftmost', () => {
// Two elements below, same distance, same overlap
const a = { left: 0 }
const b = { left: 200 }
// Sort: leftmost wins
expect(a.left - b.left).toBeLessThan(0) // a is leftmost
})
it('no wrap in 2D grid (NAV-MAP: Rules 2)', () => {
// At rightmost column, pressing right should find nothing
const from = { left: 400, right: 600, top: 0, bottom: 200 }
const threshold = 50
// No element to the right
const candidate = { left: 0, right: 150 } // far left
expect(candidate.left >= from.right - threshold).toBe(false) // NOT to the right
})
})
// ─── GAMEPAD DETECTION ─────────────────────────────────────────
describe('gamepad detection', () => {
it('counts connected gamepads', () => {
const gp = [{ connected: true }, null, { connected: true }, null] as (Gamepad | null)[]
expect(gp.filter(g => g?.connected).length).toBe(2)
})
it('handles null list', () => {
const count = (gp: (Gamepad | null)[] | null) => gp ? gp.filter(g => g?.connected).length : 0
expect(count(null)).toBe(0)
})
})
// ─── DATA-CONTROLLER-IGNORE ────────────────────────────────────
describe('data-controller-ignore', () => {
afterEach(() => { document.body.innerHTML = '' })
it('excluded elements are filtered out', () => {
document.body.innerHTML = `
<button data-controller-ignore>Skip</button>
<div data-controller-ignore><button>Nested ignored</button></div>
<button id="real">Real</button>
`
const all = queryFocusable()
expect(all.length).toBe(1)
expect(all[0]?.id).toBe('real')
})
})
// ─── NAV BAR [N] DETECTION ─────────────────────────────────────
describe('nav bar detection', () => {
afterEach(() => { document.body.innerHTML = '' })
it('nav bar items are in main zone but not inside containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<button class="mode-switcher-btn" id="tab1">Dashboard</button>
<button class="mode-switcher-btn" id="tab2">Setup</button>
<div data-controller-container tabindex="0" id="card">
<button id="inner">Inner</button>
</div>
</div>
`
const navItems = queryNavBarItems()
expect(navItems.length).toBe(2)
expect(navItems[0]?.id).toBe('tab1')
expect(navItems[1]?.id).toBe('tab2')
// Inner button is NOT a nav bar item
expect(navItems.find(el => el.id === 'inner')).toBeUndefined()
})
it('pages without nav bar return empty', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
expect(queryNavBarItems().length).toBe(0)
})
})
// ─── DISCOVER: featured + grid ─────────────────────────────────
describe('DISCOVER grid (NAV-MAP: DISCOVER /dashboard/discover)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('has nav bar + featured + app grid', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<a href="/dashboard/apps" class="mode-switcher-btn" id="myApps">My Apps</a>
<a href="/dashboard/discover" class="mode-switcher-btn" id="appStore">App Store</a>
<div data-controller-container data-controller-install tabindex="0" id="feat1">Featured 1</div>
<div data-controller-container data-controller-install tabindex="0" id="feat2">Featured 2</div>
<div data-controller-container data-controller-install tabindex="0" id="app1">App 1</div>
<div data-controller-container data-controller-install tabindex="0" id="app2">App 2</div>
</div>
`
expect(queryNavBarItems().length).toBe(2)
expect(queryContainers().length).toBe(4)
})
})
// ─── MESH / FLEET / SETTINGS containers exist ──────────────────
describe('pages have containers (NAV-MAP: all pages)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('mesh has panel containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="device">Device Status</div>
<div data-controller-container tabindex="0" id="chat">Chat Panel</div>
<div data-controller-container tabindex="0" id="peers">Peers</div>
</div>
`
expect(queryContainers().length).toBe(3)
})
it('fleet has stat + node containers', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Nodes</div>
<div data-controller-container tabindex="0">Online</div>
<div data-controller-container tabindex="0">Offline</div>
<div data-controller-container tabindex="0">Health</div>
<div data-controller-container tabindex="0">Node 1</div>
</div>
`
expect(queryContainers().length).toBe(5)
})
})
// ─── FULL FLOW: sidebar → container → inner → back ─────────────
describe('full navigation flow (NAV-MAP: Rules 1-8)', () => {
afterEach(() => { document.body.innerHTML = '' })
it('complete roundtrip: sidebar → container → inner → escape → sidebar', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/dashboard" class="nav-tab-active" id="sideHome">Home</a>
<a href="/dashboard/apps" id="sideApps">Apps</a>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="card1">
<button id="inner1">Browse</button>
<button id="inner2">Manage</button>
</div>
<div data-controller-container tabindex="0" id="card2">
<a href="/dashboard/cloud">Go</a>
</div>
</div>
`
// Step 1: Sidebar exists, has active tab
const sidebar = querySidebar()
expect(sidebar.length).toBe(2)
const activeTab = document.querySelector('.nav-tab-active') as HTMLElement
expect(activeTab?.id).toBe('sideHome')
// Step 2: Right from sidebar → first container
const containers = queryContainers()
expect(containers[0]?.id).toBe('card1')
// Step 3: Enter on card1 (no primary link) → drill into inner controls
const card1 = document.getElementById('card1')!
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
expect(inner.length).toBe(2)
expect(inner[0]?.id).toBe('inner1')
// Step 4: Escape from inner → back to card1
const innerEl = document.getElementById('inner1')!
const parentContainer = innerEl.closest('[data-controller-container]')
expect(parentContainer?.id).toBe('card1')
// Step 5: Escape from card1 → sidebar active tab
expect(activeTab?.id).toBe('sideHome')
// Step 6: card2 has primary link → Enter navigates
const card2 = document.getElementById('card2')!
const primaryLink = card2.querySelector('a[href]')
expect(primaryLink?.getAttribute('href')).toBe('/dashboard/cloud')
})
it('no dead ends: every container can reach sidebar', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/" class="nav-tab-active">Home</a>
</div>
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="c1">C1</div>
<div data-controller-container tabindex="0" id="c2">C2</div>
</div>
`
// Every container is in main zone
const containers = queryContainers()
containers.forEach(c => {
expect(c.closest('[data-controller-zone="main"]')).toBeTruthy()
})
// Sidebar has at least one item
expect(querySidebar().length).toBeGreaterThan(0)
// Active tab exists for Left → sidebar
expect(document.querySelector('.nav-tab-active')).toBeTruthy()
})
})
@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
describe('getFileCategory', () => {
it('returns folder for directories', () => {
expect(getFileCategory('', true)).toBe('folder')
expect(getFileCategory('jpg', true)).toBe('folder')
})
it('identifies image extensions', () => {
expect(getFileCategory('jpg', false)).toBe('image')
expect(getFileCategory('jpeg', false)).toBe('image')
expect(getFileCategory('png', false)).toBe('image')
expect(getFileCategory('gif', false)).toBe('image')
expect(getFileCategory('webp', false)).toBe('image')
expect(getFileCategory('svg', false)).toBe('image')
expect(getFileCategory('bmp', false)).toBe('image')
expect(getFileCategory('ico', false)).toBe('image')
})
it('identifies audio extensions', () => {
expect(getFileCategory('mp3', false)).toBe('audio')
expect(getFileCategory('flac', false)).toBe('audio')
expect(getFileCategory('wav', false)).toBe('audio')
expect(getFileCategory('ogg', false)).toBe('audio')
expect(getFileCategory('aac', false)).toBe('audio')
expect(getFileCategory('m4a', false)).toBe('audio')
})
it('identifies video extensions', () => {
expect(getFileCategory('mp4', false)).toBe('video')
expect(getFileCategory('mkv', false)).toBe('video')
expect(getFileCategory('avi', false)).toBe('video')
expect(getFileCategory('mov', false)).toBe('video')
expect(getFileCategory('webm', false)).toBe('video')
})
it('identifies document extensions', () => {
expect(getFileCategory('pdf', false)).toBe('document')
expect(getFileCategory('doc', false)).toBe('document')
expect(getFileCategory('docx', false)).toBe('document')
expect(getFileCategory('txt', false)).toBe('document')
expect(getFileCategory('md', false)).toBe('document')
})
it('identifies spreadsheet extensions', () => {
expect(getFileCategory('xls', false)).toBe('spreadsheet')
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
expect(getFileCategory('csv', false)).toBe('spreadsheet')
expect(getFileCategory('ods', false)).toBe('spreadsheet')
})
it('identifies archive extensions', () => {
expect(getFileCategory('zip', false)).toBe('archive')
expect(getFileCategory('tar', false)).toBe('archive')
expect(getFileCategory('gz', false)).toBe('archive')
expect(getFileCategory('rar', false)).toBe('archive')
expect(getFileCategory('7z', false)).toBe('archive')
})
it('returns file for unknown extensions', () => {
expect(getFileCategory('xyz', false)).toBe('file')
expect(getFileCategory('', false)).toBe('file')
expect(getFileCategory('bin', false)).toBe('file')
})
})
describe('useFileType', () => {
it('returns correct category and computed values for an image', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
expect(result.isImage.value).toBe(true)
expect(result.isAudio.value).toBe(false)
expect(result.isVideo.value).toBe(false)
expect(result.iconColor.value).toBe('text-blue-400')
expect(result.badgeLabel.value).toBe('Image')
})
it('returns correct values for audio', () => {
const ext = ref('mp3')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-orange-400')
expect(result.badgeLabel.value).toBe('Audio')
})
it('returns correct values for video', () => {
const ext = ref('mp4')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('video')
expect(result.isVideo.value).toBe(true)
expect(result.iconColor.value).toBe('text-purple-400')
})
it('returns folder when isDir is true', () => {
const ext = ref('jpg')
const isDir = ref(true)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('folder')
expect(result.isImage.value).toBe(false)
expect(result.iconColor.value).toBe('text-amber-400')
expect(result.badgeLabel.value).toBe('Folder')
})
it('reacts to ref changes', () => {
const ext = ref('jpg')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.category.value).toBe('image')
ext.value = 'mp3'
expect(result.category.value).toBe('audio')
expect(result.isAudio.value).toBe(true)
expect(result.isImage.value).toBe(false)
})
it('provides icon paths for each category', () => {
const ext = ref('pdf')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.iconPaths.value).toBeDefined()
expect(result.iconPaths.value.length).toBeGreaterThan(0)
})
it('provides badge class for each category', () => {
const ext = ref('zip')
const isDir = ref(false)
const result = useFileType(ext, isDir)
expect(result.badgeClass.value).toContain('bg-yellow')
})
})
describe('formatSize', () => {
it('formats 0 bytes', () => {
expect(formatSize(0)).toBe('0 B')
})
it('formats bytes', () => {
expect(formatSize(500)).toBe('500 B')
})
it('formats kilobytes', () => {
expect(formatSize(1024)).toBe('1.0 KB')
expect(formatSize(1536)).toBe('1.5 KB')
})
it('formats megabytes', () => {
expect(formatSize(1048576)).toBe('1.0 MB')
})
it('formats gigabytes', () => {
expect(formatSize(1073741824)).toBe('1.0 GB')
})
it('formats terabytes', () => {
expect(formatSize(1099511627776)).toBe('1.0 TB')
})
})
describe('formatDate', () => {
it('returns "Just now" for very recent dates', () => {
const now = new Date().toISOString()
expect(formatDate(now)).toBe('Just now')
})
it('returns minutes ago for recent dates', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
expect(formatDate(fiveMinAgo)).toBe('5m ago')
})
it('returns hours ago for dates within 24h', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
expect(formatDate(threeHoursAgo)).toBe('3h ago')
})
it('returns days ago for dates within a week', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
expect(formatDate(twoDaysAgo)).toBe('2d ago')
})
it('returns formatted date for older dates', () => {
const oldDate = new Date('2025-01-15').toISOString()
const result = formatDate(oldDate)
// Should be a locale date string, not a relative time
expect(result).toMatch(/\d/)
expect(result).not.toContain('ago')
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock Audio globally
class MockAudio {
src = ''
volume = 1
loop = false
currentTime = 0
play = vi.fn().mockResolvedValue(undefined)
pause = vi.fn()
addEventListener = vi.fn()
}
vi.stubGlobal('Audio', MockAudio)
// Mock fetch for playLoopStart
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
}))
// Mock AudioContext
const mockBufferSource = {
buffer: null as AudioBuffer | null,
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}
const mockMediaElementSource = {
connect: vi.fn(),
}
const mockGainNode = {
gain: {
value: 1,
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
}
const mockAudioContext = {
state: 'running' as AudioContextState,
currentTime: 0,
destination: {},
resume: vi.fn().mockResolvedValue(undefined),
createOscillator: vi.fn().mockReturnValue({
type: 'sine',
frequency: { value: 440, setValueAtTime: vi.fn() },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}),
createGain: vi.fn().mockReturnValue({ ...mockGainNode, gain: { ...mockGainNode.gain } }),
createBufferSource: vi.fn().mockReturnValue({ ...mockBufferSource }),
createMediaElementSource: vi.fn().mockReturnValue({ ...mockMediaElementSource }),
decodeAudioData: vi.fn().mockResolvedValue({} as AudioBuffer),
}
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => ({ ...mockAudioContext })))
import {
playPop,
playLoginSuccessWhoosh,
playTypingSound,
playIntroTyping,
stopIntroTyping,
playWelcomeNoderunnerSpeech,
playTypingTick,
resumeAudioContext,
startSynthwave,
stopSynthwave,
playLoopStart,
playKeyboardTypingSound,
playDashboardLoadOomph,
} from '../useLoginSounds'
describe('useLoginSounds', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('playPop', () => {
it('creates Audio with pop.mp3 and plays it', () => {
playPop()
// Audio constructor was called (via MockAudio)
expect(MockAudio.prototype.constructor).toBeDefined()
})
it('does not throw', () => {
expect(() => playPop()).not.toThrow()
})
})
describe('playLoginSuccessWhoosh', () => {
it('does not throw', () => {
expect(() => playLoginSuccessWhoosh()).not.toThrow()
})
})
describe('playTypingSound', () => {
it('does not throw', () => {
expect(() => playTypingSound()).not.toThrow()
})
})
describe('playIntroTyping', () => {
it('does not throw', () => {
expect(() => playIntroTyping()).not.toThrow()
})
it('creates a looping audio element', () => {
playIntroTyping()
// Does not throw, creates audio
})
})
describe('stopIntroTyping', () => {
it('does not throw when no audio playing', () => {
expect(() => stopIntroTyping()).not.toThrow()
})
it('stops audio that was started', () => {
playIntroTyping()
expect(() => stopIntroTyping()).not.toThrow()
})
})
describe('playWelcomeNoderunnerSpeech', () => {
it('does not throw', () => {
expect(() => playWelcomeNoderunnerSpeech()).not.toThrow()
})
})
describe('playTypingTick', () => {
it('does not throw', () => {
expect(() => playTypingTick()).not.toThrow()
})
it('can be called multiple times (pool rotation)', () => {
for (let i = 0; i < 10; i++) {
expect(() => playTypingTick()).not.toThrow()
}
})
})
describe('resumeAudioContext', () => {
it('does not throw', () => {
expect(() => resumeAudioContext()).not.toThrow()
})
})
describe('startSynthwave', () => {
it('does not throw when no audio context', () => {
// Without calling resumeAudioContext first, context might be null
expect(() => startSynthwave()).not.toThrow()
})
})
describe('stopSynthwave', () => {
it('does not throw when nothing is playing', () => {
expect(() => stopSynthwave()).not.toThrow()
})
})
describe('playLoopStart', () => {
it('does not throw when no audio context', () => {
expect(() => playLoopStart()).not.toThrow()
})
})
describe('playKeyboardTypingSound', () => {
it('does not throw when no audio context', () => {
expect(() => playKeyboardTypingSound()).not.toThrow()
})
})
describe('playDashboardLoadOomph', () => {
it('does not throw when no audio context', () => {
expect(() => playDashboardLoadOomph()).not.toThrow()
})
})
describe('audio context lifecycle', () => {
it('resumeAudioContext then startSynthwave does not throw', () => {
resumeAudioContext()
expect(() => startSynthwave()).not.toThrow()
})
it('resumeAudioContext then stopSynthwave does not throw', () => {
resumeAudioContext()
expect(() => stopSynthwave()).not.toThrow()
})
it('resumeAudioContext then playKeyboardTypingSound does not throw', () => {
resumeAudioContext()
expect(() => playKeyboardTypingSound()).not.toThrow()
})
it('resumeAudioContext then playDashboardLoadOomph does not throw', () => {
resumeAudioContext()
expect(() => playDashboardLoadOomph()).not.toThrow()
})
it('resumeAudioContext then playLoopStart does not throw', () => {
resumeAudioContext()
expect(() => playLoopStart()).not.toThrow()
})
})
})
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useMarketplaceApp } from '../useMarketplaceApp'
describe('useMarketplaceApp', () => {
beforeEach(() => {
const { clearCurrentApp } = useMarketplaceApp()
clearCurrentApp()
})
it('getCurrentApp returns null initially', () => {
const { getCurrentApp } = useMarketplaceApp()
expect(getCurrentApp()).toBeNull()
})
it('setCurrentApp stores a full app', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'bitcoin',
title: 'Bitcoin Core',
version: '25.0',
icon: '/icons/btc.png',
category: 'Finance',
description: 'Bitcoin node',
author: 'Satoshi',
source: 'github',
manifestUrl: 'https://example.com/manifest',
url: 'https://example.com',
repoUrl: 'https://github.com/bitcoin/bitcoin',
s9pkUrl: '',
dockerImage: 'bitcoin:25.0',
})
const app = getCurrentApp()
expect(app).not.toBeNull()
expect(app!.id).toBe('bitcoin')
expect(app!.title).toBe('Bitcoin Core')
expect(app!.version).toBe('25.0')
})
it('setCurrentApp with partial app fills defaults', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'lnd' })
const app = getCurrentApp()
expect(app).not.toBeNull()
expect(app!.id).toBe('lnd')
expect(app!.title).toBe('')
expect(app!.version).toBe('')
expect(app!.icon).toBe('')
expect(app!.dockerImage).toBe('')
})
it('manifestUrl falls back to s9pkUrl then url', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
const app = getCurrentApp()
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
})
it('url falls back to s9pkUrl then manifestUrl', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
const app = getCurrentApp()
expect(app!.url).toBe('https://manifest.example.com')
})
it('clearCurrentApp sets app to null', () => {
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({ id: 'bitcoin' })
expect(getCurrentApp()).not.toBeNull()
clearCurrentApp()
expect(getCurrentApp()).toBeNull()
})
it('shared state across multiple useMarketplaceApp calls', () => {
const instance1 = useMarketplaceApp()
const instance2 = useMarketplaceApp()
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
const app = instance2.getCurrentApp()
expect(app!.id).toBe('mempool')
expect(app!.title).toBe('Mempool')
})
it('handles description as object', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'test',
description: { short: 'Short desc', long: 'Long description' },
})
const app = getCurrentApp()
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
})
it('preserves real screenshot metadata', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'test',
screenshots: [
'/screenshots/test-dashboard.png',
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
],
})
expect(getCurrentApp()!.screenshots).toEqual([
'/screenshots/test-dashboard.png',
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
])
})
})
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
getReceivedMessages: vi.fn(),
},
}))
import { useMessageToast } from '../useMessageToast'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('useMessageToast', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset shared singleton state
const toast = useMessageToast()
toast.stopPolling()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 0
toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
})
afterEach(() => {
const toast = useMessageToast()
toast.stopPolling()
vi.useRealTimers()
})
it('starts with empty state', () => {
const toast = useMessageToast()
expect(toast.receivedMessages.value).toEqual([])
expect(toast.lastMessageCount.value).toBe(0)
expect(toast.loadingMessages.value).toBe(false)
expect(toast.toastMessage.value.show).toBe(false)
expect(toast.unreadCount.value).toBe(0)
})
it('loadReceivedMessages fetches and stores messages', async () => {
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
],
})
const toast = useMessageToast()
await toast.loadReceivedMessages()
expect(toast.receivedMessages.value.length).toBe(1)
expect(toast.lastMessageCount.value).toBe(1)
expect(toast.loadingMessages.value).toBe(false)
})
it('does not show toast on initial load', async () => {
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
})
const toast = useMessageToast()
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(false)
})
it('shows toast when new messages arrive after initial load', async () => {
const toast = useMessageToast()
// Initial load
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
})
await toast.loadReceivedMessages()
// New message arrives
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
],
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(true)
expect(toast.toastMessage.value.text).toBe('Second')
})
it('shows count for multiple new messages', async () => {
const toast = useMessageToast()
// Initial load
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
})
await toast.loadReceivedMessages()
// Multiple new messages
mockedRpc.getReceivedMessages.mockResolvedValue({
messages: [
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
],
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value.show).toBe(true)
expect(toast.toastMessage.value.text).toBe('2 new messages')
})
it('unreadCount reflects difference', async () => {
const toast = useMessageToast()
toast.receivedMessages.value = [
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
]
toast.lastMessageCount.value = 1
expect(toast.unreadCount.value).toBe(1)
})
it('unreadCount is never negative', () => {
const toast = useMessageToast()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 5
expect(toast.unreadCount.value).toBe(0)
})
it('markAsRead syncs lastMessageCount', () => {
const toast = useMessageToast()
toast.receivedMessages.value = [
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
]
toast.lastMessageCount.value = 0
toast.markAsRead()
expect(toast.lastMessageCount.value).toBe(2)
expect(toast.unreadCount.value).toBe(0)
})
it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast()
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
})
it('stops polling on 401 error', async () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
toast.startPolling()
// Wait for initial load triggered by startPolling
await vi.advanceTimersByTimeAsync(0)
// Polling should have stopped, so advancing time should NOT call again
vi.clearAllMocks()
await vi.advanceTimersByTimeAsync(60000)
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
})
it('startPolling does not create duplicate timers', () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
toast.startPolling()
toast.startPolling()
toast.startPolling()
// Should only have one timer — verify by stopping and checking no more calls
toast.stopPolling()
})
})
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick } from 'vue'
import { useMobileBackButton } from '../useMobileBackButton'
// Helper component that uses the composable
const TestComponent = defineComponent({
setup() {
return useMobileBackButton()
},
template: '<div>{{ bottomPosition }}</div>',
})
describe('useMobileBackButton', () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
wrapper?.unmount()
vi.useRealTimers()
})
it('returns bottomPosition, bottomClass, and tabBarHeight', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as {
bottomPosition: string
bottomClass: string
tabBarHeight: number
}
expect(typeof vm.bottomPosition).toBe('string')
expect(typeof vm.bottomClass).toBe('string')
expect(typeof vm.tabBarHeight).toBe('number')
})
it('defaults tabBarHeight to 72', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(72)
})
it('computes bottomPosition as tabBarHeight + 8', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as {
bottomPosition: string
tabBarHeight: number
}
expect(vm.bottomPosition).toBe('80px') // 72 + 8
})
it('computes bottomClass with Tailwind arbitrary value', () => {
wrapper = mount(TestComponent)
const vm = wrapper.vm as unknown as { bottomClass: string }
expect(vm.bottomClass).toBe('bottom-[80px]')
})
it('reads tabBar element if present', async () => {
// Create mock tab bar element
const tabBar = document.createElement('div')
tabBar.setAttribute('data-mobile-tab-bar', '')
Object.defineProperty(tabBar, 'offsetHeight', { value: 56 })
document.body.appendChild(tabBar)
wrapper = mount(TestComponent)
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(56)
document.body.removeChild(tabBar)
})
it('falls back to CSS variable when no tab bar element', async () => {
document.documentElement.style.setProperty('--mobile-tab-bar-height', '64')
wrapper = mount(TestComponent)
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(64)
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
})
it('keeps default when no tab bar or CSS var', async () => {
wrapper = mount(TestComponent)
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
// Should keep the default of 72
expect(vm.tabBarHeight).toBe(72)
})
it('cleans up observers on unmount', () => {
wrapper = mount(TestComponent)
const removeEventSpy = vi.spyOn(window, 'removeEventListener')
wrapper.unmount()
expect(removeEventSpy).toHaveBeenCalled()
removeEventSpy.mockRestore()
})
it('updates on window resize', async () => {
const tabBar = document.createElement('div')
tabBar.setAttribute('data-mobile-tab-bar', '')
Object.defineProperty(tabBar, 'offsetHeight', {
value: 48,
writable: true,
configurable: true,
})
document.body.appendChild(tabBar)
wrapper = mount(TestComponent)
await nextTick()
// Trigger resize
window.dispatchEvent(new Event('resize'))
await nextTick()
const vm = wrapper.vm as unknown as { tabBarHeight: number }
expect(vm.tabBarHeight).toBe(48)
document.body.removeChild(tabBar)
})
})
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { useModalKeyboard } from '../useModalKeyboard'
import { defineComponent } from 'vue'
// We need to test the composable inside a component
function createTestComponent(onCloseFn: () => void) {
return defineComponent({
setup() {
const containerRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const restoreFocusRef = ref<HTMLElement | null>(null)
useModalKeyboard(containerRef, isOpen, onCloseFn, {
restoreFocusRef,
})
return { containerRef, isOpen, restoreFocusRef }
},
template: `
<div>
<button id="trigger">Trigger</button>
<div v-if="isOpen" ref="containerRef">
<button id="btn1">One</button>
<button id="btn2">Two</button>
<button id="btn3">Three</button>
</div>
</div>
`,
})
}
describe('useModalKeyboard', () => {
let closeFn: ReturnType<typeof vi.fn>
beforeEach(() => {
closeFn = vi.fn()
})
it('calls onClose when Escape is pressed and modal is open', async () => {
const Comp = createTestComponent(closeFn)
const wrapper = mount(Comp, { attachTo: document.body })
wrapper.vm.isOpen = true
await nextTick()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(closeFn).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('does not call onClose when modal is closed', () => {
const Comp = createTestComponent(closeFn)
const wrapper = mount(Comp, { attachTo: document.body })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(closeFn).not.toHaveBeenCalled()
wrapper.unmount()
})
it('cleans up listener on unmount', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const Comp = createTestComponent(closeFn)
const wrapper = mount(Comp, { attachTo: document.body })
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
removeSpy.mockRestore()
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock Audio globally
class MockAudio {
src = ''
volume = 1
play = vi.fn().mockResolvedValue(undefined)
pause = vi.fn()
currentTime = 0
addEventListener = vi.fn()
}
vi.stubGlobal('Audio', MockAudio)
// Mock AudioContext
const mockOscillator = {
type: 'sine',
frequency: { setValueAtTime: vi.fn() },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
}
const mockGain = {
gain: {
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
}
const mockAudioContext = {
createOscillator: vi.fn().mockReturnValue(mockOscillator),
createGain: vi.fn().mockReturnValue(mockGain),
currentTime: 0,
destination: {},
}
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => mockAudioContext))
import { playNavSound } from '../useNavSounds'
describe('playNavSound', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is a function', () => {
expect(playNavSound).toBeTypeOf('function')
})
it('plays move sound (default)', () => {
playNavSound()
// Should try to play a sound
})
it('plays move sound explicitly', () => {
playNavSound('move')
})
it('plays select sound', () => {
playNavSound('select')
})
it('plays action sound', () => {
playNavSound('action')
})
it('plays back sound using AudioContext', () => {
playNavSound('back')
// Back uses Web Audio API synthesis
})
it('does not throw for any sound type', () => {
expect(() => playNavSound('move')).not.toThrow()
expect(() => playNavSound('select')).not.toThrow()
expect(() => playNavSound('action')).not.toThrow()
expect(() => playNavSound('back')).not.toThrow()
})
})
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
isOnboardingComplete: vi.fn(),
completeOnboarding: vi.fn(),
},
}))
import { isOnboardingComplete, completeOnboarding } from '../useOnboarding'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('useOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('isOnboardingComplete', () => {
it('returns true when RPC says complete', async () => {
mockedRpc.isOnboardingComplete.mockResolvedValue(true)
const result = await isOnboardingComplete()
expect(result).toBe(true)
})
it('returns false when RPC says not complete', async () => {
mockedRpc.isOnboardingComplete.mockResolvedValue(false)
const result = await isOnboardingComplete()
expect(result).toBe(false)
})
it('falls back to localStorage when RPC fails with non-retryable error', async () => {
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
localStorage.setItem('neode_onboarding_complete', '1')
const result = await isOnboardingComplete()
expect(result).toBe(true)
})
it('returns false from localStorage fallback when not set', async () => {
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
const result = await isOnboardingComplete()
expect(result).toBe(false)
})
it('retries on 502 errors before falling back', async () => {
mockedRpc.isOnboardingComplete
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
.mockResolvedValueOnce(true)
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(900)
const result = await promise
expect(result).toBe(true)
expect(mockedRpc.isOnboardingComplete).toHaveBeenCalledTimes(2)
})
it('retries on 503 errors', async () => {
mockedRpc.isOnboardingComplete
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
.mockResolvedValueOnce(false)
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(900)
const result = await promise
expect(result).toBe(false)
})
it('falls back to localStorage after exhausting retries', async () => {
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('502 Bad Gateway'))
localStorage.setItem('neode_onboarding_complete', '1')
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(8000)
const result = await promise
expect(result).toBe(true)
}, 10000)
})
describe('completeOnboarding', () => {
it('calls RPC and sets localStorage', async () => {
mockedRpc.completeOnboarding.mockResolvedValue(true)
await completeOnboarding()
expect(mockedRpc.completeOnboarding).toHaveBeenCalled()
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
})
it('sets localStorage even when RPC fails', async () => {
mockedRpc.completeOnboarding.mockRejectedValue(new Error('Network error'))
const promise = completeOnboarding()
await vi.advanceTimersByTimeAsync(10000)
await promise
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
})
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useToast } from '../useToast'
describe('useToast', () => {
beforeEach(() => {
vi.useFakeTimers()
// Get a fresh toast instance and clear any leftover state
const { toasts, dismiss } = useToast()
// Dismiss all existing toasts
for (const t of [...toasts.value]) {
dismiss(t.id)
}
vi.advanceTimersByTime(500)
})
afterEach(() => {
vi.useRealTimers()
})
it('creates a success toast', () => {
const { success, toasts } = useToast()
success('Operation complete')
expect(toasts.value.length).toBeGreaterThanOrEqual(1)
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('Operation complete')
expect(toast.variant).toBe('success')
expect(toast.dismissing).toBe(false)
})
it('creates an error toast', () => {
const { error, toasts } = useToast()
error('Something went wrong')
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('Something went wrong')
expect(toast.variant).toBe('error')
})
it('creates an info toast', () => {
const { info, toasts } = useToast()
info('FYI: Node syncing')
const toast = toasts.value[toasts.value.length - 1]!
expect(toast.message).toBe('FYI: Node syncing')
expect(toast.variant).toBe('info')
})
it('auto-dismisses toast after duration', () => {
const { success, toasts } = useToast()
success('Will auto-dismiss')
const toast = toasts.value[toasts.value.length - 1]!
const toastId = toast.id
expect(toasts.value.some((t) => t.id === toastId)).toBe(true)
// After 3000ms, the toast should start dismissing
vi.advanceTimersByTime(3000)
const dismissingToast = toasts.value.find((t) => t.id === toastId)
if (dismissingToast) {
expect(dismissingToast.dismissing).toBe(true)
}
// After another 300ms, the toast should be fully removed
vi.advanceTimersByTime(300)
expect(toasts.value.some((t) => t.id === toastId)).toBe(false)
})
it('dismiss marks toast as dismissing then removes it', () => {
const { info, toasts, dismiss } = useToast()
info('Dismissable')
const toast = toasts.value[toasts.value.length - 1]!
dismiss(toast.id)
// Should be marked as dismissing
const found = toasts.value.find((t) => t.id === toast.id)
if (found) {
expect(found.dismissing).toBe(true)
}
// After 300ms animation delay, should be removed
vi.advanceTimersByTime(300)
expect(toasts.value.some((t) => t.id === toast.id)).toBe(false)
})
it('dismiss is a no-op for nonexistent toast ID', () => {
const { dismiss, toasts } = useToast()
const countBefore = toasts.value.length
dismiss(999999)
expect(toasts.value.length).toBe(countBefore)
})
it('each toast gets a unique ID', () => {
const { info, toasts } = useToast()
info('First')
info('Second')
info('Third')
const ids = toasts.value.slice(-3).map((t) => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
it('caps visible toasts at 5', () => {
const { info, toasts } = useToast()
for (let i = 0; i < 7; i++) {
info(`Toast ${i}`)
}
expect(toasts.value.length).toBeLessThanOrEqual(5)
})
it('toasts ref is readonly', () => {
const { toasts } = useToast()
// The readonly wrapper prevents direct mutation
expect(typeof toasts.value).toBe('object')
})
})
@@ -0,0 +1,60 @@
import { ref } from 'vue'
import {
parseFramesReducer,
areFramesComplete,
framesToData,
totalNumberOfFrames,
currentNumberOfFrames,
} from 'qrloop'
/**
* Collects animated-QR frames (qrloop format, as used by k484 for large
* Fedimint tokens) and reassembles them into the original token string.
*/
export function useAnimatedQRDecoder() {
const framesState = ref<ReturnType<typeof parseFramesReducer> | null>(null)
const isComplete = ref(false)
const decodedData = ref<string | null>(null)
const uniqueFrames = ref<Set<string>>(new Set())
/** Feed one scanned frame; returns true once the full payload is decoded. */
function addFrame(frame: string): boolean {
if (isComplete.value) return true
if (uniqueFrames.value.has(frame)) return false
uniqueFrames.value.add(frame)
try {
framesState.value = parseFramesReducer(framesState.value, frame)
if (areFramesComplete(framesState.value)) {
const dataBuffer = framesToData(framesState.value)
// Tokens travel as URL-safe base64
decodedData.value = dataBuffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
isComplete.value = true
return true
}
return false
} catch {
// A frame that qrloop rejects may just be a different QR format
return false
}
}
function progressText(): string {
if (!framesState.value) return ''
const total = totalNumberOfFrames(framesState.value)
const current = currentNumberOfFrames(framesState.value)
return `${current}/${total} frames`
}
function reset() {
framesState.value = null
uniqueFrames.value.clear()
isComplete.value = false
decodedData.value = null
}
return { isComplete, decodedData, addFrame, reset, progressText }
}
+123
View File
@@ -0,0 +1,123 @@
import { ref, computed } from 'vue'
const audio = ref<HTMLAudioElement | null>(null)
const currentSrc = ref<string | null>(null)
const currentName = ref('')
const playing = ref(false)
const loading = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const error = ref<string | null>(null)
let initialized = false
/** Create the Audio element and attach listeners once */
function init() {
if (initialized) return
initialized = true
audio.value = new Audio()
audio.value.addEventListener('timeupdate', () => {
currentTime.value = audio.value?.currentTime ?? 0
})
audio.value.addEventListener('loadedmetadata', () => {
duration.value = audio.value?.duration ?? 0
error.value = null
})
// Buffering / connecting over mesh|Tor → show a loader until it can play.
audio.value.addEventListener('loadstart', () => {
loading.value = true
})
audio.value.addEventListener('waiting', () => {
loading.value = true
})
audio.value.addEventListener('canplay', () => {
loading.value = false
})
audio.value.addEventListener('playing', () => {
loading.value = false
})
audio.value.addEventListener('ended', () => {
playing.value = false
loading.value = false
})
audio.value.addEventListener('pause', () => {
playing.value = false
})
audio.value.addEventListener('play', () => {
playing.value = true
error.value = null
})
audio.value.addEventListener('error', () => {
playing.value = false
loading.value = false
error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.'
})
}
function play(src: string, name: string) {
init()
error.value = null
if (currentSrc.value === src && playing.value) {
audio.value!.pause()
return
}
if (currentSrc.value !== src) {
loading.value = true
audio.value!.src = src
currentSrc.value = src
currentName.value = name
}
// play() rejects (e.g. NotSupportedError when the peer 404s and there's no
// decodable source) independently of the 'error' event above — uncaught,
// this surfaces as a raw console error instead of the friendly message
// already wired up there. The 'error' listener sets the same state, so
// this just needs to stop the rejection from going unhandled.
audio.value!.play().catch(() => {
playing.value = false
loading.value = false
})
}
function pause() {
audio.value?.pause()
}
function seek(time: number) {
if (audio.value) {
audio.value.currentTime = time
}
}
function stop() {
if (audio.value) {
audio.value.pause()
audio.value.currentTime = 0
}
playing.value = false
currentSrc.value = null
currentName.value = ''
}
const progress = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
export function useAudioPlayer() {
return {
play,
pause,
seek,
stop,
playing,
loading,
currentName,
currentTime,
duration,
progress,
currentSrc,
error,
}
}
+109
View File
@@ -0,0 +1,109 @@
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
/**
* Shared bitcoin sync (IBD) tracker with a live time-remaining estimate.
*
* Polls `bitcoin.getinfo` while at least one consumer holds an acquire()
* lease, samples the sync rate, and exposes a ticking countdown so setup
* screens can show "~2h 14m remaining" that visibly counts down between
* polls. Module-level singleton every consumer sees the same state.
*/
/** Sync fraction (as percent) at which we consider IBD done, matching the Home tile */
export const IBD_SYNCED_AT = 99.9
const POLL_MS = 15_000
const TICK_MS = 1_000
/** Ignore rate samples older than this when estimating */
const SAMPLE_WINDOW_MS = 10 * 60_000
export const bitcoinSyncPercent = ref(0)
export const bitcoinBlockHeight = ref(0)
export const bitcoinSyncAvailable = ref(false)
export const bitcoinSyncLoaded = ref(false)
export const bitcoinSynced = computed(() => bitcoinSyncLoaded.value && bitcoinSyncPercent.value >= IBD_SYNCED_AT)
const etaSeconds = ref<number | null>(null)
/** Human countdown like "2h 14m" / "5m 12s" / "less than a minute", or '' while estimating */
export const bitcoinSyncEtaText = computed(() => {
const s = etaSeconds.value
if (s === null) return ''
if (s < 60) return 'less than a minute'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
if (h > 0) return `${h}h ${m}m`
const sec = Math.floor(s % 60)
return `${m}m ${sec}s`
})
let samples: { t: number; p: number }[] = []
let etaBase: { at: number; secs: number } | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let tickTimer: ReturnType<typeof setInterval> | null = null
let leases = 0
async function poll() {
try {
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
method: 'bitcoin.getinfo',
timeout: 8000,
})
const pct = (btc.sync_progress ?? 0) * 100
bitcoinSyncPercent.value = pct
bitcoinBlockHeight.value = btc.block_height ?? 0
bitcoinSyncAvailable.value = true
bitcoinSyncLoaded.value = true
const now = Date.now()
samples.push({ t: now, p: pct })
samples = samples.filter((s) => now - s.t <= SAMPLE_WINDOW_MS).slice(-50)
if (pct >= IBD_SYNCED_AT) {
etaBase = null
etaSeconds.value = 0
return
}
const first = samples[0]
if (first && now - first.t >= 10_000 && pct > first.p) {
const ratePerSec = (pct - first.p) / ((now - first.t) / 1000)
etaBase = { at: now, secs: (IBD_SYNCED_AT - pct) / ratePerSec }
}
} catch {
bitcoinSyncAvailable.value = false
}
}
function tick() {
if (!etaBase) {
if (!bitcoinSynced.value) etaSeconds.value = null
return
}
etaSeconds.value = Math.max(0, etaBase.secs - (Date.now() - etaBase.at) / 1000)
}
/**
* Hold a polling lease. Returns a release function call it on unmount.
* Polling only runs while at least one lease is held.
*/
export function acquireBitcoinSync(): () => void {
leases++
if (leases === 1) {
void poll()
pollTimer = setInterval(() => void poll(), POLL_MS)
tickTimer = setInterval(tick, TICK_MS)
}
let released = false
return () => {
if (released) return
released = true
leases = Math.max(0, leases - 1)
if (leases === 0) {
if (pollTimer) clearInterval(pollTimer)
if (tickTimer) clearInterval(tickTimer)
pollTimer = null
tickTimer = null
}
}
}
@@ -0,0 +1,37 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
let activeLocks = 0
let previousOverflow = ''
function lock() {
if (typeof document === 'undefined') return
if (activeLocks === 0) {
previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
activeLocks += 1
}
function unlock() {
if (typeof document === 'undefined' || activeLocks === 0) return
activeLocks -= 1
if (activeLocks === 0) {
document.body.style.overflow = previousOverflow
previousOverflow = ''
}
}
export function useBodyScrollLock(active: Ref<boolean>) {
watch(
active,
(isActive, wasActive) => {
if (isActive && !wasActive) lock()
if (!isActive && wasActive) unlock()
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (active.value) unlock()
})
}
@@ -0,0 +1,45 @@
import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
export function useCollapsingHeaderTabs(
headerRef: Ref<HTMLElement | null>,
primaryRef: Ref<HTMLElement | null>,
tabsProbeRef: Ref<HTMLElement | null>,
minSearchWidth = 176,
minTabsWidth = 260
) {
const collapsed = ref(false)
let resizeObserver: ResizeObserver | null = null
function measure() {
const header = headerRef.value
const probe = tabsProbeRef.value
if (!header || !probe) return
const primaryWidth = primaryRef.value?.getBoundingClientRect().width ?? 0
const tabsWidth = probe.getBoundingClientRect().width
const gapWidth = 48
const fullTabsFit = primaryWidth + tabsWidth + minSearchWidth + gapWidth <= header.clientWidth
const usableTabsFit = primaryWidth + minTabsWidth + minSearchWidth + gapWidth <= header.clientWidth
collapsed.value = !fullTabsFit && !usableTabsFit
}
function scheduleMeasure() {
nextTick(() => requestAnimationFrame(measure))
}
onMounted(() => {
scheduleMeasure()
resizeObserver = new ResizeObserver(scheduleMeasure)
if (headerRef.value) resizeObserver.observe(headerRef.value)
if (primaryRef.value) resizeObserver.observe(primaryRef.value)
if (tabsProbeRef.value) resizeObserver.observe(tabsProbeRef.value)
window.addEventListener('resize', scheduleMeasure)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', scheduleMeasure)
})
return { collapsed, measure: scheduleMeasure }
}
@@ -0,0 +1,13 @@
import { ref } from 'vue'
/**
* Cross-view trigger for the Remote Companion intro/pairing modal
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
* App Store banner call openCompanionIntro() to pop it on demand this
* bypasses the once-per-browser auto-show gate.
*/
export const companionIntroRequested = ref(false)
export function openCompanionIntro(): void {
companionIntroRequested.value = true
}
@@ -0,0 +1,56 @@
// Escape hatch for the "Checking containers…" / "Checking..." states.
//
// If the server never flips `containers-scanned` to true (e.g. the UI missed
// a websocket broadcast), views gating on it would spin forever. This starts
// a timeout once initial data has loaded and, if the scan flag is still false
// when it fires, treats the scan as complete so the UI falls through to its
// real empty/install states. A periodic store-level resync exists too — this
// is the belt-and-suspenders guarantee that the spinner is always bounded.
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from 'vue'
const DEFAULT_SCAN_TIMEOUT_MS = 20_000
export function useContainersScanTimeout(
containersScanned: Ref<boolean>,
hasLoadedInitialData: Ref<boolean>,
timeoutMs: number = DEFAULT_SCAN_TIMEOUT_MS,
) {
const scanTimedOut = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
function clearTimer(): void {
if (timer !== undefined) {
clearTimeout(timer)
timer = undefined
}
}
watch(
[containersScanned, hasLoadedInitialData],
([scanned, loaded]) => {
if (scanned) {
// Real signal arrived — cancel the escape hatch.
clearTimer()
scanTimedOut.value = false
return
}
if (loaded && timer === undefined && !scanTimedOut.value) {
timer = setTimeout(() => {
scanTimedOut.value = true
timer = undefined
}, timeoutMs)
}
},
{ immediate: true },
)
if (getCurrentInstance()) onBeforeUnmount(clearTimer)
/** True once the server reports the scan done OR the timeout has elapsed. */
const effectiveContainersScanned = computed(
() => containersScanned.value || scanTimedOut.value,
)
return { effectiveContainersScanned, scanTimedOut }
}
@@ -0,0 +1,783 @@
/**
* Controller / gamepad navigation for Archipelago.
*
* Navigation model (from the design spec):
*
* SIDEBAR (vertical list):
* Up/Down = move between items, wraps topbottom, auto-navigates
* Right = jump to first container in main content
* Left = does nothing
*
* MAIN CONTENT (container tile grid):
* Arrows = move between containers spatially (the red tile grid)
* Enter = trigger container's primary action (navigate link / launch)
* Escape = back to sidebar
* Left from leftmost container = back to sidebar
*
* INSIDE CONTAINER (yellow inner controls entered via second Enter):
* Arrows = move between inner controls spatially
* Escape = exit back to the container tile
* Cannot move to other containers without exiting first
*
* TEXT INPUTS:
* Up/Down = exit field, navigate to nearest element
* Enter = submit (click next button)
* Left/Right = cursor movement (stay in field)
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useControllerStore } from '@/stores/controller'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { playNavSound } from '@/composables/useNavSounds'
// ─── Element Queries ────────────────────────────────────────────
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[data-controller-focus]',
'[data-controller-container]',
].join(', ')
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
el =>
!el.hasAttribute('disabled') &&
el.offsetParent !== null &&
!el.hasAttribute('data-controller-ignore') &&
!el.closest('[data-controller-ignore]')
)
}
/** Sidebar items */
function getSidebarElements(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
return zone ? getFocusableElements(zone) : []
}
/** Main zone containers only — the [C] tile grid */
function getContainers(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]')).filter(
el => el.offsetParent !== null
)
}
/** Nav bar items [N] focusable elements in main zone that are NOT inside any container
* (mode-switcher buttons, tab buttons, search inputs above the grid) */
function getNavBarItems(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return getFocusableElements(zone).filter(el =>
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
)
}
function isNavBarItem(el: HTMLElement | null): boolean {
if (!el) return false
if (!isInZone(el, 'main')) return false
if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false
// On container-free pages (e.g. Settings), don't classify elements as nav bar items —
// let them fall through to the main zone handler which supports linear up/down/right nav.
const zone = document.querySelector('[data-controller-zone="main"]')
if (zone && !zone.querySelector('[data-controller-container]')) return false
return true
}
/** Inner focusables within a container (buttons, links — not the container itself) */
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
return getFocusableElements(container).filter(
el => el !== container && !el.hasAttribute('data-controller-container')
)
}
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
if (!el) return false
return !!el.closest(`[data-controller-zone="${zone}"]`)
}
/** Topmost open modal dialog, if any — it owns navigation while visible. */
function getOpenModal(): HTMLElement | null {
const dialogs = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
).filter(el => el.offsetParent !== null)
return dialogs[dialogs.length - 1] ?? null
}
function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false
const container = el.closest('[data-controller-container]')
return !!container && container !== el
}
function isContainer(el: HTMLElement | null): boolean {
return !!el?.hasAttribute('data-controller-container')
}
// ─── Spatial Navigation ─────────────────────────────────────────
function findNearestInDirection(
from: HTMLElement,
candidates: HTMLElement[],
direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null {
const fromRect = from.getBoundingClientRect()
const fromCX = fromRect.left + fromRect.width / 2
const fromCY = fromRect.top + fromRect.height / 2
const threshold = 50
const filtered = candidates.filter(el => {
if (el === from) return false
const r = el.getBoundingClientRect()
switch (direction) {
case 'left': return r.right <= fromRect.left + threshold
case 'right': return r.left >= fromRect.right - threshold
case 'up': return r.bottom <= fromRect.top + threshold
case 'down': return r.top >= fromRect.bottom - threshold
}
})
if (!filtered.length) return null
const scored = filtered.map(el => {
const r = el.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const isVertical = direction === 'up' || direction === 'down'
const overlap = isVertical
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
return { el, overlap, dist }
})
scored.sort((a, b) => {
const isVertical = direction === 'up' || direction === 'down'
// For vertical nav: prefer closest element first, use overlap as tiebreaker.
// This prevents a distant full-width element from winning over a closer narrow one.
if (isVertical) {
// Both have overlap — prefer closer distance
if (a.overlap > 0 && b.overlap > 0) {
if (a.dist !== b.dist) return a.dist - b.dist
if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
}
// One has overlap, the other doesn't — prefer the one with overlap
if (a.overlap !== b.overlap) return b.overlap - a.overlap
return a.dist - b.dist
}
// Horizontal: overlap first (same row), then distance
if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.dist - b.dist
})
return scored[0]?.el ?? null
}
// ─── Focus Memory ───────────────────────────────────────────────
const zoneFocusMemory = new Map<string, HTMLElement>()
function rememberFocus(zone: string, el: HTMLElement) {
zoneFocusMemory.set(zone, el)
}
function recallFocus(zone: string): HTMLElement | null {
const el = zoneFocusMemory.get(zone)
if (!el) return null
if (document.contains(el) && el.offsetParent !== null) return el
zoneFocusMemory.delete(zone)
return null
}
// ─── Focus Helper ───────────────────────────────────────────────
function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
playNavSound(sound)
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
// ─── Nav-Ring Modality ──────────────────────────────────────────
// The orange focus ring must only render while the user is actually
// navigating directionally (arrows / gamepad). Ring visibility is gated in
// CSS behind html.controller-nav; directional input turns it on, any
// pointer input (tap, click) turns it off. Without this gate the ring
// painted on plain :focus — every tap on a tabindex card, and every
// programmatic route-change autofocus, lit it up with no controller in
// sight (worst on mobile).
function setNavRing(on: boolean) {
document.documentElement.classList.toggle('controller-nav', on)
}
function navRingActive(): boolean {
return document.documentElement.classList.contains('controller-nav')
}
// ─── Main Composable ────────────────────────────────────────────
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const route = useRoute()
const router = useRouter()
const store = useControllerStore()
const isControllerActive = ref(false)
const gamepadCount = ref(0)
watch([isControllerActive, gamepadCount], () => {
store.setActive(isControllerActive.value)
store.setGamepadCount(gamepadCount.value)
}, { immediate: true })
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
let pollIntervalId: ReturnType<typeof setInterval> | null = null
function checkGamepads() {
const gamepads = navigator.getGamepads?.()
const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
if (count !== gamepadCount.value) {
gamepadCount.value = count
isControllerActive.value = count > 0
}
}
// ─── Keyboard Handler ───────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
if (!navKeys.includes(e.key)) return
const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement
// ── MODAL SCOPE ──────────────────────────────────────────
// An open dialog owns navigation: focus is pulled inside, arrows move
// spatially between its controls, Enter activates. Escape stays with the
// modal's own close handling. Standard mapping, no per-modal code.
const modal = getOpenModal()
if (modal) {
if (e.key === 'Escape') return
const focusables = getFocusableElements(modal)
if (!focusables.length) return
if (!activeEl || !modal.contains(activeEl)) {
e.preventDefault()
const first = focusables[0]
if (first) focusEl(first)
return
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Typing keys stay with the field; Enter keeps the form-submit
// behavior below; only Up/Down leave the field spatially.
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
} else if (e.key === 'Enter') {
e.preventDefault()
playNavSound('action')
activeEl.click()
return
}
e.preventDefault()
const dir =
e.key === 'ArrowDown' ? ('down' as const)
: e.key === 'ArrowUp' ? ('up' as const)
: e.key === 'ArrowLeft' ? ('left' as const)
: ('right' as const)
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
if (nearest) focusEl(nearest)
return
}
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (
e.key === 'Enter' &&
target.tagName === 'INPUT' &&
(target as HTMLInputElement).type !== 'submit' &&
!target.hasAttribute('data-controller-no-submit')
) {
// Enter in input: click next button (submit pattern). Live-filter
// search boxes opt out via data-controller-no-submit — the "next
// focusable element" after a search field is often an unrelated
// button (a clear icon, a sideload/upload action) that just happens
// to sit next to it in the DOM, not a submit action for the query.
e.preventDefault()
const all = getFocusableElements(containerRef?.value ?? document)
const idx = all.indexOf(target)
const next = idx >= 0 ? all[idx + 1] : undefined
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
next.focus()
next.click()
} else if (next) {
next.focus()
}
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Up/Down: exit field, navigate spatially
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
const all = getFocusableElements(containerRef?.value ?? document)
const candidates = all.filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) {
focusEl(nearest)
} else {
// Spatial nav failed — try containers directly (e.g. search bar → first container)
const containers = getContainers()
const containerNearest = containers.length
? findNearestInDirection(target, containers, dir)
: null
if (containerNearest) {
focusEl(containerNearest)
} else {
// Last fallback: tab order
const idx = all.indexOf(target)
const fallback = dir === 'down' ? all[idx + 1] : all[idx - 1]
if (fallback) focusEl(fallback)
}
}
return
}
// Left/Right: cursor movement in field, but exit at edges
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const input = target as HTMLInputElement
const atStart = input.selectionStart === 0 && input.selectionEnd === 0
const atEnd = input.selectionStart === (input.value?.length ?? 0)
if ((e.key === 'ArrowLeft' && atStart) || (e.key === 'ArrowRight' && atEnd)) {
e.preventDefault()
const dir = e.key === 'ArrowLeft' ? 'left' as const : 'right' as const
const all = getFocusableElements(containerRef?.value ?? document)
const candidates = all.filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) focusEl(nearest)
}
return
}
// Other keys (Escape): handled below.
if (e.key !== 'Escape') return
}
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
if (e.key === 'Escape') {
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
// Inside container inner controls → exit to container
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
if (container && container.tabIndex >= 0) {
focusEl(container, 'back')
e.preventDefault()
return
}
}
// On a container or anywhere in main → go to sidebar
if (isInZone(activeEl, 'main')) {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) {
rememberFocus('main', activeEl)
focusEl(target, 'back')
e.preventDefault()
}
return
}
// Detail pages: go back
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
playNavSound('back')
window.history.back()
e.preventDefault()
}
return
}
// ── ENTER ───────────────────────────────────────────────
if (e.key === 'Enter') {
e.preventDefault()
if (isContainer(activeEl)) {
// Container declares its own click as THE action (e.g. a media file
// card whose click plays it) — without this the a[href] fallback
// below hits the card's download link and gamepad-select downloads
// a song instead of playing it.
if (activeEl.hasAttribute('data-controller-primary')) {
playNavSound('action')
activeEl.click()
return
}
// Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Prioritised action: launch button
if (activeEl.hasAttribute('data-controller-launch')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Primary link (e.g. dashboard cards with a[href])
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
if (primaryLink) {
playNavSound('action')
primaryLink.click()
return
}
// Fallback: first non-disabled action button (skip uninstall/delete buttons)
const inner = getInnerFocusables(activeEl)
const actionBtn = inner.find(el =>
(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') &&
!el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') &&
!el.closest('[class*="absolute top"]')
) ?? inner[0]
if (actionBtn) {
focusEl(actionBtn, 'action')
return
}
// Last resort: click the container itself (triggers goToApp on AppCard)
playNavSound('action')
activeEl.click()
return
}
// Regular element: click it
if (activeEl) {
playNavSound('action')
activeEl.click()
}
return
}
// ── ARROW KEYS ──────────────────────────────────────────
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
e.preventDefault()
// Mark controller as active
isControllerActive.value = true
setNavRing(true)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
// Nothing focused yet (fresh page, or focus was lost) — enter nav mode
// on the first arrow press by focusing the first container, instead of
// spatially navigating from <body>.
if (!activeEl || activeEl === document.body || activeEl === document.documentElement) {
autoFocusMain()
return
}
const dir = e.key === 'ArrowLeft' ? 'left' as const
: e.key === 'ArrowRight' ? 'right' as const
: e.key === 'ArrowUp' ? 'up' as const
: 'down' as const
// ── SIDEBAR ─────────────────────────────────────────────
if (isInZone(activeEl, 'sidebar')) {
const items = getSidebarElements()
const idx = items.indexOf(activeEl)
if (dir === 'up' || dir === 'down') {
// Linear wrap
if (idx < 0) return
const nextIdx = dir === 'down'
? (idx >= items.length - 1 ? 0 : idx + 1)
: (idx <= 0 ? items.length - 1 : idx - 1)
const next = items[nextIdx]
if (next && next !== activeEl) {
focusEl(next)
// Auto-navigate sidebar links (not buttons — Logout etc. require Enter)
if (next.tagName === 'A') {
const href = (next as HTMLAnchorElement).getAttribute('href')
if (href?.startsWith('/')) router.push(href).catch(() => {})
}
}
return
}
if (dir === 'right') {
// Jump to first container in main
rememberFocus('sidebar', activeEl)
const remembered = recallFocus('main')
// Only use remembered if it's a container (not a nav bar button)
const target = (remembered && isContainer(remembered)) ? remembered : null
const containers = getContainers()
const dest = target ?? containers[0]
if (dest) {
focusEl(dest)
} else {
// Check if this is a container-free page (e.g. Settings) — focus first button immediately
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
const hasAnyContainers = zone?.querySelector('[data-controller-container]')
if (!hasAnyContainers && zone) {
const focusable = getFocusableElements(zone)
if (focusable[0]) { focusEl(focusable[0]); return }
}
// Containers not rendered yet (route transition / animation in progress)
// Poll until they appear, up to 1s
let attempts = 0
const poll = setInterval(() => {
attempts++
const retryContainers = getContainers()
if (retryContainers[0]) {
clearInterval(poll)
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
// Last resort: focus first focusable element
if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) }
}
}, 100)
}
return
}
// Left from sidebar: does nothing
return
}
// ── INSIDE CONTAINER (inner controls) ───────────────────
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement
const inner = getInnerFocusables(container)
const next = findNearestInDirection(activeEl, inner, dir)
if (next) focusEl(next)
// Can't leave container via arrows — must use Escape
return
}
// ── NAV BAR [N] — secondary controls above the grid ────
if (isNavBarItem(activeEl)) {
const navItems = getNavBarItems()
if (dir === 'left' || dir === 'right') {
// Spatial nav between nav bar items
const next = findNearestInDirection(activeEl, navItems, dir)
if (next) { focusEl(next); return }
// Left from leftmost nav item → sidebar
if (dir === 'left') {
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
}
return
}
if (dir === 'down' || dir === 'up') {
// Up/Down from standalone element → find nearest focusable (container or button) in direction.
// Searches containers + standalone elements together so mixed pages (Settings) don't jump.
if (dir === 'down') rememberFocus('navBar', activeEl)
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const target = findNearestInDirection(activeEl, allFocusable, dir)
if (target) { focusEl(target); return }
}
}
return
}
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
if (isInZone(activeEl, 'main')) {
const containers = getContainers()
// Try spatial nav to containers + standalone focusables (not inner buttons).
// This handles mixed pages (e.g. Settings) where containers and buttons coexist.
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
const navTargets = zone ? getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
) : containers
const next = findNearestInDirection(activeEl, navTargets, dir)
if (next) {
rememberFocus('main', next)
focusEl(next)
return
}
// Up from top-row container → nav bar, or previous focusable (linear pages like Settings)
if (dir === 'up') {
const remembered = recallFocus('navBar')
if (remembered) { focusEl(remembered); return }
const navItems = getNavBarItems()
if (navItems.length) {
const nearest = findNearestInDirection(activeEl, navItems, 'up')
if (nearest) { focusEl(nearest); return }
const first = navItems[0]
if (first) { focusEl(first); return }
}
// No nav bar items — try any focusable element above (linear page nav)
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const above = findNearestInDirection(activeEl, allFocusable, 'up')
if (above) { rememberFocus('main', above); focusEl(above) }
}
return
}
// Left from leftmost container → sidebar
if (dir === 'left') {
rememberFocus('main', activeEl)
const remembered = recallFocus('sidebar')
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = remembered ?? activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
return
}
// At grid edges: try containers + nav bar items as fallback
// (prevents dead ends, but never jumps into container inner controls)
if (dir === 'down' || dir === 'right') {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
if (fallback) {
rememberFocus('main', fallback)
focusEl(fallback)
}
}
}
return
}
// ── FALLBACK: unhandled focusable element ───────────────
// Covers standalone buttons/links in empty/error states, modals, etc.
// that aren't inside a recognized zone or container.
if (dir === 'left') {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) { rememberFocus('main', activeEl); focusEl(target) }
} else {
// Exclude container inner buttons to prevent focus getting lost
const all = getFocusableElements().filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const next = findNearestInDirection(activeEl, all, dir)
if (next) focusEl(next)
}
}
// ─── Gamepad Detection ──────────────────────────────────────
function handleGamepadConnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
isControllerActive.value = true
setNavRing(true)
}
function handleGamepadDisconnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
isControllerActive.value = gamepadCount.value > 0
}
// ─── Scroll Support ────────────────────────────────────────
function handleWheel(e: WheelEvent) {
// Scroll the container UNDER THE POINTER, not the focused element. Real
// wheel events always target the element beneath the cursor, so walking up
// from e.target matches native behaviour. Using document.activeElement here
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
// peer list, still focused after a click) instead of the panel actually
// being hovered — producing a double-scroll where both moved at once.
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
if (!start) return
let p: HTMLElement | null = start
while (p) {
const style = getComputedStyle(p)
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
return
}
p = p.parentElement
}
}
// ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() {
// Only when the user is actually in directional-nav mode. For pointer
// and touch users this programmatic focus painted the first card's
// focus ring on every route change with zero controller input.
if (!navRingActive()) return
const active = document.activeElement as HTMLElement | null
// Don't steal focus from inputs, modals, or sidebar
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
if (document.querySelector('[role="dialog"]')) return
if (isInZone(active, 'sidebar')) return
requestAnimationFrame(() => {
// Re-check sidebar after RAF — user may still be navigating
if (isInZone(document.activeElement as HTMLElement, 'sidebar')) return
const remembered = recallFocus('main')
if (remembered) { remembered.focus({ preventScroll: true }); return }
const containers = getContainers()
if (containers[0]) containers[0].focus({ preventScroll: true })
})
}
watch(() => route.path, () => {
zoneFocusMemory.delete('main')
zoneFocusMemory.delete('navBar')
setTimeout(autoFocusMain, 150)
})
// ─── Lifecycle ─────────────────────────────────────────────
// Any pointer interaction ends directional-nav mode — the ring should
// never linger once the user reaches for mouse or touch.
function handlePointerDown() {
setNavRing(false)
}
onMounted(() => {
checkGamepads()
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('pointerdown', handlePointerDown, { capture: true, passive: true })
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
pollIntervalId = setInterval(() => checkGamepads(), 500)
setTimeout(autoFocusMain, 300)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('pointerdown', handlePointerDown, { capture: true } as EventListenerOptions)
window.removeEventListener('wheel', handleWheel)
window.removeEventListener('gamepadconnected', handleGamepadConnected)
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
if (pollIntervalId) clearInterval(pollIntervalId)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
setNavRing(false)
})
return { isControllerActive, gamepadCount }
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Public-demo helpers.
*
* The demo build (VITE_DEMO=1) replays the typing splash + intro/onboarding on
* EVERY fresh boot of the app at '/' (first visit or browser refresh) see
* App.vue and RootRedirect.demoRoute. Also exposes the shared demo credentials
* shown on the login screen.
*/
export const IS_DEMO =
import.meta.env.VITE_DEMO === '1' || import.meta.env.VITE_DEMO === 'true'
/** Memorable shared password for the public demo (must match the mock backend). */
export const DEMO_PASSWORD = 'entertoexit'
/** Forget any legacy per-day gate marker (pre-2026-07 builds stored one). */
export function clearDemoIntroSeen(): void {
try {
localStorage.removeItem('demo_intro_date')
} catch {
/* ignore */
}
}
// ── Demoable apps ───────────────────────────────────────────────────────────
// Only these apps actually do something in the demo (a mock UI or a real
// external site). Everything else shows "No demo" on a disabled install button
// and is not launchable.
// IndeeHub's real site sends X-Frame-Options: SAMEORIGIN, and the old
// same-origin nginx sub_filter proxy broke its runtime-built asset URLs —
// so the demo opens the real site directly instead.
const DEMO_EXTERNAL_URLS: Record<string, string> = {
indeedhub: 'https://indee.tx1138.com/',
}
// Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool
// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths
// rewritten) so the frame-busting real sites can be embedded.
const DEMO_MOCK_UI: Record<string, string> = {
mempool: '/app/mempool/',
'mempool-web': '/app/mempool/',
'bitcoin-knots': '/app/bitcoin-knots/',
'bitcoin-core': '/app/bitcoin-core/',
bitcoin: '/app/bitcoin-core/',
'bitcoin-ui': '/app/bitcoin-ui/',
electrs: '/app/electrumx/',
electrumx: '/app/electrumx/',
'archy-electrs-ui': '/app/electrumx/',
lnd: '/app/lnd/',
'lnd-ui': '/app/lnd/',
'archy-lnd-ui': '/app/lnd/',
thunderhub: '/app/lnd/',
fedimint: '/app/fedimint/',
fedimintd: '/app/fedimint/',
filebrowser: '/app/filebrowser/',
// Static placeholder dashboards served by the mock backend (DEMO_APP_PAGES).
'btcpay-server': '/app/btcpay-server/',
grafana: '/app/grafana/',
nextcloud: '/app/nextcloud/',
jellyfin: '/app/jellyfin/',
vaultwarden: '/app/vaultwarden/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
searxng: '/app/searxng/',
'uptime-kuma': '/app/uptime-kuma/',
}
/**
* Whether a demo app opens externally (new tab / in-app browser) because its
* real site blocks iframing (X-Frame-Options).
*/
export function isDemoExternal(appId: string): boolean {
return appId in DEMO_EXTERNAL_URLS
}
/** Can this app be launched/installed in the demo? */
export function isDemoApp(appId: string): boolean {
return appId in DEMO_EXTERNAL_URLS || appId in DEMO_MOCK_UI
}
/** Resolve the demo launch URL for an app, or null if it isn't demoable. */
export function demoAppUrl(appId: string): string | null {
return DEMO_EXTERNAL_URLS[appId] ?? DEMO_MOCK_UI[appId] ?? null
}
@@ -0,0 +1,57 @@
import { ref, computed, onUnmounted } from 'vue'
/** Shape of GET /electrs-status (see core electrs_status.rs ElectrsSyncStatus). */
export interface ElectrsSyncStatus {
indexed_height: number
bitcoin_height: number
network_height: number
progress_pct: number
status: string // "starting" | "waiting" | "syncing" | "indexing" | "synced" | "error"
stale: boolean
error: string | null
index_size: string | null
tor_onion: string | null
}
/**
* Polls GET /electrs-status while active. Used to show an ElectrumX sync screen
* *before* the real Electrum UI the Electrum server only accepts client
* connections (and the UI only works) once the on-chain index is built, which
* is a long initial process.
*
* Fails OPEN: if the status can't be fetched we report not-syncing, so a status
* outage never blocks the normal iframe path. We only gate the UI when we
* positively know the index is still being built (status !== "synced").
*/
export function useElectrsSync() {
const status = ref<ElectrsSyncStatus | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
async function poll() {
try {
const res = await fetch('/electrs-status', { cache: 'no-store' })
if (res.ok) status.value = (await res.json()) as ElectrsSyncStatus
} catch {
/* keep last known value; fail open */
}
}
function start() {
if (timer) return
void poll()
timer = setInterval(() => void poll(), 8000)
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
/** True only when we know ElectrumX is still building its index. */
const syncing = computed(() => !!status.value && status.value.status !== 'synced')
onUnmounted(stop)
return { status, syncing, start, stop }
}
+99
View File
@@ -0,0 +1,99 @@
import { computed, type Ref } from 'vue'
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'])
const AUDIO_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a', 'wma'])
const VIDEO_EXTS = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv'])
const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'txt', 'rtf', 'odt', 'md'])
const SHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods'])
const ARCHIVE_EXTS = new Set(['zip', 'tar', 'gz', 'rar', '7z', 'bz2'])
export type FileCategory = 'folder' | 'image' | 'audio' | 'video' | 'document' | 'spreadsheet' | 'archive' | 'file'
export function getFileCategory(ext: string, isDir: boolean): FileCategory {
if (isDir) return 'folder'
if (IMAGE_EXTS.has(ext)) return 'image'
if (AUDIO_EXTS.has(ext)) return 'audio'
if (VIDEO_EXTS.has(ext)) return 'video'
if (DOC_EXTS.has(ext)) return 'document'
if (SHEET_EXTS.has(ext)) return 'spreadsheet'
if (ARCHIVE_EXTS.has(ext)) return 'archive'
return 'file'
}
const CATEGORY_ICONS: Record<FileCategory, string[]> = {
folder: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
audio: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
video: ['M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z', 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
image: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
document: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
spreadsheet: ['M3 10h18M3 14h18M3 6h18M3 18h18M8 6v12M16 6v12'],
archive: ['M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4'],
file: ['M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'],
}
const CATEGORY_COLORS: Record<FileCategory, string> = {
folder: 'text-amber-400',
audio: 'text-orange-400',
video: 'text-purple-400',
image: 'text-blue-400',
document: 'text-green-400',
spreadsheet: 'text-emerald-400',
archive: 'text-yellow-400',
file: 'text-white/50',
}
const CATEGORY_LABELS: Record<FileCategory, string> = {
folder: 'Folder',
audio: 'Audio',
video: 'Video',
image: 'Image',
document: 'Document',
spreadsheet: 'Spreadsheet',
archive: 'Archive',
file: 'File',
}
const CATEGORY_BADGE_CLASSES: Record<FileCategory, string> = {
folder: 'bg-amber-500/15 text-amber-400/70',
audio: 'bg-orange-500/15 text-orange-400/70',
video: 'bg-purple-500/15 text-purple-400/70',
image: 'bg-blue-500/15 text-blue-400/70',
document: 'bg-green-500/15 text-green-400/70',
spreadsheet: 'bg-emerald-500/15 text-emerald-400/70',
archive: 'bg-yellow-500/15 text-yellow-400/70',
file: 'bg-white/8 text-white/50',
}
export function useFileType(ext: Ref<string>, isDir: Ref<boolean>) {
const category = computed(() => getFileCategory(ext.value, isDir.value))
const isImage = computed(() => category.value === 'image')
const isAudio = computed(() => category.value === 'audio')
const isVideo = computed(() => category.value === 'video')
const iconPaths = computed(() => CATEGORY_ICONS[category.value])
const iconColor = computed(() => CATEGORY_COLORS[category.value])
const badgeLabel = computed(() => CATEGORY_LABELS[category.value])
const badgeClass = computed(() => CATEGORY_BADGE_CLASSES[category.value])
return { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass }
}
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
export function formatDate(iso: string): string {
const date = new Date(iso)
const now = Date.now()
const diff = now - date.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return date.toLocaleDateString()
}
@@ -0,0 +1,90 @@
import { computed, watch, watchEffect, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import { useToast } from '@/composables/useToast'
import {
acquireBitcoinSync,
bitcoinSynced,
bitcoinSyncLoaded,
} from '@/composables/useBitcoinSync'
// Session-level guard: the "finish setup" toast fires at most once per page load.
let firedThisSession = false
/**
* Watches for Bitcoin IBD completing while a Lightning setup goal is mid-flight
* and pops a "Finish setup" toast linking back to that goal's wizard (which is
* sitting on the fund-wallet / open-channel steps). Mount once in the
* dashboard layout.
*/
export function useIbdFinishWatcher() {
const goalStore = useGoalStore()
const router = useRouter()
const toast = useToast()
// A goal qualifies while it's in progress and its manual fund/channel steps
// aren't done yet. If several qualify, the first wins — finishing the shared
// fund + channel steps completes the lightning part of any of them.
const pendingLightningGoalId = computed<string | null>(() => {
if (firedThisSession) return null
for (const goal of GOALS) {
const hasFundStep = goal.steps.some((s) => s.action === 'fund')
if (!hasFundStep) continue
if (goalStore.getGoalStatus(goal.id) !== 'in-progress') continue
const done = goalStore.progress[goal.id]?.completedSteps ?? []
const manualPending = goal.steps.some(
(s) => s.action !== 'install' && !done.includes(s.id),
)
if (manualPending) return goal.id
}
return null
})
// Only poll the chain while there's actually a goal waiting on it.
let release: (() => void) | null = null
watchEffect(() => {
const shouldWatch = pendingLightningGoalId.value !== null && !bitcoinSynced.value
if (shouldWatch && !release) {
release = acquireBitcoinSync()
} else if (!shouldWatch && release) {
// Goal finished/reset or the chain synced — stop polling.
release()
release = null
}
})
// Fire only on a REAL transition: we must have observed the chain unsynced
// at least once this session, so a node that's already synced at page load
// doesn't toast.
let sawUnsynced = false
watch([bitcoinSynced, bitcoinSyncLoaded], ([synced, loaded]) => {
if (!loaded) return
if (!synced) {
sawUnsynced = true
return
}
if (!sawUnsynced || firedThisSession) return
const goalId = pendingLightningGoalId.value
if (!goalId) return
firedThisSession = true
toast.action(
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
{
label: 'Finish setup',
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
},
)
if (release) {
release()
release = null
}
})
onUnmounted(() => {
if (release) {
release()
release = null
}
})
}
+312
View File
@@ -0,0 +1,312 @@
/**
* Login screen audio: intro loop (MP3) + transition sounds.
*
* First-install vs returning-user gate: the synthwave loop, welcome
* voice, pop/whoosh/oomph transitions exist for the first-boot cinematic
* moment. After the user has completed onboarding we silence all of
* them every subsequent login should be quiet. Typing sounds are
* exempt and continue to play regardless.
*/
/** One-way, per-page-load override: when App.vue decides to run the intro
* splash (demo fresh boot, prod first install, Replay Intro), the WHOLE
* cinematic speech, synthwave, pops must be audible even though
* localStorage already says onboarding is complete. Without this the
* replayed intro runs silent (lost "Welcome Noderunner" + song). */
let cinematicMode = false
export function enableCinematicSounds() {
cinematicMode = true
}
/** True when the node has not yet completed onboarding i.e. we're
* still in the first-install cinematic or when the intro is being
* deliberately replayed this page load (see enableCinematicSounds).
* Reads the localStorage cache set by useOnboarding (which is
* re-seeded from the backend on each successful check), so this stays
* correct after a browser clear once the onboarding-complete probe
* runs. Sound calls that fire before that probe completes will fall
* through silent on an already-onboarded node which is exactly what
* we want: ordinary re-logins stay quiet. */
function isFirstInstallPhase(): boolean {
if (cinematicMode) return true
try {
return localStorage.getItem('neode_onboarding_complete') !== '1'
} catch {
return true
}
}
let audioContext: AudioContext | null = null
let introAudio: HTMLAudioElement | null = null
let introGain: GainNode | null = null
/** Get AudioContext - only returns existing. Create via resumeAudioContext() after user gesture. */
function getContext(): AudioContext | null {
return audioContext
}
/** Create AudioContext if needed (call only from user gesture - click/tap/key) */
function ensureContext(): AudioContext | null {
if (audioContext) return audioContext
try {
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
if (!Ctx) return null
audioContext = new Ctx()
return audioContext
} catch {
return null
}
}
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in.
* Uses Web Audio API so it plays after context is resumed (user gesture). */
export function playLoopStart() {
if (!isFirstInstallPhase()) return
const ctx = getContext()
if (!ctx) return
try {
if (ctx.state === 'suspended') ctx.resume()
} catch {
return
}
fetch(LOOP_START_URL)
.then((res) => res.arrayBuffer())
.then((buf) => ctx.decodeAudioData(buf))
.then((decoded) => {
const src = ctx.createBufferSource()
src.buffer = decoded
const gain = ctx.createGain()
gain.gain.value = 0.5
src.connect(gain)
gain.connect(ctx.destination)
src.start(0)
})
.catch(() => {})
}
/** Resume audio context - MUST be called from user gesture (click/tap/key). Creates context if needed. */
export function resumeAudioContext() {
const ctx = ensureContext()
if (ctx?.state === 'suspended') {
ctx.resume().catch(() => {})
}
}
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
export function startSynthwave() {
if (!isFirstInstallPhase()) return
const ctxOrNull = getContext()
if (!ctxOrNull) return
try {
if (ctxOrNull.state === 'suspended') ctxOrNull.resume().catch(() => {})
} catch {
return
}
stopSynthwave()
const audio = new Audio(INTRO_AUDIO_URL)
audio.loop = true
const ctx = ctxOrNull
const source = ctx.createMediaElementSource(audio)
const gainNode = ctx.createGain()
gainNode.gain.value = 0.25
source.connect(gainNode)
gainNode.connect(ctx.destination)
introGain = gainNode
introAudio = audio
audio.play().catch(() => {})
}
/** Stop intro loop (call on login success) */
export function stopSynthwave() {
if (introAudio) {
if (introGain && audioContext) {
const t = audioContext.currentTime
introGain.gain.setValueAtTime(introGain.gain.value, t)
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
}
setTimeout(() => {
introAudio?.pause()
introAudio = null
introGain = null
}, 220)
}
}
/** Stop ALL login audio and close AudioContext. Call on route change to dashboard. */
export function stopAllAudio() {
// Stop synthwave loop
if (introAudio) {
introAudio.pause()
introAudio = null
introGain = null
}
// Stop intro typing
stopIntroTyping()
// Close AudioContext to kill any lingering BufferSource nodes (playLoopStart)
if (audioContext) {
audioContext.close().catch(() => {})
audioContext = null
}
}
/** Pop sound - plays when intro initiator (tap to start) is pressed */
export function playPop() {
if (!isFirstInstallPhase()) return
const audio = new Audio('/assets/audio/pop.mp3')
audio.volume = 0.6
audio.play().catch(() => {})
}
/** Whoosh transition on successful login */
export function playLoginSuccessWhoosh() {
if (!isFirstInstallPhase()) return
const woosh = new Audio('/assets/audio/woosh.mp3')
woosh.volume = 0.5
woosh.play().catch(() => {})
}
/** Typing sound - plays once when welcome typing starts (typing.mp3) */
export function playTypingSound() {
const audio = new Audio('/assets/audio/typing.mp3')
audio.volume = 0.6
audio.play().catch(() => {})
}
/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */
let introTypingAudio: HTMLAudioElement | null = null
const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3'
export function playIntroTyping() {
stopIntroTyping()
introTypingAudio = new Audio(INTRO_TYPING_URL)
introTypingAudio.volume = 0.5
introTypingAudio.loop = true
introTypingAudio.play().catch(() => {})
}
export function stopIntroTyping() {
if (introTypingAudio) {
introTypingAudio.pause()
introTypingAudio.currentTime = 0
introTypingAudio = null
}
}
const WELCOME_SPEECH_URL = '/assets/audio/welcome-noderunner.mp3'
/** Sci-fi female voice: "Welcome Noderunner" - plays when welcome text types in.
* Requires pre-recorded audio from ElevenLabs. Run:
* ELEVENLABS_API_KEY=your_key node neode-ui/scripts/generate-welcome-speech.js
* Browse sci-fi voices at elevenlabs.io/voice-library and set ELEVENLABS_VOICE_ID for custom voice. */
export function playWelcomeNoderunnerSpeech() {
if (!isFirstInstallPhase()) return
const audio = new Audio(WELCOME_SPEECH_URL)
audio.volume = 0.9
audio.play().catch(() => {})
}
/** Typing tick - for dashboard welcome typing (typing.mp3) */
let typingTickPool: HTMLAudioElement[] = []
const TYPING_TICK_POOL_SIZE = 5
function getTypingTick(): HTMLAudioElement {
if (typingTickPool.length === 0) {
for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) {
const a = new Audio('/assets/audio/typing.mp3')
a.volume = 0.4
typingTickPool.push(a)
}
}
const a = typingTickPool.shift()!
typingTickPool.push(a)
return a
}
export function playTypingTick() {
const a = getTypingTick()
a.currentTime = 0
a.play().catch(() => {})
}
/** Keyboard input sound - short synthesized click per key. Does NOT use typing.mp3 or intro-typing.mp3. */
export function playKeyboardTypingSound() {
const ctx = getContext()
if (!ctx) return
try {
if (ctx.state === 'suspended') ctx.resume()
} catch {
return
}
const t = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(1200, t)
gain.gain.setValueAtTime(0, t)
gain.gain.linearRampToValueAtTime(0.06, t + 0.002)
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(t)
osc.stop(t + 0.04)
}
/** Gaming-style boot thud - soft impact when dashboard loads */
/**
* @param force Play regardless of install phase. The first dashboard entry
* right after the onboarding wizard needs this: by then onboarding is already
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
* exists to keep ordinary re-logins quiet) would silence the one entrance
* that SHOULD be loud.
*/
export function playDashboardLoadOomph(force = false) {
if (!force && !isFirstInstallPhase()) return
// The login→dashboard route change runs stopAllAudio(), which CLOSES the
// AudioContext — so the context must be recreated here, not just fetched.
// The login click's sticky user activation lets the fresh context run.
const ctx = ensureContext()
if (!ctx) return
try {
if (ctx.state === 'suspended') ctx.resume()
} catch {
return
}
const t = ctx.currentTime
// Soft layered thud - sine only, smooth attack/decay
const layers = [
{ freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 },
{ freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 },
{ freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 },
{ freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 },
]
for (let i = 0; i < layers.length; i++) {
const L = layers[i]!
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = 'sine'
osc.frequency.value = L.freq
g.gain.setValueAtTime(0, t)
g.gain.linearRampToValueAtTime(L.gain, t + L.attack)
g.gain.exponentialRampToValueAtTime(0.001, t + L.dur)
osc.connect(g)
g.connect(ctx.destination)
osc.start(t + i * 0.01)
osc.stop(t + L.dur)
}
}
@@ -0,0 +1,73 @@
import { ref } from 'vue'
export interface MarketplaceAppInfo {
id: string
title: string
version: string
icon: string
category: string
description: string | { short?: string; long?: string }
author: string
source: string
manifestUrl: string
url: string
repoUrl: string
s9pkUrl: string
dockerImage: string
/** External web URL for iframe-based web apps (no container needed) */
webUrl?: string
screenshots?: AppScreenshot[]
containerConfig?: {
ports?: string[]
volumes?: string[]
env?: string[]
command?: string
args?: string[]
}
}
export type AppScreenshot = string | {
src: string
alt?: string
}
// Simple in-memory store for the current marketplace app
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
export function useMarketplaceApp() {
function setCurrentApp(app: Partial<MarketplaceAppInfo> & { id: string }) {
// Create a clean, serializable copy
currentMarketplaceApp.value = {
id: app.id,
title: app.title ?? '',
version: app.version ?? '',
icon: app.icon ?? '',
category: app.category ?? '',
description: app.description ?? '',
author: app.author ?? '',
source: app.source ?? '',
manifestUrl: app.manifestUrl || app.s9pkUrl || app.url || '',
url: app.url || app.s9pkUrl || app.manifestUrl || '',
repoUrl: app.repoUrl ?? '',
s9pkUrl: app.s9pkUrl ?? '',
dockerImage: app.dockerImage ?? '',
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
screenshots: Array.isArray(app.screenshots) ? app.screenshots : undefined,
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
}
}
function getCurrentApp() {
return currentMarketplaceApp.value
}
function clearCurrentApp() {
currentMarketplaceApp.value = null
}
return {
setCurrentApp,
getCurrentApp,
clearCurrentApp
}
}
+114
View File
@@ -0,0 +1,114 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
export interface ReceivedMessage {
from_pubkey: string
message: string
timestamp: string
}
const MESSAGE_POLL_INTERVAL = 30000 // 30s
// Shared state (singleton) so toast works across route changes
const receivedMessages = ref<ReceivedMessage[]>([])
const lastMessageCount = ref(0)
const loadingMessages = ref(false)
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
let pollTimer: ReturnType<typeof setInterval> | null = null
export function useMessageToast() {
const router = useRouter()
const unreadCount = computed(() =>
Math.max(0, receivedMessages.value.length - lastMessageCount.value)
)
async function loadReceivedMessages() {
loadingMessages.value = true
try {
const res = await rpcClient.getReceivedMessages()
const msgs = (res.messages || []) as ReceivedMessage[]
receivedMessages.value = msgs
// New messages since last check? (don't show toast on initial load)
if (msgs.length > lastMessageCount.value && lastMessageCount.value > 0) {
const newCount = msgs.length - lastMessageCount.value
const latest = msgs[msgs.length - 1]
toastMessage.value = {
show: true,
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
// Only deep-link to a specific chat when it's a single new message
// from one sender; otherwise open the mesh list.
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
}
lastMessageCount.value = msgs.length
} else {
lastMessageCount.value = msgs.length
}
} catch (e) {
// Stop polling on auth failure — session expired, no point retrying
if (e instanceof Error && /401|Unauthorized/i.test(e.message)) {
stopPolling()
return
}
if (import.meta.env.DEV) console.error('Failed to load messages:', e)
} finally {
loadingMessages.value = false
}
}
function isAuthenticated(): boolean {
return localStorage.getItem('neode-auth') === 'true'
}
function startPolling() {
if (pollTimer) return
if (!isAuthenticated()) return
loadReceivedMessages()
pollTimer = setInterval(() => {
if (!isAuthenticated()) {
stopPolling()
return
}
loadReceivedMessages()
}, MESSAGE_POLL_INTERVAL)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function markAsRead() {
lastMessageCount.value = receivedMessages.value.length
}
function dismissToastAndOpenMessages() {
const peer = toastMessage.value.fromPubkey
toastMessage.value = { show: false, text: '', fromPubkey: '' }
markAsRead()
// Open the specific conversation when we know the sender; else the mesh list.
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
}
// Dismiss the toast without navigating (the close icon).
function closeToast() {
toastMessage.value = { show: false, text: '', fromPubkey: '' }
}
return {
receivedMessages,
lastMessageCount,
loadingMessages,
toastMessage,
unreadCount,
loadReceivedMessages,
startPolling,
stopPolling,
markAsRead,
dismissToastAndOpenMessages,
closeToast,
}
}
@@ -0,0 +1,103 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
/**
* Composable for mobile back button positioning
* Ensures back buttons are always 8px above the mobile tab bar
* Uses ResizeObserver to reactively update when tab bar height changes
*/
export function useMobileBackButton() {
const tabBarHeight = ref<number>(72) // Default fallback height
// Computed property for bottom position - always 16px above tab bar
const bottomPosition = computed(() => {
return `${tabBarHeight.value + 8}px`
})
// Computed property for Tailwind class (for use in class bindings)
const bottomClass = computed(() => {
// Use Tailwind arbitrary value with the computed height
return `bottom-[${tabBarHeight.value + 8}px]`
})
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
function updateTabBarHeight() {
if (typeof window === 'undefined') return
// Try to find the mobile tab bar element
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
if (tabBar && tabBar.offsetHeight > 0) {
tabBarHeight.value = tabBar.offsetHeight
return
}
// Fallback: read from CSS variable if available
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--mobile-tab-bar-height')
.trim()
if (cssVar) {
const height = parseFloat(cssVar)
if (!isNaN(height) && height > 0) {
tabBarHeight.value = height
return
}
}
// Final fallback: keep current value (don't reset to 0)
}
onMounted(() => {
// Initial update
updateTabBarHeight()
// Watch for CSS variable changes
mutationObserver = new MutationObserver(() => {
updateTabBarHeight()
})
mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style'],
})
// Watch for tab bar size changes
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
if (tabBar && 'ResizeObserver' in window) {
resizeObserver = new ResizeObserver(() => {
updateTabBarHeight()
})
resizeObserver.observe(tabBar)
}
// Also listen to window resize as fallback
window.addEventListener('resize', updateTabBarHeight)
// Periodic check to ensure we're always in sync (safety net)
intervalId = setInterval(() => {
updateTabBarHeight()
}, 1000)
})
onBeforeUnmount(() => {
if (mutationObserver) {
mutationObserver.disconnect()
}
if (resizeObserver) {
resizeObserver.disconnect()
}
if (intervalId) {
clearInterval(intervalId)
}
window.removeEventListener('resize', updateTabBarHeight)
})
return {
bottomPosition,
bottomClass,
tabBarHeight,
}
}
@@ -0,0 +1,74 @@
/**
* Modal keyboard navigation: Escape to close, Arrow keys to move between buttons.
* Restores focus to the previously active element when closing via Escape.
*/
import { onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
export interface UseModalKeyboardOptions {
restoreFocusRef?: Ref<HTMLElement | null>
}
export function useModalKeyboard(
containerRef: Ref<HTMLElement | null>,
isOpen: Ref<boolean>,
onClose: () => void,
options?: UseModalKeyboardOptions
) {
const restoreFocusRef = options?.restoreFocusRef
// Save the element that had focus when modal opens (before focus moves to modal)
watch(isOpen, (open) => {
if (open && restoreFocusRef) {
restoreFocusRef.value = document.activeElement as HTMLElement | null
}
})
function getFocusables(): HTMLElement[] {
const el = containerRef.value
if (!el) return []
return Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(e) => e.offsetParent !== null
)
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen.value) return
if (e.key === 'Escape') {
restoreFocusRef?.value?.focus?.()
onClose()
e.preventDefault()
e.stopPropagation()
return
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
const focusables = getFocusables()
if (focusables.length === 0) return
const current = document.activeElement as HTMLElement | null
const idx = current ? focusables.indexOf(current) : -1
let nextIdx: number
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
nextIdx = idx < focusables.length - 1 ? idx + 1 : 0
} else {
nextIdx = idx > 0 ? idx - 1 : focusables.length - 1
}
focusables[nextIdx]?.focus()
e.preventDefault()
e.stopPropagation()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown, true)
})
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Epic interface sounds for controller/keyboard navigation.
* Layered synthesis - cool, impactful, celebratory for actions.
*/
let audioContext: AudioContext | null = null
function getContext(): AudioContext | null {
if (audioContext) return audioContext
try {
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
return audioContext
} catch {
return null
}
}
function playTone(
ctx: AudioContext,
freq: number,
duration: number,
gain: number,
type: OscillatorType = 'sine',
startOffset = 0
) {
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.connect(g)
g.connect(ctx.destination)
g.gain.setValueAtTime(0, ctx.currentTime)
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
osc.frequency.value = freq
osc.type = type
osc.start(ctx.currentTime + startOffset)
osc.stop(ctx.currentTime + startOffset + duration)
}
export function playNavSound(type: 'move' | 'select' | 'action' | 'back' = 'move') {
if (type === 'move') {
const audio = new Audio('/assets/audio/arrows.mp3')
audio.volume = 0.5
audio.play().catch(() => {})
return
}
if (type === 'select' || type === 'action') {
const audio = new Audio('/assets/audio/enter.mp3')
audio.volume = 0.5
audio.play().catch(() => {})
return
}
const ctx = getContext()
if (!ctx) return
try {
if (ctx.state === 'suspended') ctx.resume()
} catch {
return
}
switch (type) {
case 'back': {
playTone(ctx, 440, 0.06, 0.08, 'sine')
playTone(ctx, 330, 0.08, 0.05, 'sine', 0.03)
playTone(ctx, 220, 0.1, 0.04, 'triangle', 0.05)
break
}
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Onboarding state - backend is authoritative.
* "Unknown" (backend unreachable) must NEVER default to false
* that would falsely send an already-onboarded user back through
* the intro after a browser clear / update / reboot.
*/
import { rpcClient } from '@/api/rpc-client'
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T | null> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (e) {
const msg = e instanceof Error ? e.message : ''
const isRetryable = /502|503|504|timeout|fetch|network|abort/i.test(msg)
if (!isRetryable || i === maxRetries - 1) return null
// Exponential-ish backoff: 500, 1000, 2000, 4000, 8000 (capped)
const delay = Math.min(500 * Math.pow(2, i), 8000)
await new Promise((r) => setTimeout(r, delay))
}
}
return null
}
/**
* Returns true/false if the backend gave a definitive answer, null if
* the backend is unreachable. Callers MUST handle null explicitly
* do not coerce to boolean without thinking about the consequences.
*/
export async function checkOnboardingStatus(): Promise<boolean | null> {
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 5)
if (result !== null) {
if (result) {
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
} else {
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
}
}
return result
}
/**
* Boolean-only variant for places that genuinely cannot wait.
* Backend answer wins; on backend-unreachable, trusts a prior
* localStorage cache (set by a past successful check on THIS node).
* Returns false only when both the backend and the cache agree
* or when the cache is empty on a genuinely fresh install.
*
* Prefer checkOnboardingStatus() where possible so the caller can
* distinguish "confirmed fresh install" from "can't reach backend".
*/
export async function isOnboardingComplete(): Promise<boolean> {
const result = await checkOnboardingStatus()
if (result !== null) return result
// Backend unreachable — trust the local cache. If the cache says
// we're onboarded, we almost certainly are (this browser saw a
// prior backend 'true' and re-seeded the flag). If the cache is
// empty, we genuinely don't know; returning false here is the
// last-resort fallback, and the calling views should additionally
// keep polling the backend instead of treating this as gospel.
return localStorage.getItem('neode_onboarding_complete') === '1'
}
export async function completeOnboarding(): Promise<void> {
await callWithRetry(() => rpcClient.completeOnboarding(), 3)
localStorage.setItem('neode_onboarding_complete', '1')
localStorage.removeItem('neode_onboarding_step')
}
/** Save current onboarding step so refresh resumes where user left off */
export function saveOnboardingStep(step: string): void {
localStorage.setItem('neode_onboarding_step', step)
}
/** Get the last saved onboarding step, or 'intro' if none */
export function getSavedOnboardingStep(): string {
return localStorage.getItem('neode_onboarding_step') || 'intro'
}
+56
View File
@@ -0,0 +1,56 @@
import { ref, readonly } from 'vue'
export type ToastVariant = 'success' | 'error' | 'info'
export interface ToastAction {
label: string
onClick: () => void
}
export interface ToastItem {
id: number
message: string
variant: ToastVariant
dismissing: boolean
action?: ToastAction
}
const toasts = ref<ToastItem[]>([])
let nextId = 0
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) {
const id = nextId++
toasts.value.push({ id, message, variant, dismissing: false, action })
// Auto-dismiss
if (duration > 0) {
setTimeout(() => dismissToast(id), duration)
}
// Cap at 5 visible toasts
if (toasts.value.length > 5) {
toasts.value.shift()
}
}
function dismissToast(id: number) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx === -1) return
toasts.value[idx]!.dismissing = true
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 300)
}
export function useToast() {
return {
toasts: readonly(toasts),
success: (msg: string) => addToast(msg, 'success'),
error: (msg: string) => addToast(msg, 'error'),
info: (msg: string) => addToast(msg, 'info'),
/** Toast with an action link (e.g. "Finish setup"). Sticks around longer. */
action: (msg: string, action: ToastAction, opts?: { variant?: ToastVariant; duration?: number }) =>
addToast(msg, opts?.variant ?? 'success', opts?.duration ?? 15000, action),
dismiss: dismissToast,
}
}

Some files were not shown because too many files have changed in this diff Show More