Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
<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 />
|
||||
|
||||
<!-- "You need a Lightning node" install prompt. Global because it is
|
||||
raised from inside other modals (wallet Receive, the Web5 sheet, the
|
||||
app launcher's paywall) — one instance, shared state. -->
|
||||
<LightningRequiredModal />
|
||||
|
||||
<!-- 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 LightningRequiredModal from './components/LightningRequiredModal.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'
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
|
||||
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')
|
||||
// Companion in-app demo never requests the splash replay; browser demo unaffected.
|
||||
if (IS_DEMO && bootPath === '/' && !isCompanionApp()) 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) {
|
||||
// Companion in-app demo skips the intro and lands on /login; browser demo unaffected.
|
||||
if (isCompanionApp()) {
|
||||
await router.push('/login').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
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,98 @@
|
||||
/**
|
||||
* Regression pin for T-13-39 — `streamUrl` used to append `?auth=<jwt>` to
|
||||
* the raw-file URL, leaking the filebrowser JWT into browser history,
|
||||
* `Referer` headers and access logs. 13-CONTEXT.md names this "the known
|
||||
* leak to fix rather than propagate"; this file pins the fix so it cannot
|
||||
* silently regress.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// FileBrowserClient reads window.location.origin in its constructor.
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
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)])),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileBrowserClient.streamUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = false
|
||||
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
})
|
||||
|
||||
it('resolves to a same-origin raw-file URL with no query component', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'super-secret-jwt-token' } }))
|
||||
|
||||
const url = await fileBrowserClient.streamUrl('/Music/song.m4a')
|
||||
|
||||
expect(url).toBe('http://localhost/app/filebrowser/api/raw/Music/song.m4a')
|
||||
expect(url).not.toContain('?')
|
||||
})
|
||||
|
||||
it('never embeds the filebrowser JWT anywhere in the returned string', async () => {
|
||||
const token = 'super-secret-jwt-token-value-12345'
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token } }))
|
||||
|
||||
const url = await fileBrowserClient.streamUrl('/Videos/movie.mp4')
|
||||
|
||||
expect(url).not.toContain(token)
|
||||
expect(url).not.toMatch(/[?&]auth=/)
|
||||
})
|
||||
|
||||
it('awaits authentication (sets the cookie the media request relies on) before returning', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-abc' } }))
|
||||
|
||||
await fileBrowserClient.streamUrl('/Videos/movie.mp4')
|
||||
|
||||
// The cookie login() sets is what the same-origin media request depends
|
||||
// on now that the URL itself carries no credential — assert it's really
|
||||
// there by the time the caller has the URL in hand.
|
||||
expect(document.cookie).toContain('auth=jwt-abc')
|
||||
})
|
||||
|
||||
it('does not re-authenticate when a valid session already exists', async () => {
|
||||
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
|
||||
document.cookie = 'auth=already-authed'
|
||||
|
||||
const url = await fileBrowserClient.streamUrl('/a.mp3')
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(url).toBe('http://localhost/app/filebrowser/api/raw/a.mp3')
|
||||
})
|
||||
|
||||
it('still resolves traversal via sanitizePath — a path cannot escape root', async () => {
|
||||
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
|
||||
document.cookie = 'auth=already-authed'
|
||||
|
||||
const url = await fileBrowserClient.streamUrl('/Music/../../etc/passwd')
|
||||
|
||||
expect(url).toBe('http://localhost/app/filebrowser/api/raw/etc/passwd')
|
||||
expect(url).not.toContain('..')
|
||||
})
|
||||
})
|
||||
@@ -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,601 @@
|
||||
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('federationInvite omits password when none is given', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationInvite forwards the password for a trusted invite', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({ trust_level: 'trusted', password: 'hunter2' })
|
||||
})
|
||||
|
||||
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('federationSetTrust omits password on demotion', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'observer' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationSetTrust forwards the password when promoting', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({
|
||||
did: 'did:key:z',
|
||||
trust_level: 'trusted',
|
||||
password: 'hunter2',
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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 for video/audio `<src>` where the browser
|
||||
* needs to make Range requests.
|
||||
*
|
||||
* Carries NO credential in the query string (T-13-39, fixed 2026-08-03 —
|
||||
* this was "the known leak to fix rather than propagate", per
|
||||
* 13-CONTEXT.md). `login()` already sets the filebrowser JWT as a
|
||||
* `path=/` cookie on this page's own origin, `baseUrl` is that same
|
||||
* origin, and the browser attaches the cookie to the same-origin media
|
||||
* subresource request automatically — the same mechanism filebrowser's
|
||||
* own web UI relies on. Putting the token in the URL too was redundant,
|
||||
* and it reached browser history, `Referer` headers and any access log on
|
||||
* the path. The cookie itself is unchanged by this fix: it is still a
|
||||
* 24-hour JWT, now confined to the cookie jar rather than also appearing
|
||||
* in the URL.
|
||||
*/
|
||||
async streamUrl(path: string): Promise<string> {
|
||||
await this.ensureAuth()
|
||||
const safePath = sanitizePath(path)
|
||||
return `${this.baseUrl}/api/raw${safePath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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,679 @@
|
||||
<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'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
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.openInAppEx === 'function' && store.title) {
|
||||
native.openInAppEx(store.url, '', store.title)
|
||||
return
|
||||
}
|
||||
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') {
|
||||
// Both arms below need a Lightning node — paying an invoice and minting
|
||||
// one. With none installed, raise the install modal instead of failing.
|
||||
if (!(await lightning.requireLightningReady('send'))) return
|
||||
if (pay.invoice) {
|
||||
// Tracked to a real terminal state — slow routing is not a failure.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
if (res.status === 'pending') throw new Error('Payment is still settling — check your wallet transactions before retrying.')
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<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.
|
||||
Gated on the owning view being active: this is Teleported to <body>, so
|
||||
it lives outside the view's own subtree and KeepAlive deactivating the
|
||||
owner does NOT remove it — the button stayed pinned above the tab bar on
|
||||
every other screen. activated/deactivated propagate from the KeepAlive
|
||||
boundary down to this child, so the shared component can guard itself and
|
||||
every caller is fixed at once. -->
|
||||
<Teleport v-if="isViewActive" 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">
|
||||
import { useViewActive } from '@/composables/useViewActive'
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
const isViewActive = useViewActive()
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,134 @@
|
||||
<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, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
import { useModalHistory } from '@/composables/useModalHistory'
|
||||
|
||||
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)
|
||||
|
||||
// A modal must not outlive the screen that raised it. Tab views are
|
||||
// KeepAlive'd, so navigating away deactivates the owner rather than
|
||||
// unmounting it, and a Teleported modal keeps floating over the destination
|
||||
// (seen with the Lightning "Open a channel" / "Setup Guide" actions, which
|
||||
// route away from inside the wallet's own modal). Closing on any route change
|
||||
// is the general fix — every modal here is a transient dialog.
|
||||
//
|
||||
// `useRoute()` returns undefined when no router is installed (component
|
||||
// tests mount BaseModal bare), so the getter is optional-chained.
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route?.fullPath,
|
||||
(to, from) => {
|
||||
if (to !== from && props.show) emit('close')
|
||||
},
|
||||
)
|
||||
|
||||
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))
|
||||
// Browser/mouse/gesture Back closes the modal instead of navigating the
|
||||
// router out from under it — the native-app behaviour kiosk and mobile
|
||||
// browsers expect (the companion webview already provides it natively).
|
||||
useModalHistory(computed(() => props.show), close)
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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,474 @@
|
||||
<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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
<!-- Which version the Download button installs — read from the
|
||||
metadata staged beside the APK; absent file, absent note -->
|
||||
<p v-if="companionVersion" class="text-xs text-white/40 text-center mt-3">
|
||||
Version {{ companionVersion.versionName }}<template v-if="companionVersion.versionCode"> (build {{ companionVersion.versionCode }})</template>
|
||||
</p>
|
||||
</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 on the release server's https
|
||||
// domain (bare-IP origins retired 2026-08-11; /packages/ is proxied to the
|
||||
// same package host that previously answered on the IP).
|
||||
// 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`
|
||||
: 'https://source.archipelago-foundation.org/packages/archipelago-companion.apk'
|
||||
|
||||
// Version note for the download step. Read from the node's own copy of the
|
||||
// metadata (ships in the frontend beside the APK at /packages/), written by
|
||||
// publish-companion-apk.sh from the same gradle config that built the APK.
|
||||
// Best-effort: no file, no note.
|
||||
const companionVersion = ref<{ versionName: string; versionCode: number } | null>(null)
|
||||
async function loadCompanionVersion() {
|
||||
try {
|
||||
const res = await fetch('/packages/archipelago-companion.json', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const meta = await res.json()
|
||||
if (meta && typeof meta.versionName === 'string' && meta.versionName) {
|
||||
companionVersion.value = { versionName: meta.versionName, versionCode: Number(meta.versionCode) || 0 }
|
||||
}
|
||||
} catch { /* metadata is a nicety — the download works without it */ }
|
||||
}
|
||||
|
||||
// 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
|
||||
if (!companionVersion.value) void loadCompanionVersion()
|
||||
// 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)
|
||||
* - a .fips name or mesh ULA (operator browsing over the mesh — a scanned
|
||||
* QR carried npub….fips as fhost on 2026-07-24; Android's system DNS
|
||||
* can't resolve .fips, so the phone's direct dial died and first
|
||||
* connect crawled through anchor discovery instead of the LAN)
|
||||
*/
|
||||
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) ||
|
||||
hostname.endsWith('.fips') ||
|
||||
hostname.includes(':') // IPv6 literal — the node's mesh ULA
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
:class="[sizeClass, copied ? 'copy-btn-copied' : '']"
|
||||
:aria-label="copied ? 'Copied' : label"
|
||||
@click.stop="copy"
|
||||
>
|
||||
<!-- Icon swaps to a tick on success; both are the same box so the button
|
||||
never changes width mid-feedback (the jump was half the reason the
|
||||
old ad-hoc buttons felt broken). -->
|
||||
<svg v-if="!copied" class="copy-btn-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-2M8 5a2 2 0 002 2h4a2 2 0 002-2M8 5a2 2 0 012-2h4a2 2 0 012 2m0 0h2a2 2 0 012 2v3" />
|
||||
</svg>
|
||||
<svg v-else class="copy-btn-icon" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span v-if="!iconOnly" class="copy-btn-text">{{ copied ? copiedLabel : label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
// One copy button for the whole app. Before this, 25 call sites each rolled
|
||||
// their own markup and feedback — some flipped a label, some did nothing at
|
||||
// all, and the widths jumped. Every copy affordance should look and behave
|
||||
// identically, so this owns both the visual and the timing.
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Text to place on the clipboard. */
|
||||
value: string
|
||||
/** Button label in the idle state. */
|
||||
label?: string
|
||||
/** Label shown while the success state is held. */
|
||||
copiedLabel?: string
|
||||
/** Icon with no text — for tight rows next to a truncated value. */
|
||||
iconOnly?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}>(), {
|
||||
label: 'Copy',
|
||||
copiedLabel: 'Copied',
|
||||
iconOnly: false,
|
||||
size: 'sm',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ copied: [] }>()
|
||||
|
||||
/** How long the success state is held. Long enough to register, short enough
|
||||
* that a second copy doesn't feel blocked. */
|
||||
const FEEDBACK_MS = 1600
|
||||
|
||||
const copied = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const sizeClass = computed(() => (props.size === 'md' ? 'copy-btn-md' : 'copy-btn-sm'))
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value)
|
||||
} catch {
|
||||
// Clipboard can reject (insecure origin, denied permission). Fall back to
|
||||
// the legacy path so the button still works over plain http on a LAN IP,
|
||||
// which is how plenty of nodes are reached.
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = props.value
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
} catch {
|
||||
return // genuinely could not copy — don't claim success
|
||||
}
|
||||
}
|
||||
copied.value = true
|
||||
emit('copied')
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
copied.value = false
|
||||
timer = null
|
||||
}, FEEDBACK_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.copy-btn-sm {
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.copy-btn-md {
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.copy-btn-icon {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.copy-btn-md .copy-btn-icon {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
/* Success: emerald, matching the paid/settled language used elsewhere in the wallet. */
|
||||
.copy-btn-copied {
|
||||
background: rgba(16, 185, 129, 0.16);
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
color: rgb(110, 231, 183);
|
||||
}
|
||||
.copy-btn-copied:hover {
|
||||
background: rgba(16, 185, 129, 0.22);
|
||||
color: rgb(110, 231, 183);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.copy-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">★</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,124 @@
|
||||
<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">
|
||||
<!-- object-contain, not the default fill: app icons are not all square
|
||||
(bitcoin-knots is not), so a fixed 56x56 box distorted or cropped the
|
||||
mark against the rounded corners. Contain plus a dark plate shows the
|
||||
whole icon whatever its aspect ratio. -->
|
||||
<img
|
||||
v-if="app?.icon"
|
||||
:src="app.icon"
|
||||
:alt="app?.title || ''"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0 object-contain bg-black/40 p-1"
|
||||
/>
|
||||
<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,657 @@
|
||||
<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 && closedChannels.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">
|
||||
<!-- Status tabs -->
|
||||
<div class="flex gap-1 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 flex items-center justify-center gap-1.5"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
|
||||
>{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 filteredChannels"
|
||||
: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',
|
||||
'bg-gray-400': channelStatus(ch) === 'closing',
|
||||
'bg-gray-500': channelStatus(ch) === 'force_closing',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
|
||||
@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 / closing tx -->
|
||||
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
|
||||
<button
|
||||
v-if="fundingTxid(ch)"
|
||||
@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>
|
||||
<button
|
||||
v-if="ch.closing_txid"
|
||||
@click="openInMempool(ch.closing_txid!)"
|
||||
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
|
||||
:title="ch.closing_txid"
|
||||
>
|
||||
Closing 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>
|
||||
|
||||
<!-- Closed channel history (All + Closed tabs) -->
|
||||
<div
|
||||
v-for="ch in filteredClosed"
|
||||
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
|
||||
class="glass-card p-4 opacity-75"
|
||||
: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 bg-white/30"></span>
|
||||
<span class="text-white/60 text-sm font-medium">Closed</span>
|
||||
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
|
||||
</div>
|
||||
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
|
||||
{{ ch.remote_pubkey }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-between text-xs text-white/50 mb-2">
|
||||
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
|
||||
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ch.closing_tx_hash" class="flex justify-end">
|
||||
<button
|
||||
@click="openInMempool(ch.closing_tx_hash)"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="ch.closing_tx_hash"
|
||||
>
|
||||
Closing 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>
|
||||
|
||||
<!-- Per-tab empty state -->
|
||||
<div
|
||||
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
|
||||
class="glass-card p-6 text-center"
|
||||
>
|
||||
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
|
||||
</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, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
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
|
||||
closing_txid?: string
|
||||
}
|
||||
|
||||
interface ClosedChannel {
|
||||
chan_id?: string
|
||||
remote_pubkey: string
|
||||
capacity: number
|
||||
settled_balance: number
|
||||
close_type?: string
|
||||
closing_tx_hash?: string
|
||||
channel_point?: string
|
||||
close_height?: number
|
||||
}
|
||||
|
||||
/** 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 ChannelTab = 'all' | 'active' | 'pending' | 'closed'
|
||||
const activeTab = ref<ChannelTab>('all')
|
||||
|
||||
/** pending_open, closing and force_closing all live on the Pending tab */
|
||||
function isPendingState(ch: Channel): boolean {
|
||||
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
|
||||
}
|
||||
|
||||
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
|
||||
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
|
||||
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
|
||||
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
|
||||
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
|
||||
])
|
||||
|
||||
const filteredChannels = computed((): Channel[] => {
|
||||
switch (activeTab.value) {
|
||||
case 'closed': return []
|
||||
case 'active': return channels.value.filter(ch => !isPendingState(ch))
|
||||
case 'pending': return channels.value.filter(isPendingState)
|
||||
default: return channels.value
|
||||
}
|
||||
})
|
||||
|
||||
const filteredClosed = computed((): ClosedChannel[] =>
|
||||
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
|
||||
)
|
||||
|
||||
const emptyTabMessage = computed((): string => {
|
||||
switch (activeTab.value) {
|
||||
case 'active': return 'No open channels.'
|
||||
case 'pending': return 'No pending or closing channels.'
|
||||
case 'closed': return 'No closed channels yet.'
|
||||
default: return 'No channels yet.'
|
||||
}
|
||||
})
|
||||
|
||||
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
|
||||
function closeTypeLabel(ch: ClosedChannel): string {
|
||||
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
// Cached: revisits paint the channel lists instantly and revalidate behind
|
||||
// them. Open and closed history are separate entries so a closed-history
|
||||
// failure keeps its last list without touching the main channel view.
|
||||
interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
|
||||
const channelsRes = useCachedResource<ChannelsData>({
|
||||
key: 'lnd.channels',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.call<ChannelsData>({
|
||||
method: 'lnd.listchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return {
|
||||
channels: result?.channels || [],
|
||||
total_inbound: result?.total_inbound || 0,
|
||||
total_outbound: result?.total_outbound || 0,
|
||||
}
|
||||
},
|
||||
persist: false, // open channel balances/capacity — wallet data (T-02-01)
|
||||
})
|
||||
const closedRes = useCachedResource<ClosedChannel[]>({
|
||||
key: 'lnd.closed-channels',
|
||||
fetcher: async (signal) => {
|
||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||
method: 'lnd.closedchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return closed?.channels || []
|
||||
},
|
||||
persist: false, // closed channel settlement records — wallet data (T-02-01)
|
||||
})
|
||||
const loading = computed(() =>
|
||||
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
|
||||
const error = computed(() => channelsRes.error.value)
|
||||
const channels = computed(() => channelsRes.data.value?.channels ?? [])
|
||||
const closedChannels = computed(() => closedRes.data.value ?? [])
|
||||
const summary = computed(() => ({
|
||||
total_inbound: channelsRes.data.value?.total_inbound ?? 0,
|
||||
total_outbound: channelsRes.data.value?.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)
|
||||
}
|
||||
|
||||
function loadChannels(): Promise<void> {
|
||||
const main = channelsRes.refresh()
|
||||
void closedRes.refresh()
|
||||
return main
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ channels, loadChannels })
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<!-- z-3600: this is raised from INSIDE another modal (Receive, the Web5
|
||||
send/receive sheet, the app launcher's paywall), so it must sit above
|
||||
the standard modal layer (3000) but below the app overlay (4000) —
|
||||
same reasoning as ExternalExplorerModal. -->
|
||||
<BaseModal
|
||||
:show="lightning.show.value"
|
||||
:title="modalTitle"
|
||||
max-width="max-w-md"
|
||||
z-index="z-[3600]"
|
||||
@close="onClose"
|
||||
>
|
||||
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
|
||||
Your Lightning node is running, but it has no payment channel yet.
|
||||
<template v-if="lightning.fundingDirection.value === 'receive'">
|
||||
Receiving needs <span class="text-white/90">inbound liquidity</span> — a
|
||||
channel with funds on the far side — otherwise any invoice you create
|
||||
is unpayable.
|
||||
</template>
|
||||
<template v-else>
|
||||
Sending needs <span class="text-white/90">outbound liquidity</span> — a
|
||||
funded channel to route through.
|
||||
</template>
|
||||
Open one with <span class="text-white/90">Zeus Olympus</span> from the
|
||||
channels screen — it's prefilled there, and needs 150,000–1,500,000
|
||||
on-chain sats.
|
||||
</p>
|
||||
<p v-else-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
|
||||
Lightning payments need a Lightning node that's actually running. Yours is
|
||||
installed but isn't running right now — start it from My Apps and try
|
||||
again.
|
||||
</p>
|
||||
<p v-else class="text-sm text-white/70 leading-relaxed">
|
||||
Lightning payments need a Lightning node running on this Archipelago
|
||||
node. You don't have one installed yet — pick an implementation below and
|
||||
it'll be installed for you.
|
||||
</p>
|
||||
|
||||
<div v-if="lightning.status.value === 'absent'" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
|
||||
>
|
||||
<div class="ln-node-row flex items-start gap-3">
|
||||
<div class="ln-node-icon shrink-0">
|
||||
<img
|
||||
v-if="!failedIcons.has(node.id)"
|
||||
:src="node.icon"
|
||||
:alt="node.name"
|
||||
class="w-full h-full object-contain"
|
||||
@error="failedIcons.add(node.id)"
|
||||
/>
|
||||
<!-- No packaged icon yet (Core Lightning): a neutral bolt keeps the
|
||||
row aligned instead of showing a broken-image box. -->
|
||||
<svg v-else class="w-6 h-6 text-white/40" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">{{ node.name }}</span>
|
||||
<span
|
||||
v-if="!node.available"
|
||||
class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-white/10 text-white/50"
|
||||
>Coming soon</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-0.5 leading-relaxed">{{ node.blurb }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="node.available"
|
||||
:disabled="installing !== null"
|
||||
class="ln-node-action glass-button glass-button-warning rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
@click="install(node.id)"
|
||||
>
|
||||
{{ installing === node.id ? 'Installing…' : 'Install' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
disabled
|
||||
class="ln-node-action glass-button rounded-lg text-xs opacity-40 cursor-not-allowed"
|
||||
>Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-3 alert-error text-sm">{{ error }}</p>
|
||||
|
||||
<p v-if="installing" class="mt-3 text-xs text-white/50 leading-relaxed">
|
||||
This takes a few minutes — the image has to be pulled and the node
|
||||
started. You can close this and carry on; the install keeps running.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-6">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
|
||||
{{ installing ? 'Close' : 'Not now' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lightning.status.value === 'stopped'"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="openApps"
|
||||
>Open My Apps</button>
|
||||
<template v-else-if="lightning.status.value === 'no-funds'">
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm"
|
||||
@click="openSetupGuide"
|
||||
>Setup Guide</button>
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="openLightningSetup"
|
||||
>Open a channel</button>
|
||||
</template>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
interface NodeChoice {
|
||||
id: string
|
||||
name: string
|
||||
blurb: string
|
||||
/** Falls back to a neutral bolt when the asset is missing. */
|
||||
icon: string
|
||||
/** False renders the row as "Coming soon" with a dead Install button. */
|
||||
available: boolean
|
||||
}
|
||||
|
||||
// Core Lightning is listed deliberately while unavailable: the choice is the
|
||||
// point of this modal, and showing it greyed tells the user the platform is
|
||||
// not LND-only. When its app id lands in the catalog, flip `available` here
|
||||
// and add the id to LIGHTNING_NODE_APP_IDS — nothing else changes.
|
||||
const nodes: NodeChoice[] = [
|
||||
{
|
||||
id: 'lnd',
|
||||
name: 'LND',
|
||||
blurb: 'Lightning Network Daemon. The implementation Archipelago ships today — wallet, channels and payments are wired to it.',
|
||||
icon: '/assets/img/app-icons/lnd.png',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'core-lightning',
|
||||
name: 'Core Lightning',
|
||||
blurb: 'Blockstream\'s implementation. Not packaged yet — it will appear here as a choice once it ships.',
|
||||
icon: '/assets/img/app-icons/core-lightning.svg',
|
||||
available: false,
|
||||
},
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (lightningStatusIs('no-funds')) return 'You need a Lightning channel'
|
||||
if (lightningStatusIs('stopped')) return 'Lightning node not running'
|
||||
return 'Lightning node required'
|
||||
})
|
||||
const appStore = useAppStore()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
/** App id currently installing, or null. */
|
||||
/** Icons that 404'd — swapped for the inline bolt. */
|
||||
const failedIcons = ref(new Set<string>())
|
||||
|
||||
const installing = ref<string | null>(null)
|
||||
const error = ref('')
|
||||
|
||||
async function install(id: string) {
|
||||
installing.value = id
|
||||
error.value = ''
|
||||
try {
|
||||
await appStore.installPackage(id, '', 'latest')
|
||||
// The gate reads install state from the package list, so once the install
|
||||
// lands the modal simply stops being raised. Close on success rather than
|
||||
// holding the user here watching a spinner.
|
||||
lightning.close()
|
||||
} catch (err) {
|
||||
error.value = `Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
} finally {
|
||||
installing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function lightningStatusIs(s: string) {
|
||||
return lightning.status.value === s
|
||||
}
|
||||
|
||||
/** The Lightning goal already owns funding + channel-opening, so reuse it
|
||||
* rather than duplicating that flow inside this modal. */
|
||||
function openLightningSetup() {
|
||||
lightning.close()
|
||||
// The channels screen already has the prefilled Zeus/Olympus open-channel
|
||||
// flow, so send the user straight to the thing that solves it rather than
|
||||
// to the wizard that would only point here anyway.
|
||||
router.push('/dashboard/apps/lnd/channels')
|
||||
}
|
||||
|
||||
/** The guided walkthrough, for someone who wants the whole path explained
|
||||
* rather than to be dropped straight into the open-channel form. */
|
||||
function openSetupGuide() {
|
||||
lightning.close()
|
||||
router.push('/dashboard/goals/run-lightning-node')
|
||||
}
|
||||
|
||||
function openApps() {
|
||||
lightning.close()
|
||||
router.push('/dashboard/apps')
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
error.value = ''
|
||||
lightning.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ln-node-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
.ln-node-action {
|
||||
flex-shrink: 0;
|
||||
padding: 0.375rem 0.875rem;
|
||||
}
|
||||
/* Narrow phones: the icon + name + blurb keep the top row, and the action
|
||||
drops to its own full-width line rather than squeezing the blurb into a
|
||||
two-word column. */
|
||||
@media (max-width: 26rem) {
|
||||
.ln-node-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ln-node-action {
|
||||
width: 100%;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,726 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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
|
||||
|
||||
// Only-while-visible: the window resize listener and ResizeObserver follow
|
||||
// the Mesh tab's activate/deactivate lifecycle now that this component
|
||||
// survives a tab switch under KeepAlive (T-02-03/D-03 — Mesh.vue joined
|
||||
// KEEP_ALIVE_PATHS in 02-04). The Leaflet instance itself is never destroyed
|
||||
// or recreated by any of this: initMap()'s own `if (!mapContainer.value ||
|
||||
// map) return` guard already makes construction idempotent, so exactly one
|
||||
// map is built per session no matter how many times armMapVisibility() runs.
|
||||
// This only quiesces/repairs the listener + observer and re-tiles the map
|
||||
// around it, so a map laid out while off screen doesn't paint at a stale or
|
||||
// partial size on return.
|
||||
//
|
||||
// Called from BOTH onMounted and onActivated: onActivated is a documented
|
||||
// no-op outside a <KeepAlive> boundary (see Mesh.vue's own armMeshLive for
|
||||
// the same idiom), so a bare mount (a unit test, or any future non-KeepAlive
|
||||
// usage) must not silently skip this setup.
|
||||
function armMapVisibility() {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
if (mapContainer.value) {
|
||||
if (!resizeObserver) {
|
||||
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 for the very first mount (idempotent no-op once `map` is
|
||||
// already set — mirrors initMap's own not-yet-laid-out retry loop). Guard
|
||||
// against `map` already existing so later reactivations don't schedule a
|
||||
// throwaway no-op timer (WR-06) — this is genuinely first-mount-only.
|
||||
if (!map) setTimeout(initMap, 300)
|
||||
|
||||
// A map laid out while the tab was off screen may have settled at a
|
||||
// zero/partial size; re-tile it at its real size now that it's back on
|
||||
// screen instead of showing an unsized or partially tiled canvas.
|
||||
if (map) {
|
||||
void nextTick(() => {
|
||||
try { map?.invalidateSize() } catch { /* destroyed */ }
|
||||
})
|
||||
}
|
||||
}
|
||||
function disarmMapVisibility() {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (resizeObserver) resizeObserver.disconnect()
|
||||
}
|
||||
|
||||
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
|
||||
// component's first mount, which would otherwise double the fallback
|
||||
// setTimeout(initMap, 300) call on every fresh page load (harmless since
|
||||
// initMap is idempotent, but redundant). This flag lets onMounted's call
|
||||
// count as the first activation's arm, so onActivated only re-arms on a
|
||||
// genuine later reactivation, matching Mesh.vue's own meshFreshMount idiom.
|
||||
let mapMountFresh = true
|
||||
// Set when a live geolocation watch is torn down by onDeactivated so
|
||||
// onActivated can transparently resume it (WR-02) — the browser location
|
||||
// watch must not keep firing (battery drain, background location indicator)
|
||||
// while the Mesh tab is off screen, matching the only-while-visible pattern
|
||||
// already applied to the resize listener/ResizeObserver in this file.
|
||||
let wasSharingBeforeDeactivate = false
|
||||
onActivated(() => {
|
||||
if (mapMountFresh) { mapMountFresh = false; return }
|
||||
armMapVisibility()
|
||||
if (wasSharingBeforeDeactivate) {
|
||||
wasSharingBeforeDeactivate = false
|
||||
startSharing()
|
||||
}
|
||||
})
|
||||
onMounted(() => armMapVisibility())
|
||||
onDeactivated(() => {
|
||||
disarmMapVisibility()
|
||||
if (sharingLocation.value) {
|
||||
wasSharingBeforeDeactivate = true
|
||||
stopSharing()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disarmMapVisibility()
|
||||
stopSharing()
|
||||
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>
|
||||
@@ -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 → 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 · SECP256K1 · 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>
|
||||
@@ -0,0 +1,148 @@
|
||||
<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' })
|
||||
}
|
||||
}
|
||||
// Auto-apply everywhere (alpha, user-approved 2026-07-31):
|
||||
// a prompt only reaches users who happen to click it, so
|
||||
// security fixes sat unapplied in long-lived sessions (the
|
||||
// installed PWA and kiosk displays especially). The kiosk
|
||||
// already auto-applied for exactly that reason; the rest of
|
||||
// the fleet now does too.
|
||||
//
|
||||
// Deliberately still routed through SKIP_WAITING rather than
|
||||
// build-time `skipWaiting: true`, so activation keeps the two
|
||||
// guards below: reloadAfterCinematic() holds the reload until
|
||||
// the splash/dashboard cinematic is over, and the
|
||||
// hadController check ignores the first-install claim. A
|
||||
// build-time skipWaiting would bypass both and could reload
|
||||
// the app out from under someone.
|
||||
//
|
||||
// Revisit at beta: restore the prompt (showUpdatePrompt.value
|
||||
// = true) if users should choose their own update moment.
|
||||
updateCallback()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function dismissUpdate() {
|
||||
showUpdatePrompt.value = false
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (updateCallback) {
|
||||
await updateCallback()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,320 @@
|
||||
<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>
|
||||
<CopyButton :value="invoiceResult" :label="t('common.copy')" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-chain -->
|
||||
<div v-if="receiveMethod === 'onchain'">
|
||||
<!-- Payment detected: the QR did its job — show the outcome -->
|
||||
<div v-if="paymentSeen" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div
|
||||
class="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
:class="paymentSeen.confirmations > 0 ? 'bg-green-500/15' : 'bg-orange-500/15 animate-pulse'"
|
||||
>
|
||||
<!-- Check once confirmed, clock while in the mempool -->
|
||||
<svg v-if="paymentSeen.confirmations > 0" class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white mb-1">
|
||||
{{ paymentSeen.confirmations > 0 ? t('receiveBitcoin.paymentConfirmed') : t('receiveBitcoin.paymentBroadcast') }}
|
||||
</p>
|
||||
<p v-if="paymentSeen.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
|
||||
{{ paymentSeen.amountSats.toLocaleString() }} sats
|
||||
</p>
|
||||
<p v-if="paymentSeen.confirmations === 0" class="text-sm text-white/50 mb-3 max-w-md mx-auto">
|
||||
{{ t('receiveBitcoin.paymentBroadcastHint') }}
|
||||
</p>
|
||||
<p class="text-xs text-white/50 mb-1">{{ t('receiveBitcoin.transactionId') }}</p>
|
||||
<p class="text-xs font-mono text-white/80" :title="paymentSeen.txid">{{ midTxid(paymentSeen.txid) }}</p>
|
||||
<CopyButton :value="paymentSeen.txid" :label="t('common.copy')" class="mt-2" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<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>
|
||||
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
|
||||
</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>
|
||||
</template>
|
||||
</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>
|
||||
<CopyButton :value="arkAddress" :label="t('common.copy')" class="mt-2" />
|
||||
</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>
|
||||
|
||||
<!-- Once the payment is seen there is nothing left to do here -->
|
||||
<div v-if="paymentSeen" class="flex">
|
||||
<button @click="close" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium">{{ t('common.done') }}</button>
|
||||
</div>
|
||||
<div v-else 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, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import CopyButton from '@/components/CopyButton.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
const { t } = useI18n()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
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) {
|
||||
stopWatchingPayment()
|
||||
return
|
||||
}
|
||||
paymentSeen.value = null
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
receiveMethod.value = 'onchain'
|
||||
invoiceAmount.value = 0
|
||||
invoiceMemo.value = ''
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
error.value = ''
|
||||
processing.value = false
|
||||
if (props.autoGenerate && receiveMethod.value === 'onchain') {
|
||||
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('')
|
||||
|
||||
// ── On-chain payment detection ────────────────────────────────────────────
|
||||
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
|
||||
// transaction paying it is this receive — no baseline bookkeeping needed.
|
||||
// Poll while the QR is showing; flip to the success view on first sight
|
||||
// (0-conf, clock), keep polling gently until the first confirmation
|
||||
// upgrades it to a check, then stop.
|
||||
const paymentSeen = ref<null | { txid: string; amountSats: number; confirmations: number }>(null)
|
||||
let watchTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function midTxid(txid: string): string {
|
||||
return txid.length > 24 ? `${txid.slice(0, 10)}…${txid.slice(-10)}` : txid
|
||||
}
|
||||
|
||||
function stopWatchingPayment() {
|
||||
if (watchTimer) {
|
||||
clearInterval(watchTimer)
|
||||
watchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startWatchingPayment() {
|
||||
stopWatchingPayment()
|
||||
watchTimer = setInterval(() => void checkForPayment(), 5000)
|
||||
}
|
||||
|
||||
async function checkForPayment() {
|
||||
if (!props.show || !onchainAddress.value) {
|
||||
stopWatchingPayment()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
transactions: Array<{
|
||||
tx_hash: string
|
||||
amount: number
|
||||
num_confirmations: number
|
||||
dest_addresses: string[]
|
||||
direction: string
|
||||
}>
|
||||
}>({ method: 'lnd.gettransactions' })
|
||||
const hit = (res.transactions || []).find(
|
||||
(tx) => tx.direction === 'incoming' && (tx.dest_addresses || []).includes(onchainAddress.value),
|
||||
)
|
||||
if (!hit) return
|
||||
const firstSighting = !paymentSeen.value
|
||||
paymentSeen.value = {
|
||||
txid: hit.tx_hash,
|
||||
amountSats: hit.amount,
|
||||
confirmations: hit.num_confirmations,
|
||||
}
|
||||
if (firstSighting) emit('received')
|
||||
if (hit.num_confirmations > 0) stopWatchingPayment()
|
||||
} catch {
|
||||
// Transient poll failure (daemon busy, LND mid-restart) — keep watching.
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopWatchingPayment)
|
||||
|
||||
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() {
|
||||
stopWatchingPayment()
|
||||
paymentSeen.value = null
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
error.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function receive() {
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (receiveMethod.value === 'lightning') {
|
||||
// No Lightning implementation installed is not an error — it is a
|
||||
// missing prerequisite. Raise the install modal instead of letting
|
||||
// lnd.createinvoice fail with connection-refused (FED-08 follow-up).
|
||||
if (!(await lightning.requireLightningReady('receive'))) return
|
||||
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
|
||||
paymentSeen.value = null
|
||||
startWatchingPayment()
|
||||
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) {
|
||||
// A running node with no inbound liquidity is a funding problem, not a
|
||||
// failure — reuse the Lightning modal in its funding mode instead.
|
||||
if (receiveMethod.value === 'lightning' && lightning.handleLightningFailure(err)) return
|
||||
error.value = receiveMethod.value === 'onchain'
|
||||
? explainReceiveAddressFailure(err)
|
||||
: err instanceof Error ? err.message : 'Failed'
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="refresh-indicator-slot" aria-hidden="false">
|
||||
<div
|
||||
v-if="state === 'refreshing'"
|
||||
class="refresh-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="refresh-indicator-spinner" aria-hidden="true" />
|
||||
<span class="sr-only">{{ label ?? 'Refreshing…' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ResourceLoadState } from '@/stores/resources'
|
||||
|
||||
// Subtle background-refresh indicator (D-05): visible only while a cached
|
||||
// resource is revalidating behind already-rendered content. A first load
|
||||
// is the view's own skeleton's job, not this component's — 'loading'
|
||||
// intentionally renders nothing here, same as 'ready'/'idle'. No stale-age
|
||||
// text or "last updated" badge (D-05 rules those out).
|
||||
defineProps<{
|
||||
state: ResourceLoadState
|
||||
label?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fixed-size outer slot so the indicator appearing/disappearing never
|
||||
shifts surrounding layout — callers can drop this inline without also
|
||||
reserving space themselves. */
|
||||
.refresh-indicator-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.refresh-indicator-spinner {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.55);
|
||||
border-radius: 50%;
|
||||
animation: refresh-indicator-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes refresh-indicator-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,143 @@
|
||||
<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);
|
||||
* 'badge' is the paid-tick size that fits inside a modal card (FED-06). */
|
||||
size?: 'default' | 'compact' | 'badge'
|
||||
/** Override segment count. Defaults to 48 (screensaver standard). */
|
||||
segmentCount?: number
|
||||
}>(), { size: 'default', segmentCount: 48 })
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
if (props.size === 'compact') return 'viz-ring-compact'
|
||||
if (props.size === 'badge') return 'viz-ring-badge'
|
||||
return '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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Paid-tick badge (FED-06) — sized to sit inside a modal card without
|
||||
clipping, unlike the compact variant (240-320px against a ~112px core). */
|
||||
.viz-ring-badge {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
--viz-radius: 80px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-ring-badge {
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
--viz-radius: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
/* Site-wide reduced-motion convention — applies to every size variant, so
|
||||
the ring holds a static, legible pose instead of pulsing. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.viz-segment {
|
||||
animation: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
@click="seedTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<template v-if="seedTab === 'words'">
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="hidden ? 'blur-md' : ''"
|
||||
@click="hidden = !hidden"
|
||||
>
|
||||
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">
|
||||
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
|
||||
Tap to {{ hidden ? 'reveal' : 'hide' }}.
|
||||
</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
|
||||
:class="hidden ? 'blur-md' : ''"
|
||||
@click="hidden = !hidden"
|
||||
>
|
||||
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
</div>
|
||||
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
|
||||
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
@click="qrFormat = f.key"
|
||||
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
<template v-if="aezeed">
|
||||
The code contains your seed words as plain text — treat it exactly like the words
|
||||
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
|
||||
phrase — it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
|
||||
not into hardware wallets like Passport.
|
||||
</template>
|
||||
<template v-else-if="qrFormat === 'seedqr'">
|
||||
SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import
|
||||
seeds by QR. Treat this code exactly like the words themselves.
|
||||
</template>
|
||||
<template v-else>
|
||||
Plain text words — for wallets that read the phrase as text. Treat this code exactly
|
||||
like the words themselves.
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
|
||||
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
|
||||
// Words are always the first view. For BIP39 seeds the QR defaults to the
|
||||
// SeedQR standard (4-digit wordlist indices — what Passport Prime, SeedSigner,
|
||||
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
|
||||
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
|
||||
// plain-text QR plus an explanation — SeedQR-encoding one would be dishonest.
|
||||
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
|
||||
|
||||
const seedTab = ref<'words' | 'qr'>('words')
|
||||
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
|
||||
const seedQrAvailable = ref(!props.aezeed)
|
||||
const hidden = ref(true)
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
async function renderQr() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || props.words.length === 0) return
|
||||
try {
|
||||
let payload = props.words.join(' ')
|
||||
if (!props.aezeed && qrFormat.value === 'seedqr') {
|
||||
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||
const digits = await toSeedQrDigits(props.words)
|
||||
if (digits) {
|
||||
payload = digits
|
||||
} else {
|
||||
// Not a BIP39 phrase after all — only plain text is honest.
|
||||
seedQrAvailable.value = false
|
||||
qrFormat.value = 'text'
|
||||
return // the qrFormat watcher re-renders as text
|
||||
}
|
||||
}
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
|
||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||
}
|
||||
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
|
||||
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
|
||||
watch(() => props.words, () => {
|
||||
seedTab.value = 'words' // fresh reveal always shows words first
|
||||
hidden.value = true
|
||||
seedQrAvailable.value = !props.aezeed
|
||||
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,771 @@
|
||||
<template>
|
||||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
|
||||
<template v-if="successInfo">
|
||||
<div class="text-center py-4">
|
||||
<div class="send-success-badge mx-auto mb-6">
|
||||
<ScreensaverRing size="badge" />
|
||||
<div class="send-success-burst">
|
||||
<div class="burst-core">
|
||||
<svg class="w-14 h-14 text-green-400 burst-check" 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>
|
||||
</div>
|
||||
|
||||
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
|
||||
{{ successInfo.amount.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-1">SENT</div>
|
||||
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
|
||||
|
||||
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
|
||||
<div v-if="successInfo.hash">
|
||||
<p class="text-xs text-white/50 mb-1">Payment hash</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
|
||||
<CopyButton class="shrink-0" :value="successInfo.hash" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="successInfo.txid">
|
||||
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
|
||||
<CopyButton class="shrink-0" :value="successInfo.txid" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
|
||||
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||
<template v-else-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 v-if="effectiveMethod === 'onchain'" class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Network fee</span>
|
||||
<span class="text-sm font-medium text-white/80">{{ feeEstimateLabel }}</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">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-white/60 text-sm">{{ amountLabel }}</label>
|
||||
<!-- sats/BTC entry toggle (on-chain only) -->
|
||||
<div v-if="sendMethod === 'onchain'" class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="u in (['sats', 'btc'] as const)"
|
||||
:key="u"
|
||||
@click="setAmountUnit(u)"
|
||||
class="px-2 py-0.5 rounded text-[11px] font-medium transition-colors"
|
||||
:class="amountUnit === u ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ u === 'btc' ? 'BTC' : 'sats' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<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="amountEntry"
|
||||
type="number"
|
||||
:min="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
|
||||
:step="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
|
||||
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : amountUnit === 'btc' && sendMethod === 'onchain' ? '0.001' : '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>
|
||||
<p v-else-if="unitConversionHint" class="text-xs text-white/40 mt-1">{{ unitConversionHint }}</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>
|
||||
|
||||
<!-- Network fee (on-chain only) -->
|
||||
<div v-if="sendMethod === 'onchain'" class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">Network fee</label>
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="preset in onchainFeePresets"
|
||||
:key="preset.key"
|
||||
@click="feePreset = preset.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
<p v-if="feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
|
||||
{{ onchainFeePresets.find(p => p.key === 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 blocks</label>
|
||||
<input
|
||||
v-model.number="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="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 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="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 { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import CopyButton from '@/components/CopyButton.vue'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
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')
|
||||
|
||||
// --- Amount entry with a sats/BTC unit toggle (on-chain). `amountEntry` is
|
||||
// --- what the user types in the chosen unit; `amount` stays the canonical
|
||||
// --- sats value the rest of the flow reads and writes.
|
||||
const amountUnit = ref<'sats' | 'btc'>('sats')
|
||||
const amountEntry = ref<number>(0)
|
||||
const amount = computed<number>({
|
||||
get: () =>
|
||||
amountUnit.value === 'btc'
|
||||
? Math.round((amountEntry.value || 0) * 100_000_000)
|
||||
: Math.floor(amountEntry.value || 0),
|
||||
set: (sats: number) => {
|
||||
amountEntry.value = amountUnit.value === 'btc' ? (sats || 0) / 100_000_000 : sats || 0
|
||||
},
|
||||
})
|
||||
|
||||
/** Switch entry unit, converting whatever is already typed. */
|
||||
function setAmountUnit(unit: 'sats' | 'btc') {
|
||||
if (unit === amountUnit.value) return
|
||||
const sats = amount.value
|
||||
amountUnit.value = unit
|
||||
amount.value = sats
|
||||
}
|
||||
|
||||
// Only the on-chain tab offers BTC entry — leaving it snaps back to sats so
|
||||
// the lightning/ecash flows (and their sats-only hints) stay consistent.
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && amountUnit.value !== 'sats') setAmountUnit('sats')
|
||||
})
|
||||
|
||||
const amountLabel = computed(() =>
|
||||
sendMethod.value === 'onchain' && amountUnit.value === 'btc' ? 'Amount (BTC)' : t('sendBitcoin.amountSats')
|
||||
)
|
||||
|
||||
const unitConversionHint = computed(() => {
|
||||
if (sendMethod.value !== 'onchain' || !amountEntry.value) return ''
|
||||
return amountUnit.value === 'btc'
|
||||
? `= ${amount.value.toLocaleString()} sats`
|
||||
: `= ${(amount.value / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')} BTC`
|
||||
})
|
||||
|
||||
const dest = ref('')
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
// Set on a completed send — flips the modal to the success pane.
|
||||
const successInfo = ref<{
|
||||
amount: number
|
||||
methodLabel: string
|
||||
hash?: string
|
||||
txid?: string
|
||||
note?: string
|
||||
} | null>(null)
|
||||
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) {
|
||||
// Disarming clears the field — a swept-balance figure left behind reads
|
||||
// as a typed amount.
|
||||
amount.value = 0
|
||||
return
|
||||
}
|
||||
// Arming shows the swept balance IN the (disabled) amount field — a field
|
||||
// stuck at 0 while "send all" is lit read as "sending nothing" (operator
|
||||
// feedback 2026-08-05). Refresh the figure on every arm.
|
||||
const applyBalance = () => {
|
||||
if (sendAll.value && onchainBalance.value !== null) amount.value = onchainBalance.value
|
||||
}
|
||||
applyBalance()
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0; applyBalance() })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
}
|
||||
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
// (and drops the swept-balance figure it wrote into the amount field).
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && sendAll.value) {
|
||||
sendAll.value = false
|
||||
amount.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Every open starts from a blank slate. Stale state from the previous send —
|
||||
// destination, amount, and above all an armed "send all funds" toggle — is
|
||||
// dangerous to inherit invisibly (operator feedback 2026-08-05).
|
||||
watch(() => props.show, (shown) => {
|
||||
if (!shown) return
|
||||
sendMethod.value = 'lightning'
|
||||
amountUnit.value = 'sats'
|
||||
amountEntry.value = 0
|
||||
dest.value = ''
|
||||
error.value = ''
|
||||
successInfo.value = null
|
||||
ecashToken.value = ''
|
||||
sendAll.value = false
|
||||
onchainBalance.value = null
|
||||
feePreset.value = 'standard'
|
||||
customConfTarget.value = null
|
||||
customSatPerVbyte.value = null
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
confirming.value = false
|
||||
confirmBalance.value = null
|
||||
invoiceAmountSats.value = null
|
||||
processing.value = false
|
||||
})
|
||||
|
||||
// --- On-chain network fee: presets map to LND confirmation targets; custom
|
||||
// --- takes a block target or an explicit sat/vB rate (rate wins).
|
||||
type OnchainFeePreset = 'fast' | 'standard' | 'slow' | 'custom'
|
||||
|
||||
const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
{ key: 'fast', label: 'Fast', hint: 'Targets the next block (~10 minutes)', confTarget: 1 },
|
||||
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'slow', label: 'Slow', hint: 'Confirms within ~144 blocks (about a day)', confTarget: 144 },
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
const feePreset = ref<OnchainFeePreset>('standard')
|
||||
const customConfTarget = ref<number | null>(null)
|
||||
const customSatPerVbyte = ref<number | null>(null)
|
||||
// Resolved at review time so confirm + send use the same params.
|
||||
const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>({})
|
||||
|
||||
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (feePreset.value !== 'custom') {
|
||||
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
|
||||
}
|
||||
const rate = customSatPerVbyte.value
|
||||
const conf = customConfTarget.value
|
||||
if (rate != null && rate !== 0) {
|
||||
if (rate < 1 || rate > 5000) { error.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) { error.value = 'Target blocks must be between 1 and 1008'; return null }
|
||||
return { target_conf: Math.floor(conf) }
|
||||
}
|
||||
error.value = 'Custom fee requires target blocks or sats per vByte'
|
||||
return null
|
||||
}
|
||||
|
||||
// Fee estimate for the confirm pane (best-effort — LND's own estimator).
|
||||
const feeEstimate = ref<{ fee_sat: number; sat_per_vbyte: number } | null>(null)
|
||||
const feeEstimateLoading = ref(false)
|
||||
|
||||
const feeEstimateLabel = computed(() => {
|
||||
if (feeEstimate.value) {
|
||||
return `~${feeEstimate.value.fee_sat.toLocaleString()} sats · ${feeEstimate.value.sat_per_vbyte} sat/vB`
|
||||
}
|
||||
if (resolvedFeeParams.value.sat_per_vbyte) {
|
||||
return `${resolvedFeeParams.value.sat_per_vbyte} sat/vB (custom)`
|
||||
}
|
||||
if (feeEstimateLoading.value) return '…'
|
||||
return isSweep.value ? 'deducted from swept amount' : 'estimated at broadcast'
|
||||
})
|
||||
|
||||
async function loadFeeEstimate() {
|
||||
feeEstimate.value = null
|
||||
// Explicit sat/vB shows as-is; sweeps have no fixed amount to estimate on.
|
||||
if (resolvedFeeParams.value.sat_per_vbyte || isSweep.value) return
|
||||
const addr = dest.value.trim()
|
||||
const amt = confirmAmount.value
|
||||
if (!addr || amt < 546) return
|
||||
feeEstimateLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
|
||||
method: 'lnd.estimatefee',
|
||||
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
|
||||
timeout: 10000,
|
||||
})
|
||||
if (res.fee_sat > 0) feeEstimate.value = res
|
||||
} catch {
|
||||
/* estimate is a preview — the label falls back to prose */
|
||||
} finally {
|
||||
feeEstimateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
})
|
||||
|
||||
// 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.
|
||||
// MUST come after effectiveMethod: watch() evaluates its source getter at
|
||||
// setup, and reading a const still in its temporal dead zone crashed the
|
||||
// whole modal at mount ("Cannot access 'R' before initialization").
|
||||
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
|
||||
})
|
||||
|
||||
// --- 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
|
||||
}
|
||||
}
|
||||
|
||||
async function review() {
|
||||
error.value = ''
|
||||
const method = effectiveMethod.value
|
||||
const d = dest.value.trim()
|
||||
if (method === 'lightning') {
|
||||
// Gate BEFORE the confirm step, not at submit: walking a user through
|
||||
// review-and-confirm only to fail on a missing node is the defect.
|
||||
if (!(await lightning.requireLightningReady('send'))) return
|
||||
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
|
||||
}
|
||||
if (method === 'onchain') {
|
||||
const fee = onchainFeeParams()
|
||||
if (!fee) return
|
||||
resolvedFeeParams.value = fee
|
||||
void loadFeeEstimate()
|
||||
} else {
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
}
|
||||
void loadConfirmBalance()
|
||||
confirming.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
error.value = ''
|
||||
ecashToken.value = ''
|
||||
confirming.value = false
|
||||
successInfo.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** Reset the form for a fresh payment straight from the success screen. */
|
||||
function sendAnother() {
|
||||
successInfo.value = null
|
||||
confirming.value = false
|
||||
dest.value = ''
|
||||
amount.value = 0
|
||||
sendAll.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
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 = ''
|
||||
|
||||
const method = effectiveMethod.value
|
||||
const paidAmount = confirmAmount.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,
|
||||
})
|
||||
successInfo.value = { amount: paidAmount, methodLabel: 'Sent via Ark', note: 'The transfer settles with the next Ark round.' }
|
||||
} 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 }
|
||||
// Waits out slow multi-hop routing and only reports failure when LND
|
||||
// itself declares the payment failed — never on a timeout. The moment
|
||||
// the payment goes pending (~8s), the success pane appears in
|
||||
// "settling…" mode instead of freezing the modal through the poll —
|
||||
// the poll keeps running and upgrades the pane when LND settles.
|
||||
const res = await rpcClient.payLightningInvoice(
|
||||
{ payment_request: dest.value.trim() },
|
||||
(hash) => {
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
methodLabel: 'Settling…',
|
||||
hash,
|
||||
note: 'Payment is on its way through the network. This pane updates the moment it settles — safe to close.',
|
||||
}
|
||||
confirming.value = false
|
||||
processing.value = false
|
||||
emit('sent')
|
||||
},
|
||||
)
|
||||
if (res.status === 'failed') {
|
||||
// If the settling pane is up, replace it with the failure — LND
|
||||
// declared this payment failed for real.
|
||||
successInfo.value = null
|
||||
error.value = res.failure_reason || t('web5.sendFailed')
|
||||
return
|
||||
}
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
|
||||
hash: res.payment_hash || undefined,
|
||||
...(res.status === 'pending'
|
||||
? { note: 'This payment is taking longer than usual to settle. It will appear in your transactions once it completes.' }
|
||||
: {}),
|
||||
}
|
||||
} 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, ...resolvedFeeParams.value }
|
||||
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
|
||||
})
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
methodLabel: isSweep.value ? 'Swept on-chain' : 'Sent on-chain',
|
||||
txid: res.txid,
|
||||
note: 'On-chain payments confirm over the next blocks.',
|
||||
}
|
||||
}
|
||||
emit('sent')
|
||||
// Success pane (or the token pane for ecash mints) takes over the modal.
|
||||
confirming.value = false
|
||||
} catch (err: unknown) {
|
||||
// Running node with nothing to pay with -> funding modal, not a raw string.
|
||||
if (effectiveMethod.value === 'lightning' && lightning.handleLightningFailure(err)) return
|
||||
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Success badge (FED-06) — the branded ScreensaverRing carries the motion,
|
||||
with the emerald pop-in check centred over it. */
|
||||
.send-success-badge {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.send-success-badge {
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
}
|
||||
}
|
||||
.send-success-burst {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
.burst-core {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
|
||||
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
|
||||
}
|
||||
.burst-check {
|
||||
stroke-dasharray: 32;
|
||||
stroke-dashoffset: 32;
|
||||
animation: burst-draw 0.45s ease-out 0.25s forwards;
|
||||
}
|
||||
@keyframes burst-pop {
|
||||
from { transform: scale(0.3); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes burst-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.burst-core, .burst-check { animation: none; }
|
||||
.burst-check { stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<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="px-8 pt-8 pb-2 text-center text-white/50">
|
||||
No results for "{{ query }}"
|
||||
</div>
|
||||
|
||||
<!-- Hand the typed text to AIUI. Always offered while there is a
|
||||
query — it is the whole point when nothing matched, and a
|
||||
useful escape hatch when something did. -->
|
||||
<div class="p-2" :class="filteredItems.length > 0 ? 'border-t border-white/10' : ''">
|
||||
<button
|
||||
type="button"
|
||||
class="group w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="getItemClass(askAiuiIndex)"
|
||||
@click="askAiui()"
|
||||
>
|
||||
<span
|
||||
class="relative shrink-0 flex items-center justify-center w-9 h-9 rounded-lg overflow-hidden
|
||||
bg-gradient-to-br from-blue-500/30 via-sky-400/15 to-transparent border border-blue-400/30"
|
||||
>
|
||||
<span class="absolute inset-0 transition-colors group-hover:bg-blue-400/10"></span>
|
||||
<svg
|
||||
class="relative w-[18px] h-[18px] text-blue-300"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.5 11.5a8 8 0 01-11.9 6.97L4 19.5l1.06-4.3A8 8 0 1120.5 11.5z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12.2 8.4l.78 2.02 2.02.78-2.02.78-.78 2.02-.78-2.02-2.02-.78 2.02-.78z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-white/90">Talk to AIUI about it</span>
|
||||
<span class="block text-xs text-white/40 truncate">“{{ query.trim() }}”</span>
|
||||
</span>
|
||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded shrink-0">↵</kbd>
|
||||
</button>
|
||||
</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
|
||||
)
|
||||
|
||||
// "Talk to AIUI about it" is appended after the matches, so it is the last
|
||||
// selectable row whenever there is a query (including the zero-match case,
|
||||
// where it is the only one).
|
||||
const askAiuiIndex = computed(() => filteredItems.value.length)
|
||||
|
||||
const selectableCount = computed(() => {
|
||||
if (query.value.trim()) return filteredItems.value.length + 1
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the raw typed text to AIUI instead of trying to match it to a screen.
|
||||
// The nonce is what makes re-asking the identical question work: without a
|
||||
// changing query the router treats the push as a no-op and Chat.vue never sees
|
||||
// a new `ask` to forward.
|
||||
function askAiui() {
|
||||
const text = query.value.trim()
|
||||
if (!text) return
|
||||
spotlightStore.close()
|
||||
router.push({ path: '/dashboard/chat', query: { ask: text, askedAt: String(Date.now()) } })
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (idx === askAiuiIndex.value) { askAiui(); return }
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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,134 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
data-testid="tool-confirm-overlay"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="dismiss"
|
||||
>
|
||||
<div
|
||||
data-testid="tool-confirm-backdrop"
|
||||
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">Approve this action?</h3>
|
||||
<button
|
||||
@click="dismiss"
|
||||
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>
|
||||
|
||||
<!--
|
||||
The description is node-authored: fetched by the host page over
|
||||
its own authenticated RPC session (assistant.pending), never
|
||||
received from the AIUI iframe and never model text. Plain
|
||||
interpolation only — peer-influenced argument values must render
|
||||
as inert text, so the raw-HTML directive is banned in this file.
|
||||
There is deliberately NO code path in this component that reads
|
||||
from the frame's message channel.
|
||||
-->
|
||||
<div class="bg-black/20 rounded-xl border border-white/10 p-4 mb-4">
|
||||
<p class="text-white text-sm leading-relaxed whitespace-pre-wrap">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<p class="text-white/40 text-xs mb-4">
|
||||
The assistant asked to do this. Nothing happens unless you approve — closing this
|
||||
window decides nothing, and the request expires on its own.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
data-testid="tool-confirm-deny"
|
||||
@click="deny"
|
||||
class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
data-testid="tool-confirm-approve"
|
||||
@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 props = defineProps<{
|
||||
show: boolean
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: []
|
||||
deny: []
|
||||
/** Closed without a decision: nothing is sent anywhere — the node's own
|
||||
* timeout declines the pending action. Never treated as an approval. */
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useModalKeyboard(
|
||||
modalRef,
|
||||
computed(() => props.show),
|
||||
() => emit('dismiss'),
|
||||
)
|
||||
|
||||
function approve() {
|
||||
emit('approve')
|
||||
}
|
||||
|
||||
function deny() {
|
||||
emit('deny')
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
emit('dismiss')
|
||||
}
|
||||
</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,215 @@
|
||||
<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.
|
||||
Sticky inside the modal's scroll region so the tabs stay pinned
|
||||
while rows blur past underneath. -->
|
||||
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
|
||||
backdrop blur alone keeps the pinned tabs legible over scrolling
|
||||
rows without painting an opaque container onto the modal. -->
|
||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
|
||||
<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 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 items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||
</div>
|
||||
|
||||
<!-- Must NOT be its own scroll container: BaseModal's slot wrapper is the
|
||||
scroller, and modal-scroll-locked gives every .overflow-y-auto inside
|
||||
the overlay overscroll-behavior:contain — a nested scroller with no
|
||||
overflow then swallows touch scrolling entirely (phones only; wheel
|
||||
latches onto the scrollable ancestor and never sees the bug). -->
|
||||
<div v-else class="-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' ? '+' : '-' }}{{ displayAmount(tx).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="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
|
||||
<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 feeFor(tx: WalletTransaction): number {
|
||||
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
|
||||
}
|
||||
|
||||
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
|
||||
* itself is broken out on its own tag. Incoming rows are untouched. */
|
||||
function displayAmount(tx: WalletTransaction): number {
|
||||
const gross = Math.abs(tx.amount_sats)
|
||||
const fee = feeFor(tx)
|
||||
return fee > 0 && gross > fee ? gross - fee : gross
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,901 @@
|
||||
<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. On plain-http
|
||||
desktop the button stays VISIBLE — hiding it read as "the
|
||||
scanner is gone" (operator, 2026-08-05); choosing it surfaces
|
||||
the browser's HTTPS requirement with the fallbacks instead. -->
|
||||
<button @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Scan with camera
|
||||
</button>
|
||||
<p v-if="liveCameraUnavailable && !hasNativeQr" class="text-[11px] text-white/40 text-center -mt-1">
|
||||
Your browser only allows live camera on HTTPS pages — the photo and paste options below always work.
|
||||
</p>
|
||||
<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">₿</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="scan-success-badge mx-auto mb-5">
|
||||
<ScreensaverRing size="badge" />
|
||||
<div class="scan-success-core">
|
||||
<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>
|
||||
|
||||
<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'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
|
||||
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',
|
||||
// With a native BarcodeDetector (Chrome/Android WebView) decoding is
|
||||
// hardware-cheap — scan at 10/s for a snappier lock-on. The 4/s cap
|
||||
// remains for the JS-worker fallback, where 10/s visibly lagged the
|
||||
// preview on phone WebViews.
|
||||
maxScansPerSecond: 'BarcodeDetector' in window ? 10 : 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: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
|
||||
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
|
||||
// Waits out slow multi-hop routing and only reports failure when LND
|
||||
// itself declares the payment failed — never on a timeout. Pending
|
||||
// (~8s) jumps straight to the success pane in SETTLING mode instead of
|
||||
// spinning through the whole poll; the poll upgrades it to PAID.
|
||||
const res = await rpcClient.payLightningInvoice(params, (hash) => {
|
||||
successAmount.value = effectiveAmount.value
|
||||
successVerb.value = 'SETTLING'
|
||||
successDetail.value = 'Payment is on its way — this updates the moment it settles. Safe to close.'
|
||||
successRef.value = hash
|
||||
processing.value = false
|
||||
goTo('success')
|
||||
emit('sent')
|
||||
})
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
successAmount.value = res.amount_sats || effectiveAmount.value
|
||||
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
|
||||
successDetail.value = res.status === 'pending'
|
||||
? 'Payment in flight — it will appear in your transactions once it settles'
|
||||
: '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);
|
||||
}
|
||||
|
||||
/* Paid tick (FED-06) — same composition as SendBitcoinModal: the branded
|
||||
ScreensaverRing badge with the emerald checkmark core centred over it. */
|
||||
.scan-success-badge {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.scan-success-badge {
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
}
|
||||
}
|
||||
.scan-success-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
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,77 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
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()
|
||||
})
|
||||
|
||||
it('closes itself when the route changes', async () => {
|
||||
// Tab views are KeepAlive'd, so navigating away deactivates the owner
|
||||
// rather than unmounting it — a Teleported modal would otherwise keep
|
||||
// floating over the destination screen. Seen with the Lightning modal's
|
||||
// "Open a channel" / "Setup Guide" actions, which route away from inside
|
||||
// the wallet's own send/receive modal.
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/elsewhere', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(BaseModal, {
|
||||
props: { show: true, title: 'Test modal' },
|
||||
slots: { default: '<p>Modal content</p>' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
|
||||
await router.push('/elsewhere')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit close on a route change while hidden', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/elsewhere', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(BaseModal, {
|
||||
props: { show: false, title: 'Test modal' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await router.push('/elsewhere')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
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,145 @@
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import MediaLightbox from '../cloud/MediaLightbox.vue'
|
||||
import { usePipSession } from '../../composables/usePipSession'
|
||||
import type { FileBrowserItem } from '../../api/filebrowser-client'
|
||||
|
||||
// jsdom has no picture-in-picture implementation — stub the pieces the
|
||||
// component and the session touch so `enterpictureinpicture` /
|
||||
// `leavepictureinpicture` can be dispatched like a real browser would.
|
||||
beforeEach(() => {
|
||||
// jsdom doesn't implement these either — MediaLightbox's onUnmounted
|
||||
// revokes every cached blob URL, which throws "not implemented" otherwise.
|
||||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:stub')
|
||||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn()
|
||||
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
})
|
||||
if (!('pictureInPictureElement' in document)) {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
|
||||
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
|
||||
return null as unknown as PictureInPictureWindow
|
||||
}
|
||||
}
|
||||
if (!document.exitPictureInPicture) {
|
||||
document.exitPictureInPicture = async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
usePipSession().release()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const videoItem: FileBrowserItem = {
|
||||
name: 'clip.mp4',
|
||||
path: '/clip.mp4',
|
||||
isDir: false,
|
||||
} as FileBrowserItem
|
||||
|
||||
// MediaLightbox teleports its content to <body>, so its markup lives
|
||||
// outside the mounted wrapper's own root element — query the document
|
||||
// directly rather than through `wrapper.find`.
|
||||
function findVideo(): HTMLVideoElement {
|
||||
const video = document.body.querySelector('video')
|
||||
if (!video) throw new Error('video not rendered')
|
||||
return video as HTMLVideoElement
|
||||
}
|
||||
|
||||
function findBackdrop(): HTMLElement {
|
||||
const backdrop = document.body.querySelector('.lightbox-backdrop')
|
||||
if (!backdrop) throw new Error('backdrop not rendered')
|
||||
return backdrop as HTMLElement
|
||||
}
|
||||
|
||||
async function mountLightbox() {
|
||||
const wrapper = mount(MediaLightbox, {
|
||||
props: {
|
||||
items: [videoItem],
|
||||
startIndex: 0,
|
||||
show: true,
|
||||
fetchBlobUrl: vi.fn().mockResolvedValue('blob:fetch'),
|
||||
streamUrl: vi.fn().mockResolvedValue('blob:stream'),
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('MediaLightbox picture-in-picture handoff', () => {
|
||||
it('entering PiP emits close exactly once and adopts the video before doing so', async () => {
|
||||
const wrapper = await mountLightbox()
|
||||
const video = findVideo()
|
||||
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
|
||||
// Adopted synchronously, before the animation/emit has finished — the
|
||||
// video is no longer a descendant of the lightbox's own subtree.
|
||||
expect(usePipSession().active.value).toBe(true)
|
||||
expect(usePipSession().element.value).toBe(video)
|
||||
expect(findBackdrop().contains(video)).toBe(false)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
|
||||
// Bounded fallback fires the close even without a real transitionend
|
||||
// (jsdom does not run CSS transitions).
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
|
||||
wrapper.unmount()
|
||||
// Still connected to the document after the owner unmounts.
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
})
|
||||
|
||||
it('applies the handoff class on the PiP path', async () => {
|
||||
await mountLightbox()
|
||||
const video = findVideo()
|
||||
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies no handoff class on a button-driven close', async () => {
|
||||
const wrapper = await mountLightbox()
|
||||
const closeButton = document.body.querySelector(
|
||||
'.lightbox-topbar .lightbox-btn:last-child'
|
||||
) as HTMLButtonElement
|
||||
closeButton.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the session when picture-in-picture is left', async () => {
|
||||
await mountLightbox()
|
||||
const video = findVideo()
|
||||
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
expect(usePipSession().active.value).toBe(true)
|
||||
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
|
||||
expect(usePipSession().active.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not change props or emits declared by the component', async () => {
|
||||
// Contract guard mirrored from the diff-based acceptance criterion:
|
||||
// this component is used by a second, parallel instance (plan 01-14)
|
||||
// and must keep working with an unmodified prop/emit set.
|
||||
const wrapper = await mountLightbox()
|
||||
expect(wrapper.props('startIndex')).toBe(0)
|
||||
expect(wrapper.props('show')).toBe(true)
|
||||
expect(typeof wrapper.props('fetchBlobUrl')).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -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,79 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import i18n from '@/i18n'
|
||||
import ScreensaverRing from '../ScreensaverRing.vue'
|
||||
import SendBitcoinModal from '../SendBitcoinModal.vue'
|
||||
import WalletScanModal from '../WalletScanModal.vue'
|
||||
|
||||
// Both modals fetch balances/fees on open. None of that is what this suite is
|
||||
// about — stub the transport so mounting is deterministic and offline.
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
// WalletScanModal reaches for camera/QR APIs jsdom does not implement.
|
||||
beforeEach(() => {
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia: vi.fn().mockRejectedValue(new Error('no camera')), enumerateDevices: vi.fn().mockResolvedValue([]) },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Teleported modal markup outlives the wrapper's root, so clear it between
|
||||
// cases — otherwise one modal's nodes answer the next one's queries.
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const mountOpts = { props: { show: true }, global: { plugins: [i18n] } }
|
||||
|
||||
describe('paid tick renders the branded ring (FED-06)', () => {
|
||||
it('SendBitcoinModal: payment success shows exactly one badge ring, no ripple burst', async () => {
|
||||
const wrapper = mount(SendBitcoinModal, mountOpts)
|
||||
// Drive the component into its settled-payment state directly — this
|
||||
// suite is about what success *renders*, not how a payment settles.
|
||||
;(wrapper.vm as unknown as Record<string, unknown>).successInfo = {
|
||||
amount: 12345,
|
||||
methodLabel: 'Sent via Lightning',
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
const rings = wrapper.findAllComponents(ScreensaverRing)
|
||||
expect(rings).toHaveLength(1)
|
||||
expect(rings[0]?.props('size')).toBe('badge')
|
||||
|
||||
// BaseModal teleports its content to <body>, so the rendered markup lives
|
||||
// outside the wrapper's own root element — assert against the document.
|
||||
// The checkmark core survives the ring swap...
|
||||
expect(document.querySelector('.burst-core')).not.toBeNull()
|
||||
expect(document.querySelector('.burst-check')).not.toBeNull()
|
||||
// ...and the CSS ripple elements it replaced are gone entirely.
|
||||
expect(document.querySelectorAll('.burst-ring')).toHaveLength(0)
|
||||
|
||||
// Decoration only: the amount and SENT copy are untouched.
|
||||
expect(document.body.textContent).toContain('12,345')
|
||||
expect(document.body.textContent).toContain('SENT')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('WalletScanModal: success pane shows the same badge ring and keeps its checkmark', async () => {
|
||||
const wrapper = mount(WalletScanModal, mountOpts)
|
||||
;(wrapper.vm as unknown as Record<string, unknown>).pane = 'success'
|
||||
await flushPromises()
|
||||
|
||||
const rings = wrapper.findAllComponents(ScreensaverRing)
|
||||
expect(rings).toHaveLength(1)
|
||||
expect(rings[0]?.props('size')).toBe('badge')
|
||||
expect(document.querySelector('.scan-success-core')).not.toBeNull()
|
||||
expect(document.querySelector('svg path[d="M5 13l4 4L19 7"]')).not.toBeNull()
|
||||
// The plain fixed-size circle the ring replaced is gone.
|
||||
expect(document.querySelectorAll('.success-ring')).toHaveLength(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ScreensaverRing from '../ScreensaverRing.vue'
|
||||
|
||||
// The ring is shared by the screensaver (default), SystemDangerZone (compact)
|
||||
// and — as of FED-06 — the payment-success tick (badge). These assertions pin
|
||||
// the variant mapping so adding a size can never silently re-point an existing
|
||||
// call site at different geometry.
|
||||
describe('ScreensaverRing', () => {
|
||||
it('maps each size variant to its own ring class', () => {
|
||||
expect(mount(ScreensaverRing).classes()).toContain('viz-ring-default')
|
||||
expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).toContain(
|
||||
'viz-ring-compact',
|
||||
)
|
||||
expect(mount(ScreensaverRing, { props: { size: 'badge' } }).classes()).toContain(
|
||||
'viz-ring-badge',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the existing variants off the badge class', () => {
|
||||
expect(mount(ScreensaverRing).classes()).not.toContain('viz-ring-badge')
|
||||
expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).not.toContain(
|
||||
'viz-ring-badge',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders one segment per segmentCount, defaulting to 48', () => {
|
||||
expect(mount(ScreensaverRing).findAll('.viz-segment')).toHaveLength(48)
|
||||
expect(
|
||||
mount(ScreensaverRing, { props: { segmentCount: 12 } }).findAll('.viz-segment'),
|
||||
).toHaveLength(12)
|
||||
expect(
|
||||
mount(ScreensaverRing, { props: { size: 'badge', segmentCount: 24 } }).findAll('.viz-segment'),
|
||||
).toHaveLength(24)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
// 02-05 Task 2: bounds MeshMap.vue's Leaflet instance across Mesh.vue's
|
||||
// activate/deactivate lifecycle (established in 02-04 — Mesh.vue joined
|
||||
// KEEP_ALIVE_PATHS). Kept in its own file rather than
|
||||
// src/views/__tests__/meshTabCache.test.ts because `vi.mock('@/stores/mesh')`
|
||||
// and `vi.mock('leaflet')` are hoisted file-wide and would otherwise clobber
|
||||
// that file's need for the REAL mesh/transport stores (mirrors the
|
||||
// MarketplaceRefresh.test.ts precedent set in 02-02 for the same class of
|
||||
// vi.mock-hoisting conflict — Rule 1/3 auto-fix, documented in
|
||||
// 02-05-SUMMARY.md).
|
||||
//
|
||||
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
|
||||
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of
|
||||
// this plan's scope). This file therefore only covers the Leaflet map's
|
||||
// activate/deactivate lifecycle — the D3-specific truths from the plan are
|
||||
// vacuously satisfied (there is nothing to leak).
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MeshMap from '../MeshMap.vue'
|
||||
|
||||
const mapInstances: Array<{ invalidateSize: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> }> = []
|
||||
let resizeObserverInstances: Array<{ observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = []
|
||||
|
||||
vi.mock('@/stores/mesh', () => ({
|
||||
useMeshStore: () => ({
|
||||
nodePositions: new Map(),
|
||||
federatedPositions: new Map(),
|
||||
peers: [],
|
||||
status: null,
|
||||
deadmanStatus: null,
|
||||
updateSelfPosition: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('leaflet', () => ({
|
||||
default: {
|
||||
map: vi.fn(() => {
|
||||
const instance = { invalidateSize: vi.fn(), fitBounds: vi.fn(), remove: vi.fn(), setView: vi.fn() }
|
||||
mapInstances.push(instance)
|
||||
return instance
|
||||
}),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
|
||||
divIcon: vi.fn((opts: unknown) => opts),
|
||||
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
|
||||
polyline: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
||||
|
||||
function mountMapHost() {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(MeshMap, { key: 'map' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
return mount(Host)
|
||||
}
|
||||
|
||||
async function toggleTab(wrapper: ReturnType<typeof mountMapHost>, show: boolean) {
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = show
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
describe('Mesh graphics lifecycle (Task 2): Leaflet map (MeshMap.vue)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mapInstances.length = 0
|
||||
resizeObserverInstances = []
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => {
|
||||
const inst = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() }
|
||||
resizeObserverInstances.push(inst)
|
||||
return inst
|
||||
}))
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined,
|
||||
} as DOMRect)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('entering and leaving the tab three times constructs exactly one map instance', async () => {
|
||||
const wrapper = mountMapHost()
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(300) // the onMounted-arm's fallback initMap()
|
||||
await flushPromises()
|
||||
expect(mapInstances.length).toBe(1)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await toggleTab(wrapper, false)
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
}
|
||||
expect(mapInstances.length).toBe(1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reactivating calls the Leaflet map size-invalidation so a map laid out off screen re-tiles at its real size', async () => {
|
||||
const wrapper = mountMapHost()
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
const instance = mapInstances[0]!
|
||||
instance.invalidateSize.mockClear()
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
await toggleTab(wrapper, true)
|
||||
await flushPromises()
|
||||
|
||||
expect(instance.invalidateSize).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('a window resize listener is removed on deactivate and re-added exactly once on activate', async () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const wrapper = mountMapHost()
|
||||
await flushPromises()
|
||||
// armMapVisibility's own idempotent idiom (remove-then-add) means mount
|
||||
// itself issues one defensive remove alongside the one add — baseline
|
||||
// both counts here rather than assuming remove starts at zero.
|
||||
const addCountAtMount = addSpy.mock.calls.filter((c) => c[0] === 'resize').length
|
||||
const removeCountAtMount = removeSpy.mock.calls.filter((c) => c[0] === 'resize').length
|
||||
expect(addCountAtMount).toBe(1)
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
expect(removeSpy.mock.calls.filter((c) => c[0] === 'resize').length).toBe(removeCountAtMount + 1)
|
||||
|
||||
await toggleTab(wrapper, true)
|
||||
expect(addSpy.mock.calls.filter((c) => c[0] === 'resize').length).toBe(addCountAtMount + 1) // once more, not twice
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('deactivating disconnects the ResizeObserver; reactivating re-observes the container', async () => {
|
||||
const wrapper = mountMapHost()
|
||||
await flushPromises()
|
||||
const inst = resizeObserverInstances[0]!
|
||||
expect(inst.observe).toHaveBeenCalledTimes(1)
|
||||
|
||||
await toggleTab(wrapper, false)
|
||||
expect(inst.disconnect).toHaveBeenCalledTimes(1)
|
||||
|
||||
await toggleTab(wrapper, true)
|
||||
expect(inst.observe).toHaveBeenCalledTimes(2)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,528 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="lightbox-fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="lightbox-backdrop"
|
||||
:class="{ 'lightbox-pip-handoff': pipHandoff }"
|
||||
@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="pipAvailable && 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"
|
||||
@enterpictureinpicture="onEnterPip"
|
||||
@leavepictureinpicture="onLeavePip"
|
||||
/>
|
||||
|
||||
<!-- 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 { isPipSupported, togglePip } from '@/utils/pip'
|
||||
import { usePipSession } from '@/composables/usePipSession'
|
||||
|
||||
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 pipAvailable = isPipSupported()
|
||||
const pipSession = usePipSession()
|
||||
const pipHandoff = ref(false)
|
||||
// Bounded fallback for the transitionend-driven close below — covers a
|
||||
// browser that skips the transition entirely (including the reduced-motion
|
||||
// path, where the CSS transition duration is zero and transitionend never
|
||||
// fires).
|
||||
const PIP_HANDOFF_FALLBACK_MS = 350
|
||||
let handoffTimer: ReturnType<typeof setTimeout> | 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 finishPipHandoff() {
|
||||
backdropEl.value?.removeEventListener('transitionend', finishPipHandoff)
|
||||
if (handoffTimer) {
|
||||
clearTimeout(handoffTimer)
|
||||
handoffTimer = null
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Listens for the enterpictureinpicture event itself, rather than inferring
|
||||
// from the button click, so PiP entered by any route (the browser's own
|
||||
// control, a keyboard shortcut) behaves the same. Order matters here and
|
||||
// must stay exactly this: adopt first, animate second, emit last — adopting
|
||||
// first is what makes the element survive the unmount the emit triggers.
|
||||
function onEnterPip() {
|
||||
const video = videoEl.value
|
||||
if (!video) return
|
||||
pipSession.adopt(video)
|
||||
pipHandoff.value = true
|
||||
backdropEl.value?.addEventListener('transitionend', finishPipHandoff, { once: true })
|
||||
handoffTimer = setTimeout(finishPipHandoff, PIP_HANDOFF_FALLBACK_MS)
|
||||
}
|
||||
|
||||
// The session's own leavepictureinpicture listener (attached in
|
||||
// usePipSession at adopt time) is the primary release path, since the
|
||||
// lightbox has normally already unmounted by the time PiP is exited. This
|
||||
// handler only covers the case where the lightbox is somehow still
|
||||
// mounted; release() is idempotent so calling it twice is harmless.
|
||||
function onLeavePip() {
|
||||
pipSession.release()
|
||||
}
|
||||
|
||||
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) {
|
||||
pipHandoff.value = false
|
||||
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;
|
||||
}
|
||||
|
||||
/* Picture-in-picture handoff (UIFIX-05): reads as the video moving into the
|
||||
PiP window rather than a dismissal. The backdrop's blur/opacity fall away
|
||||
while the content scales down and drifts toward the bottom-right corner
|
||||
most browsers default the PiP window to — a best-effort convention, since
|
||||
the actual corner is browser- and user-controlled. Applied only on the
|
||||
PiP path; a normal close (button or Escape) never gets this class. */
|
||||
.lightbox-pip-handoff {
|
||||
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
}
|
||||
.lightbox-pip-handoff .lightbox-topbar,
|
||||
.lightbox-pip-handoff .lightbox-nav {
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
.lightbox-pip-handoff .lightbox-content {
|
||||
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
|
||||
transform: scale(0.4) translate(40vw, 40vh);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lightbox-pip-handoff,
|
||||
.lightbox-pip-handoff .lightbox-topbar,
|
||||
.lightbox-pip-handoff .lightbox-nav,
|
||||
.lightbox-pip-handoff .lightbox-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,447 @@
|
||||
<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 lang="ts">
|
||||
/**
|
||||
* Extension → MIME map used when sharing a file that isn't already in the
|
||||
* catalog (`save()` below, `content.add`). Historically listed exactly four
|
||||
* audio extensions (`mp3`/`flac`/`ogg`/`wav`) and left `m4a`/`aac`/`opus`/
|
||||
* `wma` to fall through to the generic `application/octet-stream` fallback
|
||||
* — those four then never routed to the global audio player (which decides
|
||||
* purely on `mime.startsWith('audio/')`, see `usePaidItemViewer.ts`) and
|
||||
* auto-filed to Documents instead of Music (`content.rs`'s paid-download
|
||||
* auto-filing, same `starts_with("audio/")` check) — 13-CONTEXT.md's
|
||||
* landmine, T-13-72. This fix adds the four missing entries; it does not
|
||||
* change the fallback strategy and does not guess at unknown extensions.
|
||||
*
|
||||
* Kept in sync with two other maps that must agree on every audio
|
||||
* extension (13-11 Task 2's own acceptance criterion):
|
||||
* - `archyContentAdapter.ts`'s `classifyByMime`/`AUDIO_EXT_FALLBACK` (13-06)
|
||||
* - `content.rs`'s auto-filing check (`mime_type.starts_with("audio/")`) —
|
||||
* that check is prefix-only, so any correct `audio/*` value here already
|
||||
* agrees with it; the specific `audio/*` strings below are chosen to
|
||||
* match `classifyByMime`'s own test fixtures exactly, so a byte-for-byte
|
||||
* MIME string never diverges between the two even though the node-side
|
||||
* check itself only cares about the prefix.
|
||||
*
|
||||
* Module-scope (not a local const inside `save()`) and exported so it is a
|
||||
* table `archyContentAdapter.test.ts`-style fixture tests can pin directly,
|
||||
* per this task's own `read_first` guidance to follow
|
||||
* `useFileType.test.ts`'s fixture-table convention.
|
||||
*/
|
||||
export const SHARE_MIME_MAP: 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',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
opus: 'audio/opus',
|
||||
wma: 'audio/x-ms-wma',
|
||||
pdf: 'application/pdf',
|
||||
zip: 'application/zip',
|
||||
txt: 'text/plain',
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 addRes = await rpcClient.call<{ item: { id: string } }>({
|
||||
method: 'content.add',
|
||||
params: {
|
||||
filename: (props.filepath || props.filename).replace(/^\/+/, ''),
|
||||
mime_type: SHARE_MIME_MAP[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,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { SHARE_MIME_MAP } from '../ShareModal.vue'
|
||||
|
||||
// 13-11 Task 2: ShareModal.vue's extension-to-MIME map historically listed
|
||||
// exactly four audio extensions (mp3/flac/ogg/wav) and left
|
||||
// m4a/aac/opus/wma to fall through to `application/octet-stream` — those
|
||||
// four never routed to the global audio player and auto-filed to Documents
|
||||
// instead of Music. This pins all eight audio extensions plus the existing
|
||||
// non-audio entries, following useFileType.test.ts's fixture-table
|
||||
// convention.
|
||||
|
||||
describe('ShareModal SHARE_MIME_MAP', () => {
|
||||
it.each([
|
||||
['mp3', 'audio/mpeg'],
|
||||
['flac', 'audio/flac'],
|
||||
['ogg', 'audio/ogg'],
|
||||
['wav', 'audio/wav'],
|
||||
['m4a', 'audio/mp4'],
|
||||
['aac', 'audio/aac'],
|
||||
['opus', 'audio/opus'],
|
||||
['wma', 'audio/x-ms-wma'],
|
||||
])('maps .%s to the audio MIME %s', (ext, expected) => {
|
||||
expect(SHARE_MIME_MAP[ext]).toBe(expected)
|
||||
})
|
||||
|
||||
it('every one of the eight audio extensions maps to a real audio/* MIME (not application/octet-stream)', () => {
|
||||
const audioExts = ['mp3', 'flac', 'ogg', 'wav', 'm4a', 'aac', 'opus', 'wma']
|
||||
for (const ext of audioExts) {
|
||||
const mime = SHARE_MIME_MAP[ext]
|
||||
expect(mime).toBeDefined()
|
||||
expect(mime!.startsWith('audio/')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('existing non-audio entries are unchanged', () => {
|
||||
expect(SHARE_MIME_MAP.jpg).toBe('image/jpeg')
|
||||
expect(SHARE_MIME_MAP.mp4).toBe('video/mp4')
|
||||
expect(SHARE_MIME_MAP.pdf).toBe('application/pdf')
|
||||
expect(SHARE_MIME_MAP.zip).toBe('application/zip')
|
||||
expect(SHARE_MIME_MAP.txt).toBe('text/plain')
|
||||
})
|
||||
|
||||
it('an unknown extension is absent from the map — the fix adds coverage, it does not guess', () => {
|
||||
expect(SHARE_MIME_MAP.xyz123).toBeUndefined()
|
||||
})
|
||||
|
||||
it('agrees with archyContentAdapter.ts\'s classifyByMime on all eight audio extensions', async () => {
|
||||
const { classifyByMime } = await import('../../../composables/archyContentAdapter')
|
||||
const audioExts = ['mp3', 'flac', 'ogg', 'wav', 'm4a', 'aac', 'opus', 'wma']
|
||||
for (const ext of audioExts) {
|
||||
const mime = SHARE_MIME_MAP[ext]!
|
||||
expect(classifyByMime({ mime_type: mime, filename: `track.${ext}` })).toBe('audio')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('an audio MIME never opens the lightbox', () => {
|
||||
it('usePaidItemViewer routes every audio/* mime to the global player before its image/video lightbox branch is ever reached', () => {
|
||||
// usePaidItemViewer.ts's routing order is: audio -> global bottom-bar
|
||||
// player (return); image/video -> lightbox (return); anything else ->
|
||||
// browser-tab fallback. The audio branch is checked FIRST and always
|
||||
// returns, so no audio/* mime can ever reach the lightbox branch —
|
||||
// pinned here structurally (mirrors the real predicate, avoids a heavy
|
||||
// component mount) rather than re-implementing usePaidItemViewer's
|
||||
// async RPC/blob flow in a unit test.
|
||||
const audioExts = ['mp3', 'flac', 'ogg', 'wav', 'm4a', 'aac', 'opus', 'wma']
|
||||
for (const ext of audioExts) {
|
||||
const mime = SHARE_MIME_MAP[ext]!
|
||||
const routesToAudioPlayerFirst = mime.startsWith('audio/')
|
||||
expect(routesToAudioPlayerFirst).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,661 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Set Recommended' : '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="py-1">
|
||||
<div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
|
||||
<span>{{ probeStage }}</span>
|
||||
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
|
||||
:style="{ width: probeProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</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 Recommended
|
||||
</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 recommended 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' && rnodePlan" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode plan for {{ form.region }}: {{ (rnodePlan.frequency / 1e6).toFixed(4) }} MHz, {{ rnodePlan.bandwidth / 1000 }} kHz, SF{{ rnodePlan.spreading_factor }}, CR4/{{ rnodePlan.coding_rate }}, {{ rnodePlan.txpower }} dBm — applied on connect, and the radio confirms it.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
Pick a region to apply its recommended RNode RF plan on connect — editable any time in Mesh → Device settings.
|
||||
</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, RNODE_REGION_PLANS } 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('')
|
||||
|
||||
// Time-driven probe progress: the probe RPC is a single opaque call that can
|
||||
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
|
||||
// advances on a clock toward 92% and snaps to 100% when the result lands.
|
||||
const probeProgress = ref(0)
|
||||
const probeStage = ref('Waiting for the radio to boot…')
|
||||
let probeTicker: ReturnType<typeof setInterval> | null = null
|
||||
function startProbeProgress() {
|
||||
stopProbeProgress()
|
||||
probeProgress.value = 0
|
||||
probeStage.value = 'Waiting for the radio to boot…'
|
||||
const startedAt = Date.now()
|
||||
probeTicker = setInterval(() => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000
|
||||
// ~92% at 30s, decelerating — never looks stuck, never lies "done".
|
||||
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
|
||||
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
|
||||
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
|
||||
}, 400)
|
||||
}
|
||||
function stopProbeProgress(done = false) {
|
||||
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
|
||||
if (done) probeProgress.value = 100
|
||||
}
|
||||
|
||||
const devicePath = computed(() => mesh.flashFlowPath ?? 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 chosen region's recommended RNode RF plan (undefined = none chosen). */
|
||||
const rnodePlan = computed(() =>
|
||||
form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined,
|
||||
)
|
||||
// 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
|
||||
}
|
||||
if (mesh.flashFlowPath) {
|
||||
// Manual "Flash LoRa" entry: jump straight to the flash step. Skip the
|
||||
// read-only probe — the port is usually the live session's, and a second
|
||||
// opener on the tty corrupts the running connection; status already
|
||||
// knows the firmware kind for the connected radio.
|
||||
probe.value = null
|
||||
probeError.value = ''
|
||||
probing.value = false
|
||||
stopProbeProgress()
|
||||
imageFailed.value = false
|
||||
openFlashStep()
|
||||
const t = (mesh.status?.device_type ?? '').toLowerCase()
|
||||
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
|
||||
flashFamily.value = t as FlashFirmwareFamily
|
||||
}
|
||||
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
|
||||
startProbeProgress()
|
||||
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 {
|
||||
stopProbeProgress(true)
|
||||
if (devicePath.value === path) probing.value = false
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
function dismiss() {
|
||||
if (mesh.flashFlowPath) {
|
||||
mesh.closeFlashFlow()
|
||||
return
|
||||
}
|
||||
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)
|
||||
// RNode radios: the region's recommended RF plan is applied through the
|
||||
// Reticulum daemon's persisted settings (mesh.rnode-config-apply), the
|
||||
// same round-trip the Device panel uses — the radio confirms the values
|
||||
// itself after the daemon restarts with them. Best-effort here: a
|
||||
// failure must not abort the connect the user just asked for.
|
||||
if (effectiveKind.value === 'reticulum' && rnodePlan.value) {
|
||||
mesh.suppressDeviceDetect()
|
||||
void mesh
|
||||
.applyRnodeConfig({ enabled: true, port: null, ...rnodePlan.value })
|
||||
.catch(() => {})
|
||||
}
|
||||
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()
|
||||
if (mesh.flashFlowPath) {
|
||||
// Manual entry has no detection step to go back to — close the modal.
|
||||
mesh.closeFlashFlow()
|
||||
return
|
||||
}
|
||||
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,448 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
adaptContentItems,
|
||||
adaptToFilm,
|
||||
adaptToPodcast,
|
||||
adaptLibraryTracks,
|
||||
adaptLibraryAlbums,
|
||||
classifyByMime,
|
||||
sortDeterministic,
|
||||
type ArchyContentItem,
|
||||
type ArchyLibraryTrack,
|
||||
} from '../archyContentAdapter'
|
||||
|
||||
function item(overrides: Partial<ArchyContentItem>): ArchyContentItem {
|
||||
return {
|
||||
id: 'id-1',
|
||||
filename: 'file.bin',
|
||||
mime_type: 'application/octet-stream',
|
||||
size_bytes: 1024,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyByMime', () => {
|
||||
it('classifies a video mime as video', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'video/mp4', filename: 'movie.mp4' }))).toBe('video')
|
||||
})
|
||||
|
||||
it('classifies an audio mime as audio', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'audio/mpeg', filename: 'song.mp3' }))).toBe('audio')
|
||||
})
|
||||
|
||||
it('classifies images as image, and still excludes documents', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'image/jpeg', filename: 'photo.jpg' }))).toBe('image')
|
||||
expect(classifyByMime(item({ mime_type: 'application/pdf', filename: 'doc.pdf' }))).toBe('excluded')
|
||||
})
|
||||
|
||||
it('classifies images by extension when the mime is generic', () => {
|
||||
// A node whose catalog is mostly photos shared with an unidentified
|
||||
// mime must not present as empty.
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.webp' }))).toBe('image')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.heic' }))).toBe('image')
|
||||
})
|
||||
|
||||
it('classifies m4a, aac, opus and wma as audio via extension fallback (ShareModal blind spot)', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.m4a' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.aac' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.opus' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.wma' }))).toBe('audio')
|
||||
})
|
||||
|
||||
it('also classifies the correct audio/* mime for those four extensions', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'audio/mp4', filename: 'track.m4a' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/aac', filename: 'track.aac' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/opus', filename: 'track.opus' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/x-ms-wma', filename: 'track.wma' }))).toBe('audio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptContentItems', () => {
|
||||
it('maps a video-mime item to a Film with id carried through and one source entry', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'film-1', filename: 'The Movie.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(1)
|
||||
expect(bundle.films[0]!.id).toBe('film-1')
|
||||
expect(bundle.films[0]!.title).toBe('The Movie')
|
||||
expect(bundle.films[0]!.sources).toHaveLength(1)
|
||||
expect(bundle.songs).toHaveLength(0)
|
||||
expect(bundle.podcasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('maps an audio-mime item to a Song', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'song-1', filename: 'Track.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.songs).toHaveLength(1)
|
||||
expect(bundle.songs[0]!.id).toBe('song-1')
|
||||
expect(bundle.films).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes images to the images bucket and still excludes documents', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'img-1', filename: 'photo.jpg', mime_type: 'image/jpeg' }),
|
||||
item({ id: 'doc-1', filename: 'report.pdf', mime_type: 'application/pdf' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(0)
|
||||
expect(bundle.songs).toHaveLength(0)
|
||||
expect(bundle.podcasts).toHaveLength(0)
|
||||
// The photo renders; the PDF has no grid, so it stays out of every bucket.
|
||||
expect(bundle.images).toHaveLength(1)
|
||||
expect(bundle.images[0]!.id).toBe('img-1')
|
||||
expect(bundle.images[0]!.url).toBe('/content/img-1')
|
||||
})
|
||||
|
||||
it('never locks an OWN paid image: the node serves the authenticated owner (owner-bypass), price stays as a badge', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.images[0]!.locked).toBe(false)
|
||||
expect(bundle.images[0]!.priceSats).toBe(100)
|
||||
expect(bundle.images[0]!.url).toBe('/content/p-1')
|
||||
})
|
||||
|
||||
it('locks a PEER paid image: price carried, no URL to fetch bytes the user has not bought', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
|
||||
{ source: 'peer', peerOnion: 'seller.onion' },
|
||||
)
|
||||
expect(bundle.images[0]!.locked).toBe(true)
|
||||
expect(bundle.images[0]!.priceSats).toBe(100)
|
||||
expect(bundle.images[0]!.url).toBe('')
|
||||
})
|
||||
|
||||
it('maps an access:Paid item with a price and a locked flag, and no playable source URL', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({
|
||||
id: 'paid-1',
|
||||
filename: 'premium.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
access: { paid: { price_sats: 5000 } },
|
||||
}),
|
||||
],
|
||||
{ source: 'peer', peerOnion: 'abc123.onion' },
|
||||
)
|
||||
const film = bundle.films[0]!
|
||||
expect(film.locked).toBe(true)
|
||||
expect(film.priceSats).toBe(5000)
|
||||
expect(film.sources[0]!.url).toBe('')
|
||||
})
|
||||
|
||||
it('gives two items with identical filename and size but different id two distinct cards (adjacency)', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'peer-a', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'peer-b', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'peer', peerOnion: 'peer.onion' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(2)
|
||||
const ids = bundle.films.map((f) => f.id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect(ids).toContain('peer-a')
|
||||
expect(ids).toContain('peer-b')
|
||||
})
|
||||
|
||||
it('an item present both in own library and a peer share appears once per source (adjacency, cross-source)', () => {
|
||||
const own = adaptContentItems(
|
||||
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const peer = adaptContentItems(
|
||||
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'peer', peerOnion: 'peer.onion' },
|
||||
)
|
||||
// Each source's bundle carries its own single card for the id — the
|
||||
// broker (Task 2) is responsible for not silently merging bundles from
|
||||
// different sources into one deduplicated list.
|
||||
expect(own.films).toHaveLength(1)
|
||||
expect(peer.films).toHaveLength(1)
|
||||
expect(own.films[0]!.sources[0]!.type).not.toBe(peer.films[0]!.sources[0]!.type)
|
||||
})
|
||||
|
||||
it('an empty input array produces empty films/songs/podcasts arrays, not undefined or an error', () => {
|
||||
const bundle = adaptContentItems([], { source: 'own' })
|
||||
expect(bundle).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
})
|
||||
|
||||
it('handles null/undefined input the same as an empty array', () => {
|
||||
expect(adaptContentItems(null, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
expect(adaptContentItems(undefined, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
})
|
||||
|
||||
it('maps a null/absent description to an empty string, never the literal "null"', () => {
|
||||
const withNull = adaptContentItems(
|
||||
[item({ id: 'f1', filename: 'a.mp4', mime_type: 'video/mp4', description: null })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const withAbsent = adaptContentItems(
|
||||
[item({ id: 'f2', filename: 'b.mp4', mime_type: 'video/mp4' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(withNull.films[0]!.synopsis).toBe('')
|
||||
expect(withAbsent.films[0]!.synopsis).toBe('')
|
||||
})
|
||||
|
||||
it('sorts added_at descending with id ascending as the deterministic tiebreak', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'z', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'a', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'm', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
// Newest added_at first (m), then the 2026-01-01 pair tie-broken by id ascending (a, z).
|
||||
expect(bundle.films.map((f) => f.id)).toEqual(['m', 'a', 'z'])
|
||||
})
|
||||
|
||||
it('a null added_at sorts last rather than crashing the comparator', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'has-date', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'no-date', filename: 'b.mp4', mime_type: 'video/mp4', added_at: null }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films.map((f) => f.id)).toEqual(['has-date', 'no-date'])
|
||||
})
|
||||
|
||||
it('produces identical output order regardless of input array order (repeat-call stability)', () => {
|
||||
const items = [
|
||||
item({ id: 'a', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'b', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
item({ id: 'c', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-01-15T00:00:00Z' }),
|
||||
]
|
||||
const first = adaptContentItems(items, { source: 'own' })
|
||||
const second = adaptContentItems([...items].reverse(), { source: 'own' })
|
||||
expect(first.films.map((f) => f.id)).toEqual(second.films.map((f) => f.id))
|
||||
})
|
||||
|
||||
it('never produces a URL carrying a credential as a query parameter', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'own-1', filename: 'a.mp4', mime_type: 'video/mp4' }),
|
||||
item({ id: 'song-1', filename: 'b.mp3', mime_type: 'audio/mpeg' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const peerBundle = adaptContentItems(
|
||||
[item({ id: 'peer-1', filename: 'c.mp4', mime_type: 'video/mp4' })],
|
||||
{ source: 'peer', peerOnion: 'somepeer.onion' },
|
||||
)
|
||||
const allUrls = [
|
||||
...bundle.films.flatMap((f) => f.sources.map((s) => s.url)),
|
||||
...bundle.songs.flatMap((s) => (s.sources ?? []).map((src) => src.url)),
|
||||
...peerBundle.films.flatMap((f) => f.sources.map((s) => s.url)),
|
||||
]
|
||||
for (const url of allUrls) {
|
||||
expect(url).not.toMatch(/[?&](auth|token)=/)
|
||||
}
|
||||
})
|
||||
|
||||
it('shape-pins every field FilmGrid.vue and SongGrid.vue read', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'film-shape', filename: 'Shape Test.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'song-shape', filename: 'Shape Song.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const film = bundle.films[0]!
|
||||
// FilmGrid.vue reads: id, title, year, director, cast (search/aria-label),
|
||||
// rating, sources[].type (badges), coverSrc()/fallbackSrc() consume
|
||||
// posterUrl/backdropUrl/title/year, genres (topGenres filter).
|
||||
expect(typeof film.id).toBe('string')
|
||||
expect(typeof film.title).toBe('string')
|
||||
expect(typeof film.year).toBe('number')
|
||||
expect(typeof film.director).toBe('string')
|
||||
expect(Array.isArray(film.cast)).toBe(true)
|
||||
expect(typeof film.rating).toBe('number')
|
||||
expect(Array.isArray(film.genres)).toBe(true)
|
||||
expect(Array.isArray(film.sources)).toBe(true)
|
||||
expect(film.sources.length).toBeGreaterThan(0)
|
||||
expect(typeof film.sources[0]!.type).toBe('string')
|
||||
|
||||
const song = bundle.songs[0]!
|
||||
// SongGrid.vue reads: id, title, artist (search/aria-label), album
|
||||
// (search), genres (topGenres), coverUrl, sources[].type (badges).
|
||||
expect(typeof song.id).toBe('string')
|
||||
expect(typeof song.title).toBe('string')
|
||||
expect(typeof song.artist).toBe('string')
|
||||
expect(Array.isArray(song.sources)).toBe(true)
|
||||
expect((song.sources ?? []).length).toBeGreaterThan(0)
|
||||
expect(typeof song.sources![0]!.type).toBe('string')
|
||||
})
|
||||
|
||||
it('pins the three source-badge literal values for own/peer/indeehub films', () => {
|
||||
const own = adaptToFilm(item({ id: 'x', filename: 'x.mp4', mime_type: 'video/mp4' }), { source: 'own' })
|
||||
const peer = adaptToFilm(item({ id: 'y', filename: 'y.mp4', mime_type: 'video/mp4' }), { source: 'peer', peerOnion: 'p.onion' })
|
||||
const indeehub = adaptToFilm(item({ id: 'z', filename: 'z.mp4', mime_type: 'video/mp4' }), { source: 'indeehub' })
|
||||
expect(own.sources[0]!.type).toBe('nextcloud')
|
||||
expect(peer.sources[0]!.type).toBe('plex')
|
||||
expect(indeehub.sources[0]!.type).toBe('indeehub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptToPodcast', () => {
|
||||
it('maps a ContentItem to a Podcast shape (exported for completeness; not reachable via adaptContentItems today)', () => {
|
||||
const podcast = adaptToPodcast(
|
||||
item({ id: 'pod-1', filename: 'Episode One.mp3', mime_type: 'audio/mpeg', description: 'A description' }),
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(podcast.id).toBe('pod-1')
|
||||
expect(podcast.title).toBe('Episode One')
|
||||
expect(podcast.description).toBe('A description')
|
||||
expect(Array.isArray(podcast.sources)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortDeterministic', () => {
|
||||
it('is a pure function that does not mutate its input', () => {
|
||||
const items = [
|
||||
item({ id: 'b', filename: 'b.mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'a', filename: 'a.mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
]
|
||||
const copy = [...items]
|
||||
sortDeterministic(items)
|
||||
expect(items).toEqual(copy)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Library mapping (13-11) ────────────────────────────────────────────
|
||||
|
||||
function track(overrides: Partial<ArchyLibraryTrack>): ArchyLibraryTrack {
|
||||
return {
|
||||
id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Album/01 Song.flac' },
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
album_artist: 'Artist',
|
||||
track_number: 1,
|
||||
disc_number: 1,
|
||||
year: 2024,
|
||||
duration_secs: 210,
|
||||
has_tags: true,
|
||||
content_hash: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('adaptLibraryTracks', () => {
|
||||
it('maps a music.list-tracks record to a Song with title/artist/album/duration carried through from tags', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({ title: 'Night Drive', artist: 'The Synths', album: 'Neon', duration_secs: 187 }),
|
||||
])
|
||||
expect(song!.title).toBe('Night Drive')
|
||||
expect(song!.artist).toBe('The Synths')
|
||||
expect(song!.album).toBe('Neon')
|
||||
expect(song!.duration).toBe(187)
|
||||
})
|
||||
|
||||
it('falls back to album_artist when artist is absent, and to an empty string when both are absent — never null/undefined', () => {
|
||||
const [withAlbumArtist] = adaptLibraryTracks([track({ artist: null, album_artist: 'Various' })])
|
||||
expect(withAlbumArtist!.artist).toBe('Various')
|
||||
|
||||
const [withNeither] = adaptLibraryTracks([track({ artist: null, album_artist: null })])
|
||||
expect(withNeither!.artist).toBe('')
|
||||
expect(withNeither!.artist).not.toBe('null')
|
||||
expect(withNeither!.artist).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the index\'s own deterministic order — calling the adapter twice on the same input yields the same order', () => {
|
||||
const tracks = [
|
||||
track({ id: { source: 'OwnLibrary', path: '/a' }, title: 'A' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/b' }, title: 'B' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/c' }, title: 'C' }),
|
||||
]
|
||||
const first = adaptLibraryTracks(tracks).map((s) => s.title)
|
||||
const second = adaptLibraryTracks(tracks).map((s) => s.title)
|
||||
expect(first).toEqual(['A', 'B', 'C'])
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('a track with no cover art maps with an absent coverUrl, not a broken-image URL', () => {
|
||||
const [song] = adaptLibraryTracks([track({})])
|
||||
expect(song!.coverUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a peer-sourced track carries a source entry distinguishing it from an own-library track (same three pinned literals)', () => {
|
||||
const [own] = adaptLibraryTracks([track({ id: { source: 'OwnLibrary', path: '/x' } })])
|
||||
const [peer] = adaptLibraryTracks([
|
||||
track({ id: { source: { Peer: { onion: 'abc123.onion' } }, path: '/var/lib/archipelago/purchased-content/abc123.onion/content-1' } }),
|
||||
])
|
||||
expect(own!.sources![0]!.type).toBe('funkwhale')
|
||||
expect(peer!.sources![0]!.type).toBe('plex')
|
||||
expect(own!.sources![0]!.type).not.toBe(peer!.sources![0]!.type)
|
||||
})
|
||||
|
||||
it('never produces a playback URL carrying a credential as a query parameter', () => {
|
||||
const songs = adaptLibraryTracks([
|
||||
track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/a.flac' } }),
|
||||
track({
|
||||
id: { source: { Peer: { onion: 'peer.onion' } }, path: '/var/lib/archipelago/purchased-content/peer.onion/content-9' },
|
||||
}),
|
||||
])
|
||||
for (const song of songs) {
|
||||
for (const source of song.sources ?? []) {
|
||||
expect(source.url).not.toMatch(/[?&](auth|token)=/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('an own-library track resolves through the existing FileBrowser raw-file route with no query string', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' } }),
|
||||
])
|
||||
expect(song!.sources![0]!.url).toBe('/app/filebrowser/api/raw/Music/Artist/Song.flac')
|
||||
})
|
||||
|
||||
it('a peer track resolves through the existing peer Range-streaming proxy', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({
|
||||
id: { source: { Peer: { onion: 'xyz.onion' } }, path: '/var/lib/archipelago/purchased-content/xyz.onion/content-42' },
|
||||
}),
|
||||
])
|
||||
expect(song!.sources![0]!.url).toBe('/api/peer-content/xyz.onion/content-42')
|
||||
})
|
||||
|
||||
it('an empty library produces an empty songs array, not undefined', () => {
|
||||
expect(adaptLibraryTracks([])).toEqual([])
|
||||
expect(adaptLibraryTracks(null)).toEqual([])
|
||||
expect(adaptLibraryTracks(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptLibraryAlbums', () => {
|
||||
it('groups tracks by (album_artist, album), preserving first-seen order', () => {
|
||||
const tracks = [
|
||||
track({ id: { source: 'OwnLibrary', path: '/1' }, title: 'T1', album: 'Beta', album_artist: 'X' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/2' }, title: 'T2', album: 'Alpha', album_artist: 'Y' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/3' }, title: 'T3', album: 'Beta', album_artist: 'X' }),
|
||||
]
|
||||
const albums = adaptLibraryAlbums(tracks)
|
||||
expect(albums.map((a) => a.album)).toEqual(['Beta', 'Alpha'])
|
||||
expect(albums[0]!.tracks.map((t) => t.title)).toEqual(['T1', 'T3'])
|
||||
expect(albums[1]!.tracks.map((t) => t.title)).toEqual(['T2'])
|
||||
})
|
||||
|
||||
it('a track with no album tag forms no album bucket', () => {
|
||||
const albums = adaptLibraryAlbums([track({ album: null })])
|
||||
expect(albums).toEqual([])
|
||||
})
|
||||
|
||||
it('is stable across repeat calls on the same input', () => {
|
||||
const tracks = [track({ id: { source: 'OwnLibrary', path: '/1' }, album: 'A' })]
|
||||
const first = adaptLibraryAlbums(tracks)
|
||||
const second = adaptLibraryAlbums(tracks)
|
||||
expect(first).toEqual(second)
|
||||
})
|
||||
})
|
||||
@@ -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,205 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCachedResource } from '../useCachedResource'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('useCachedResource', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not refetch on reactivation within the TTL, and refetches exactly once after the TTL lapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
|
||||
const fetcher = vi.fn().mockResolvedValue('v1')
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const resource = useCachedResource<string>({
|
||||
key: 'test.reactivation-key',
|
||||
fetcher,
|
||||
ttlMs: 1000,
|
||||
persist: false,
|
||||
})
|
||||
return () => h('div', resource.data.value ?? '')
|
||||
},
|
||||
})
|
||||
const Other = defineComponent({ render: () => h('div', 'other') })
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Host)
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate then reactivate inside the TTL — no additional fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate, advance past the TTL, reactivate — exactly one more fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('mounts and fetches without throwing outside any KeepAlive boundary', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('bare')
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const resource = useCachedResource<string>({ key: 'test.bare-key', fetcher, persist: false })
|
||||
return () => h('div', resource.data.value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Consumer)
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toBe('bare')
|
||||
})
|
||||
|
||||
it('keeps last-known data and sets error on a rejected refresh, moving loadState ready -> refreshing (not loading)', async () => {
|
||||
const first = deferred<string>()
|
||||
const fetcher = vi.fn().mockReturnValueOnce(first.promise)
|
||||
let resource: ReturnType<typeof useCachedResource<string>> | null = null
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
resource = useCachedResource<string>({ key: 'test.error-key', fetcher, ttlMs: 1000, persist: false })
|
||||
return () => h('div', resource!.data.value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
mount(Consumer)
|
||||
await Promise.resolve()
|
||||
first.resolve('v1')
|
||||
await flushPromises()
|
||||
|
||||
expect(resource!.data.value).toBe('v1')
|
||||
expect(resource!.loadState.value).toBe('ready')
|
||||
|
||||
const second = deferred<string>()
|
||||
fetcher.mockReturnValueOnce(second.promise)
|
||||
const refreshCall = resource!.refresh()
|
||||
await Promise.resolve()
|
||||
// Sticky-ready: a refresh on already-'ready' data moves to 'refreshing',
|
||||
// never back to 'loading' — content stays on screen while it runs.
|
||||
expect(resource!.loadState.value).toBe('refreshing')
|
||||
|
||||
second.reject(new Error('offline'))
|
||||
await refreshCall
|
||||
await flushPromises()
|
||||
|
||||
expect(resource!.data.value).toBe('v1') // keep-last-known-value
|
||||
expect(resource!.error.value).toBe('offline')
|
||||
})
|
||||
|
||||
// 02-04: found while auditing Cloud.vue/Server.vue's lazy (`immediate:
|
||||
// false`) resources ahead of adding their routes to KEEP_ALIVE_PATHS.
|
||||
// Without this guard, onActivated's refreshIfStale() would treat a
|
||||
// never-fetched entry as stale and eagerly fire the "fetch on first use"
|
||||
// resource the moment the tab is first activated, even though the caller
|
||||
// never explicitly requested it (e.g. a tab-gated Paid Files fetch that
|
||||
// should wait until that sub-tab is opened).
|
||||
it('does not eagerly fetch an immediate:false resource on activation before it has been explicitly requested, but does revalidate it once it has', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
|
||||
const fetcher = vi.fn().mockResolvedValue('lazy-v1')
|
||||
let resource: ReturnType<typeof useCachedResource<string>> | null = null
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
resource = useCachedResource<string>({
|
||||
key: 'test.lazy-key',
|
||||
fetcher,
|
||||
ttlMs: 1000,
|
||||
persist: false,
|
||||
immediate: false,
|
||||
})
|
||||
return () => h('div', resource!.data.value ?? '')
|
||||
},
|
||||
})
|
||||
const Other = defineComponent({ render: () => h('div', 'other') })
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Host)
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled() // immediate: false — not fetched on mount
|
||||
|
||||
// Deactivate then reactivate — still never explicitly requested, so
|
||||
// activation must not be the thing that fetches it.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
|
||||
// The caller explicitly requests it now (e.g. the user opened the tab).
|
||||
await resource!.refresh()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate within the TTL, reactivate — no additional fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate, advance past the TTL, reactivate — now it revalidates,
|
||||
// because it has been fetched before.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -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,76 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
|
||||
// The gate reads install state off the app store's package list. Stub the
|
||||
// store rather than the RPC layer so the test pins the decision, not the
|
||||
// transport.
|
||||
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return packages.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
packages.value = {}
|
||||
// Module-scope `show` is shared by design (one global modal), so reset it
|
||||
// between cases or the first opener leaks into the next test.
|
||||
useLightningRequired().close()
|
||||
})
|
||||
|
||||
it('lets the action through when a Lightning node is running', () => {
|
||||
packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('running')
|
||||
expect(lightning.requireLightningNode()).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when the node is present but NOT running, and says so', () => {
|
||||
// The bug this closes: `id in packages` is not "usable". A node with an
|
||||
// lnd entry in a non-running state produced a raw connection-refused
|
||||
// error ("Operation failed. Check server logs for details.").
|
||||
packages.value = { lnd: { state: 'stopped' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('stopped')
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('stopped')
|
||||
})
|
||||
|
||||
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
||||
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('absent')
|
||||
expect(lightning.hasLightningNode()).toBe(false)
|
||||
// Returns false so the caller bails WITHOUT surfacing an error string —
|
||||
// that was the whole defect: a missing prerequisite rendered as a failure.
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('absent')
|
||||
})
|
||||
|
||||
it('shares one modal state across call sites', () => {
|
||||
packages.value = {}
|
||||
const a = useLightningRequired()
|
||||
const b = useLightningRequired()
|
||||
|
||||
a.requireLightningNode()
|
||||
expect(b.show.value).toBe(true)
|
||||
b.close()
|
||||
expect(a.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty package list as absent', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
||||
})
|
||||
})
|
||||
@@ -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,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── Module boundary stubs (per plan: jsdom has no real blob decoding —
|
||||
// assert on what was requested and what was routed where, not byte content) ──
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const playMock = vi.fn()
|
||||
vi.mock('../useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: playMock }),
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { usePaidItemViewer, paidItemKey, type OwnedItemLike } from '../usePaidItemViewer'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
const IMAGE_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-1',
|
||||
filename: 'photos/sunset.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 2048,
|
||||
}
|
||||
const VIDEO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-2',
|
||||
filename: 'clips/holiday.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
size_bytes: 4096,
|
||||
}
|
||||
const AUDIO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-3',
|
||||
filename: 'music/track.mp3',
|
||||
mime_type: 'audio/mpeg',
|
||||
size_bytes: 1024,
|
||||
}
|
||||
const DOC_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-4',
|
||||
filename: 'docs/invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 512,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/error)', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let windowOpenSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
|
||||
revokeObjectURLSpy = vi.fn()
|
||||
URL.createObjectURL = createObjectURLSpy as unknown as typeof URL.createObjectURL
|
||||
URL.revokeObjectURL = revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
|
||||
windowOpenSpy = vi.fn()
|
||||
window.open = windowOpenSpy as unknown as typeof window.open
|
||||
// atob is provided by jsdom; stub it to avoid depending on real base64 semantics.
|
||||
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
|
||||
})
|
||||
|
||||
it('routes an image mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value).toHaveLength(1)
|
||||
expect(viewer.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('routes a video mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'video/mp4' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(VIDEO_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value[0]?.name).toBe('holiday.mp4')
|
||||
})
|
||||
|
||||
it('routes an audio mime to the audio player, never the lightbox', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'audio/mpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(AUDIO_ITEM)
|
||||
|
||||
expect(playMock).toHaveBeenCalledWith('blob:mock-url', 'track.mp3')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the browser tab for a mime with no in-app viewer', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'application/pdf' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(DOC_ITEM)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noopener')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
// Existing revoke timer for the browser-tab path is untouched.
|
||||
vi.advanceTimersByTime(60000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('the synthetic lightbox item name carries the real extension', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(viewer.lightboxItems.value[0]?.name.endsWith('.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets opening for the whole duration of the fetch and clears it on success', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears opening and does not derive it from a background-refresh flag — it is driven only by the fetch in flight', async () => {
|
||||
// No cached-resource / refreshing concept is wired into this composable at
|
||||
// all: opening only ever reflects the current open() call's own RPC.
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
expect(viewer.opening.value).toBeNull() // idle before any open()
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
expect(viewer.opening.value).toBeNull() // back to idle the instant the fetch settles — no lingering "refreshing" state
|
||||
})
|
||||
|
||||
it('surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Request timeout'))
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await expect(viewer.open(IMAGE_ITEM)).resolves.toBeUndefined()
|
||||
|
||||
expect(viewer.error.value).toBeTruthy()
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
})
|
||||
|
||||
it('issues exactly one RPC when open() is called twice in quick succession for the same item', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p1 = viewer.open(IMAGE_ITEM)
|
||||
const p2 = viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { usePipSession } from '../usePipSession'
|
||||
import { isPipSupported } from '../../utils/pip'
|
||||
|
||||
// jsdom has no picture-in-picture implementation at all, so these three
|
||||
// document/element members don't exist until stubbed — matching the plan's
|
||||
// note that `pipSupported` (module-level) can't be restubbed after import,
|
||||
// which is exactly why `isPipSupported()` exists as a call-time check.
|
||||
function definePipStub(enabled: boolean) {
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: enabled,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
definePipStub(true)
|
||||
if (!('pictureInPictureElement' in document)) {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
|
||||
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
|
||||
return null as unknown as PictureInPictureWindow
|
||||
}
|
||||
}
|
||||
if (!document.exitPictureInPicture) {
|
||||
document.exitPictureInPicture = async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
function hostEl(): HTMLElement | null {
|
||||
return document.querySelector('[data-pip-session-host]')
|
||||
}
|
||||
|
||||
describe('usePipSession', () => {
|
||||
afterEach(() => {
|
||||
usePipSession().release()
|
||||
})
|
||||
|
||||
it('adopts a video into a body-level host that survives the owner unmounting', () => {
|
||||
const Owner = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', [h('video')])
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Owner, { attachTo: document.body })
|
||||
const video = wrapper.find('video').element as HTMLVideoElement
|
||||
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
expect(session.active.value).toBe(true)
|
||||
expect(session.element.value).toBe(video)
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
// The owning component is gone; the video must still be connected.
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
})
|
||||
|
||||
it('release() removes the adopted element and leaves the host empty', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
|
||||
session.release()
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
expect(hostEl()?.contains(video)).toBe(false)
|
||||
expect(hostEl()?.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('creates exactly one host node no matter how many times the composable is called', () => {
|
||||
const video = document.createElement('video')
|
||||
usePipSession().adopt(video)
|
||||
usePipSession()
|
||||
usePipSession()
|
||||
expect(document.querySelectorAll('[data-pip-session-host]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('a leavepictureinpicture event on the adopted element releases the session', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
})
|
||||
|
||||
it('adopting a second element while one is active releases the first rather than leaking it', () => {
|
||||
const first = document.createElement('video')
|
||||
const second = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
|
||||
session.adopt(first)
|
||||
session.adopt(second)
|
||||
|
||||
expect(session.element.value).toBe(second)
|
||||
expect(hostEl()?.contains(first)).toBe(false)
|
||||
expect(hostEl()?.contains(second)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPipSupported', () => {
|
||||
it('reads document state at call time, not at import time', () => {
|
||||
definePipStub(false)
|
||||
expect(isPipSupported()).toBe(false)
|
||||
|
||||
definePipStub(true)
|
||||
expect(isPipSupported()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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,86 @@
|
||||
// Regression suite for the recurring "tx link opens tx1138.com instead of
|
||||
// the local Mempool app" bug (reported again on .228, 2026-08-06).
|
||||
//
|
||||
// The root cause was never the explorer preference — it was that
|
||||
// `getAppState` reports `not-installed` for an app whose container list has
|
||||
// not been fetched yet. A click that landed before the list arrived sent
|
||||
// the user to a third-party explorer, telling that operator which
|
||||
// transaction they cared about. These tests pin the fix: the decision waits
|
||||
// for real data, and the local app wins whenever it exists.
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
const openSession = vi.fn()
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ openSession }),
|
||||
}))
|
||||
|
||||
let containerState: string
|
||||
let fetched: boolean
|
||||
const ensureFetched = vi.fn(async () => {
|
||||
// Mirrors the real store: state only becomes knowable after the fetch.
|
||||
fetched = true
|
||||
})
|
||||
vi.mock('@/stores/container', () => ({
|
||||
useContainerStore: () => ({
|
||||
ensureFetched,
|
||||
getAppState: (_id: string) => (fetched ? containerState : 'not-installed'),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useTxExplorer, DEFAULT_TX_EXPLORER } from '../useTxExplorer'
|
||||
|
||||
const TX = 'a'.repeat(64)
|
||||
|
||||
describe('useTxExplorer.openTx', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
fetched = false
|
||||
containerState = 'running'
|
||||
// Reset module-scope prefs/pending between tests.
|
||||
const { setExplorer, cancelPending } = useTxExplorer()
|
||||
setExplorer(DEFAULT_TX_EXPLORER, false)
|
||||
cancelPending()
|
||||
})
|
||||
|
||||
it('opens the local Mempool app when it is running', async () => {
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(pendingTx.value).toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the container list rather than assuming not-installed (the race)', async () => {
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
// fetched=false at click time — the old synchronous check read
|
||||
// 'not-installed' here and went external.
|
||||
await openTx(TX)
|
||||
expect(ensureFetched).toHaveBeenCalled()
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(pendingTx.value).toBeNull()
|
||||
})
|
||||
|
||||
it('still prefers the local app when it is installed but stopped', async () => {
|
||||
containerState = 'stopped'
|
||||
const { openTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('prefers the local app mid-restart rather than leaking to a third party', async () => {
|
||||
containerState = 'restarting'
|
||||
const { openTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
||||
containerState = 'not-installed'
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).not.toHaveBeenCalled()
|
||||
expect(pendingTx.value).toBe(TX)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref, Teleport } from 'vue'
|
||||
import { useViewActive } from '../useViewActive'
|
||||
|
||||
/**
|
||||
* Teleported chrome must not outlive the screen that raised it.
|
||||
*
|
||||
* Main tabs are KeepAlive'd, so navigating away deactivates a view instead of
|
||||
* unmounting it. Anything Teleported to <body> is outside the view's subtree
|
||||
* and therefore survives that deactivation — Mesh's mobile tab bar and the
|
||||
* shared BackButton stayed pinned above the bottom bar on every other screen.
|
||||
*/
|
||||
const ViewWithTeleportedChrome = defineComponent({
|
||||
name: 'ViewWithTeleportedChrome',
|
||||
setup() {
|
||||
const isViewActive = useViewActive()
|
||||
return () =>
|
||||
h('div', [
|
||||
isViewActive.value
|
||||
? h(Teleport, { to: 'body' }, [h('button', { class: 'leaky-chrome' }, 'Back')])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const Other = defineComponent({ name: 'Other', setup: () => () => h('div', 'other screen') })
|
||||
|
||||
function chromeCount() {
|
||||
return document.body.querySelectorAll('.leaky-chrome').length
|
||||
}
|
||||
|
||||
describe('useViewActive', () => {
|
||||
it('removes teleported chrome when the view is deactivated, and restores it on return', async () => {
|
||||
const showFirst = ref(true)
|
||||
const host = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(KeepAlive, null, {
|
||||
default: () => (showFirst.value ? h(ViewWithTeleportedChrome) : h(Other)),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
)
|
||||
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
// Navigate away: KeepAlive DEACTIVATES rather than unmounts.
|
||||
showFirst.value = false
|
||||
await host.vm.$nextTick()
|
||||
expect(chromeCount()).toBe(0)
|
||||
|
||||
// Returning must bring it back — the whole point of KeepAlive is that the
|
||||
// instance survived, so the chrome has to come back with it.
|
||||
showFirst.value = true
|
||||
await host.vm.$nextTick()
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
host.unmount()
|
||||
})
|
||||
|
||||
it('keeps the instance alive across the round trip (performance is not sacrificed)', async () => {
|
||||
const showFirst = ref(true)
|
||||
const seen: number[] = []
|
||||
const Counting = defineComponent({
|
||||
name: 'Counting',
|
||||
setup() {
|
||||
const isViewActive = useViewActive()
|
||||
const uid = Math.random()
|
||||
seen.push(uid)
|
||||
return () => h('div', [isViewActive.value ? h(Teleport, { to: 'body' }, [h('i', { class: 'leaky-chrome' })]) : null])
|
||||
},
|
||||
})
|
||||
|
||||
const host = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(KeepAlive, null, { default: () => (showFirst.value ? h(Counting) : h(Other)) }),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
)
|
||||
|
||||
showFirst.value = false
|
||||
await host.vm.$nextTick()
|
||||
showFirst.value = true
|
||||
await host.vm.$nextTick()
|
||||
|
||||
// setup() ran once: the view was cached, not re-created. If this ever
|
||||
// becomes 2, the fix has been "solved" by throwing away the perf work.
|
||||
expect(seen.length).toBe(1)
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
host.unmount()
|
||||
})
|
||||
|
||||
it('defaults to active outside a KeepAlive boundary', () => {
|
||||
// Neither hook fires here. A component used both ways — or mounted bare in
|
||||
// a test — must render normally rather than stay invisible forever.
|
||||
const host = mount(ViewWithTeleportedChrome, { attachTo: document.body })
|
||||
expect(chromeCount()).toBe(1)
|
||||
host.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Archy content adapter — maps `core/archipelago/src/content_server.rs`'s
|
||||
* `ContentItem` (peer files, this node's own shared files, IndeeHub movies,
|
||||
* paid/owned purchases) onto AIUI's `Film`/`Song`/`Podcast` shapes so its
|
||||
* existing `FilmGrid`/`SongGrid`/`NewsGrid` components can render real node
|
||||
* data instead of records regex-scraped out of the model's own reply text
|
||||
* (D-12, 13-CONTEXT.md).
|
||||
*
|
||||
* RESEARCH Pitfall 4: `ContentItem` (`id`, `filename`, `mime_type`,
|
||||
* `size_bytes`, `description`, `access`, `availability`, `added_at`) has NO
|
||||
* shape overlap with `Film`/`Song`/`Podcast` (`posterUrl`, `coverUrl`,
|
||||
* `sources[]`, `genres`, `runtime`, `director`, ...). This is a hand-written
|
||||
* adapter, not a pass-through — every field below is a deliberate mapping
|
||||
* decision, pinned by `__tests__/archyContentAdapter.test.ts`.
|
||||
*
|
||||
* neode-ui does not depend on `@aiui/core` (D-19 keeps the two packages
|
||||
* decoupled even though they now live in one repo), so the target shapes are
|
||||
* declared locally here rather than imported. They are kept structurally
|
||||
* identical to `aiui/packages/core/src/types/content.ts`'s `Film`/`Song`/
|
||||
* `Podcast`, plus a small Archipelago-only extension (`locked`/`priceSats`)
|
||||
* that AIUI's grids simply ignore today (D-14's paid-unlock state) — the
|
||||
* "shape pinning" test below is the regression pin against silent drift.
|
||||
*/
|
||||
|
||||
// ─── Target shapes (structurally match aiui/packages/core/src/types/content.ts) ───
|
||||
|
||||
export interface FilmSource {
|
||||
type: 'plex' | 'nextcloud' | 'youtube' | 'free-web' | 'indeehub'
|
||||
name: string
|
||||
url: string
|
||||
quality?: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Film {
|
||||
id: string
|
||||
title: string
|
||||
year: number
|
||||
posterUrl: string
|
||||
backdropUrl?: string
|
||||
synopsis: string
|
||||
genres: string[]
|
||||
rating: number
|
||||
runtime: number
|
||||
director: string
|
||||
cast: string[]
|
||||
trailerUrl?: string
|
||||
sources: FilmSource[]
|
||||
/** Archipelago extension (not part of AIUI's `Film` type): set when this
|
||||
* item is `access: 'Paid'` and not yet unlocked. AIUI's `FilmGrid` reads
|
||||
* only the fields above and ignores unknown ones, so this is additive. */
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
export interface SongSource {
|
||||
type:
|
||||
| 'plex'
|
||||
| 'spotify'
|
||||
| 'youtube'
|
||||
| 'apple-music'
|
||||
| 'bandcamp'
|
||||
| 'soundcloud'
|
||||
| 'wavlake'
|
||||
| 'internet_archive'
|
||||
| 'jamendo'
|
||||
| 'odysee'
|
||||
| 'funkwhale'
|
||||
name: string
|
||||
url: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album?: string
|
||||
year?: number
|
||||
coverUrl?: string
|
||||
duration?: number
|
||||
genres?: string[]
|
||||
sources?: SongSource[]
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
export interface PodcastSource {
|
||||
type: 'fountain' | 'rumble' | 'youtube' | 'podcastindex' | 'castopod' | 'odysee' | 'podverse' | 'ipfs' | 'rss'
|
||||
name: string
|
||||
url: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface Podcast {
|
||||
id: string
|
||||
title: string
|
||||
host?: string
|
||||
description?: string
|
||||
coverUrl?: string
|
||||
year?: number
|
||||
episodeCount?: number
|
||||
genres?: string[]
|
||||
sources: PodcastSource[]
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
/** Structurally matches `aiui/packages/core/src/types/content.ts`'s
|
||||
* `ImageItem` — declared locally for the same D-19 reason as the shapes
|
||||
* above (neode-ui does not depend on `@aiui/core`). */
|
||||
export interface ImageItem {
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
source?: string
|
||||
attribution?: string
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
// ─── Source shape: content_server.rs's ContentItem, as seen over RPC ───
|
||||
|
||||
/** `AccessControl` (`core/archipelago/src/content_server.rs`) serializes via
|
||||
* serde's default externally-tagged representation with `rename_all =
|
||||
* "lowercase"`: unit variants become bare strings, the struct variant
|
||||
* becomes `{ paid: { price_sats, accepted } }`. */
|
||||
export type ArchyAccessControl =
|
||||
| 'free'
|
||||
| 'peersonly'
|
||||
| { paid: { price_sats: number; accepted?: string[] } }
|
||||
|
||||
/** `Availability`, same serialization convention. Not consumed by the
|
||||
* adapter's mapping logic today (RPC scope already decides what's fetched);
|
||||
* kept on the type for fixture fidelity and future use. */
|
||||
export type ArchyAvailability = 'nobody' | 'allpeers' | { specific: { peers: string[] } }
|
||||
|
||||
/** The wire shape of `content_server::ContentItem`, mirrored field-for-field. */
|
||||
export interface ArchyContentItem {
|
||||
id: string
|
||||
filename: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
description?: string | null
|
||||
access?: ArchyAccessControl
|
||||
availability?: ArchyAvailability
|
||||
added_at?: string | null
|
||||
}
|
||||
|
||||
export interface ArchyContentBundle {
|
||||
films: Film[]
|
||||
songs: Song[]
|
||||
podcasts: Podcast[]
|
||||
/** Shared photos. Images were previously classified 'excluded' and
|
||||
* dropped on the floor, so a node sharing mostly photos rendered as an
|
||||
* empty grid — the single biggest gap between what the assistant could
|
||||
* DESCRIBE and what the surface could SHOW. AIUI has had an image grid
|
||||
* (`panelImages`/`ImageGrid`) the whole time; nothing fed it. */
|
||||
images: ImageItem[]
|
||||
}
|
||||
|
||||
export interface AdaptContentOptions {
|
||||
/** Where this batch of items came from — decides the `sources[]` badge
|
||||
* and how a playable URL is built. Not a per-item field: a whole RPC
|
||||
* response (one node's catalog, one peer's catalog, or IndeeHub) shares
|
||||
* one source. */
|
||||
source: 'own' | 'peer' | 'indeehub'
|
||||
/** Required when `source === 'peer'` — the peer's onion address, needed
|
||||
* to build the Range-streaming proxy URL. */
|
||||
peerOnion?: string
|
||||
}
|
||||
|
||||
// ─── Classification ───────────────────────────────────────────────────────
|
||||
|
||||
type ContentBucket = 'video' | 'audio' | 'image' | 'excluded'
|
||||
|
||||
// `m4a`, `aac`, `opus` and `wma` classify as audio via this extension
|
||||
// fallback — `ShareModal.vue`'s mime map omits exactly these four today, so
|
||||
// files shared with those extensions arrive here as generic
|
||||
// `application/octet-stream` (or another wrong mime) rather than `audio/*`.
|
||||
// Without the fallback they would be silently mis-typed as `excluded`
|
||||
// instead of routing to the Songs bucket. 13-11 fixes the share side; this
|
||||
// adapter must not inherit the same blind spot in the meantime.
|
||||
const AUDIO_EXT_FALLBACK = new Set([
|
||||
'm4a',
|
||||
'aac',
|
||||
'opus',
|
||||
'wma',
|
||||
'mp3',
|
||||
'flac',
|
||||
'wav',
|
||||
'ogg',
|
||||
])
|
||||
|
||||
const VIDEO_EXT_FALLBACK = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v'])
|
||||
|
||||
// Same reasoning as the audio fallback above: a photo shared with a mime
|
||||
// this node could not identify still has an unambiguous extension, and a
|
||||
// node whose catalog is mostly photos should not present as empty.
|
||||
const IMAGE_EXT_FALLBACK = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'bmp'])
|
||||
|
||||
function extensionOf(filename: string): string {
|
||||
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
|
||||
const idx = base.lastIndexOf('.')
|
||||
return idx > 0 ? base.slice(idx + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
/** Strip the extension from a filename to derive a display title. Directory
|
||||
* separators are stripped first so a full relative path collapses to a
|
||||
* bare filename-derived title. */
|
||||
function stripExtension(filename: string): string {
|
||||
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
|
||||
const idx = base.lastIndexOf('.')
|
||||
return idx > 0 ? base.slice(0, idx) : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which grid bucket a `ContentItem` belongs to. Video and audio mimes
|
||||
* (and, as a fallback for a wrong/generic mime, video and audio extensions)
|
||||
* route to Film/Song respectively; everything else (image, document, or
|
||||
* anything unrecognized) is excluded from all three buckets rather than
|
||||
* mis-typed into one. `ContentItem` carries no podcast-specific signal
|
||||
* (no episode/feed metadata), so nothing classifies as `podcast` here —
|
||||
* `adaptToPodcast` exists for shape completeness and future reuse (e.g. an
|
||||
* RSS/podcast-feed source) but `adaptContentItems` never calls it today.
|
||||
*/
|
||||
export function classifyByMime(item: Pick<ArchyContentItem, 'mime_type' | 'filename'>): ContentBucket {
|
||||
const mime = (item.mime_type || '').toLowerCase().trim()
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
|
||||
const ext = extensionOf(item.filename || '')
|
||||
if (AUDIO_EXT_FALLBACK.has(ext)) return 'audio'
|
||||
if (VIDEO_EXT_FALLBACK.has(ext)) return 'video'
|
||||
if (IMAGE_EXT_FALLBACK.has(ext)) return 'image'
|
||||
return 'excluded'
|
||||
}
|
||||
|
||||
// ─── Access / paid-lock helpers ────────────────────────────────────────────
|
||||
|
||||
function paidPriceSats(access: ArchyAccessControl | undefined): number | null {
|
||||
if (access && typeof access === 'object' && 'paid' in access) {
|
||||
return access.paid.price_sats
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Source badge + URL building ───────────────────────────────────────────
|
||||
|
||||
// FilmSource's type union has no literal that means "this node" or "a peer"
|
||||
// by name — these are infrastructure-flavored badges borrowed from AIUI's
|
||||
// existing (unmodified, D-12) vocabulary. 'nextcloud' (self-hosted file
|
||||
// storage) stands in for this node's own catalog; 'plex' (a media server)
|
||||
// stands in for a peer's shared catalog; 'indeehub' is IndeeHub's own
|
||||
// literal. SongSource's union has no 'nextcloud' entry at all, so the
|
||||
// self-hosted analogue there is 'funkwhale' (a self-hosted, federated audio
|
||||
// server) — the closest existing badge to "this node". These three literal
|
||||
// values are pinned by the adapter's tests so a later refactor cannot
|
||||
// quietly change what a grid badge means (13-06-PLAN.md Task 1).
|
||||
const FILM_SOURCE_TYPE: Record<AdaptContentOptions['source'], FilmSource['type']> = {
|
||||
own: 'nextcloud',
|
||||
peer: 'plex',
|
||||
indeehub: 'indeehub',
|
||||
}
|
||||
|
||||
const SONG_SOURCE_TYPE: Record<AdaptContentOptions['source'], SongSource['type']> = {
|
||||
own: 'funkwhale',
|
||||
peer: 'plex',
|
||||
// IndeeHub carries no audio catalog in this plan's scope — audio arriving
|
||||
// tagged 'indeehub' is not an expected path, so this falls back to the
|
||||
// generic peer-media badge rather than an invalid literal.
|
||||
indeehub: 'plex',
|
||||
}
|
||||
|
||||
const PODCAST_SOURCE_TYPE: Record<AdaptContentOptions['source'], PodcastSource['type']> = {
|
||||
own: 'rss',
|
||||
peer: 'rss',
|
||||
indeehub: 'rss',
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<AdaptContentOptions['source'], string> = {
|
||||
own: 'This node',
|
||||
peer: 'Peer',
|
||||
indeehub: 'IndeeHub',
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a playable media URL for an unlocked item. **Never** builds a URL
|
||||
* containing a credential in its query string (T-13-32): own-node media
|
||||
* resolves through the existing content endpoint (`/content/<id>`, which is
|
||||
* itself unauthenticated by design — content_server access control is
|
||||
* per-item, not per-session), and peer media through the existing Rust
|
||||
* Range-streaming proxy (`/api/peer-content/<onion>/<id>`, which rides the
|
||||
* page's own session cookie automatically as a same-origin request). Both
|
||||
* already exist; this function names them, it does not mint anything new.
|
||||
*/
|
||||
function buildMediaUrl(item: ArchyContentItem, opts: AdaptContentOptions): string {
|
||||
if (opts.source === 'peer') {
|
||||
if (!opts.peerOnion) return ''
|
||||
return `/api/peer-content/${encodeURIComponent(opts.peerOnion)}/${encodeURIComponent(item.id)}`
|
||||
}
|
||||
// 'own' and 'indeehub' items both live in this node's own catalog once
|
||||
// added (IndeeHub ingestion still lands an entry in the same catalog —
|
||||
// D-14 routes it through the existing content subsystem, not a new one).
|
||||
return `/content/${encodeURIComponent(item.id)}`
|
||||
}
|
||||
|
||||
// ─── Per-type mapping ───────────────────────────────────────────────────────
|
||||
|
||||
export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions): Film {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const sourceType = FILM_SOURCE_TYPE[opts.source]
|
||||
return {
|
||||
id: item.id,
|
||||
title: stripExtension(item.filename || ''),
|
||||
year: 0,
|
||||
posterUrl: '',
|
||||
synopsis: item.description ?? '',
|
||||
genres: [],
|
||||
rating: 0,
|
||||
runtime: 0,
|
||||
director: '',
|
||||
cast: [],
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: SOURCE_LABEL[opts.source],
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a shared photo onto AIUI's `ImageItem`. A locked (paid, not yet
|
||||
* bought) image gets an EMPTY `url` for the same reason a locked film
|
||||
* does: the card should render its price and lock, not silently fetch
|
||||
* bytes the operator has not paid for.
|
||||
*/
|
||||
export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions): ImageItem {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const title = stripExtension(item.filename || '')
|
||||
return {
|
||||
id: item.id,
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
title,
|
||||
description: item.description ?? '',
|
||||
// `alt` falls back to the title rather than being left empty — a photo
|
||||
// grid with no alt text is unreadable to a screen reader.
|
||||
alt: title,
|
||||
source: SOURCE_LABEL[opts.source],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): Song {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const sourceType = SONG_SOURCE_TYPE[opts.source]
|
||||
return {
|
||||
id: item.id,
|
||||
title: stripExtension(item.filename || ''),
|
||||
artist: '',
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: SOURCE_LABEL[opts.source],
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for shape completeness and future reuse (see `classifyByMime`'s
|
||||
* doc comment) — not called by `adaptContentItems` today, since
|
||||
* `ContentItem` carries no signal that would classify an item as a podcast
|
||||
* rather than a song. */
|
||||
export function adaptToPodcast(item: ArchyContentItem, opts: AdaptContentOptions): Podcast {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const sourceType = PODCAST_SOURCE_TYPE[opts.source]
|
||||
return {
|
||||
id: item.id,
|
||||
title: stripExtension(item.filename || ''),
|
||||
description: item.description ?? '',
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: SOURCE_LABEL[opts.source],
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deterministic ordering ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sort by `added_at` descending, `id` ascending as the tiebreak. A missing
|
||||
* `added_at` sorts as the oldest possible value rather than throwing or
|
||||
* sorting first. Calling this twice on the same input in a different array
|
||||
* order yields identical output order — the property the AIUI-03 "ordering"
|
||||
* edge requires.
|
||||
*/
|
||||
export function sortDeterministic(items: ArchyContentItem[]): ArchyContentItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const at = a.added_at ?? ''
|
||||
const bt = b.added_at ?? ''
|
||||
if (at !== bt) return at > bt ? -1 : 1
|
||||
if (a.id === b.id) return 0
|
||||
return a.id < b.id ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a batch of `ContentItem`s (all from the same source — this node, one
|
||||
* peer, or IndeeHub) into grid-ready `Film`/`Song`/`Podcast` records.
|
||||
*
|
||||
* - An empty input produces `{ films: [], songs: [], podcasts: [] }` — never
|
||||
* `undefined`, never a thrown error.
|
||||
* - Two items with identical `filename`/`size_bytes` but different `id`
|
||||
* produce two distinct cards — cards key on `id`, never on filename+size
|
||||
* (the AIUI-03 "adjacency" edge; also T-13-37).
|
||||
* - Output ordering is deterministic (see `sortDeterministic`).
|
||||
*/
|
||||
export function adaptContentItems(
|
||||
items: ArchyContentItem[] | null | undefined,
|
||||
opts: AdaptContentOptions,
|
||||
): ArchyContentBundle {
|
||||
const sorted = sortDeterministic(items ?? [])
|
||||
const films: Film[] = []
|
||||
const songs: Song[] = []
|
||||
const podcasts: Podcast[] = []
|
||||
const images: ImageItem[] = []
|
||||
|
||||
for (const item of sorted) {
|
||||
const bucket = classifyByMime(item)
|
||||
if (bucket === 'video') films.push(adaptToFilm(item, opts))
|
||||
else if (bucket === 'audio') songs.push(adaptToSong(item, opts))
|
||||
else if (bucket === 'image') images.push(adaptToImage(item, opts))
|
||||
// 'excluded' (documents/archives/other) — no grid renders these, so
|
||||
// they stay out of every bucket rather than being mistyped into one.
|
||||
}
|
||||
|
||||
return { films, songs, podcasts, images }
|
||||
}
|
||||
|
||||
// ─── Library mapping (13-11) ────────────────────────────────────────────
|
||||
//
|
||||
// `music.list-tracks` (13-07, `core/archipelago/src/api/rpc/music.rs`)
|
||||
// returns real tag-extracted metadata (title/artist/album/duration) for
|
||||
// this node's indexed library — a materially different, richer input than
|
||||
// `ContentItem` above (which carries no tag data at all; `adaptToSong`
|
||||
// above always sets `artist: ''`). `adaptLibraryTracks` is a sibling
|
||||
// mapping, not a replacement: it feeds the same `songs` bucket of the
|
||||
// `ArchyContentBundle`/`content:push` shape with real metadata instead of
|
||||
// filename-derived guesses.
|
||||
|
||||
/** `MusicSource` (`core/archipelago/src/music/mod.rs`), as seen over RPC.
|
||||
* Serde's default externally-tagged representation: the unit variant
|
||||
* `OwnLibrary` becomes the bare string `"OwnLibrary"`; the struct variant
|
||||
* `Peer { onion }` becomes `{ "Peer": { "onion": string } }`. */
|
||||
export type ArchyMusicSource = 'OwnLibrary' | { Peer: { onion: string } }
|
||||
|
||||
/** The wire shape of `core/archipelago/src/music/mod.rs`'s `Track`, as
|
||||
* returned by `music.list-tracks` — field names match the Rust struct
|
||||
* verbatim (no serde rename). */
|
||||
export interface ArchyLibraryTrack {
|
||||
id: { source: ArchyMusicSource; path: string }
|
||||
title: string
|
||||
artist?: string | null
|
||||
album?: string | null
|
||||
album_artist?: string | null
|
||||
track_number?: number | null
|
||||
disc_number?: number | null
|
||||
year?: number | null
|
||||
duration_secs: number
|
||||
has_tags: boolean
|
||||
content_hash?: string | null
|
||||
}
|
||||
|
||||
/** A library track grouped under its album — exported for shape
|
||||
* completeness and future reuse (`adaptLibraryAlbums`'s doc comment),
|
||||
* mirroring `adaptToPodcast`'s status in this same file: not consumed by
|
||||
* this plan's own wiring (`SongGrid` renders a flat track list), but a
|
||||
* real, tested mapping a future album-detail view can reuse without
|
||||
* re-deriving the grouping. */
|
||||
export interface ArchyLibraryAlbum {
|
||||
album: string
|
||||
albumArtist: string
|
||||
tracks: Song[]
|
||||
}
|
||||
|
||||
function isPeerMusicSource(source: ArchyMusicSource): source is { Peer: { onion: string } } {
|
||||
return typeof source === 'object' && source !== null && 'Peer' in source
|
||||
}
|
||||
|
||||
/** Build a playable URL for a library track. **Never** builds a URL
|
||||
* carrying a credential in its query string (T-13-71, same rule as
|
||||
* `buildMediaUrl` above).
|
||||
*
|
||||
* An `OwnLibrary` track's `path` is an absolute, canonicalized filesystem
|
||||
* path rooted at `media_roots()`'s first entry
|
||||
* (`data_dir/filebrowser/Music`, 13-04/13-07) — everything after the
|
||||
* `/filebrowser/` path segment is the exact same FileBrowser-relative path
|
||||
* `filebrowser-client.ts`'s `streamUrl` already serves via
|
||||
* `/app/filebrowser/api/raw<path>` (the T-13-39 fix, 13-06), so this reuses
|
||||
* that existing route rather than minting a new one.
|
||||
*
|
||||
* A `Peer` track's `path` is the local byte-cache layout
|
||||
* `<data_dir>/purchased-content/<onion>/<content_id>` (13-07's second media
|
||||
* root) and resolves through the existing peer Range-streaming proxy —
|
||||
* exactly `buildMediaUrl`'s peer branch above, just deriving `onion` from
|
||||
* `MusicSource::Peer` instead of an adapter option and `content_id` from
|
||||
* the path's own basename. */
|
||||
function buildLibraryTrackUrl(track: ArchyLibraryTrack): string {
|
||||
if (isPeerMusicSource(track.id.source)) {
|
||||
const onion = track.id.source.Peer.onion
|
||||
const segments = track.id.path.split('/').filter(Boolean)
|
||||
const contentId = segments[segments.length - 1]
|
||||
if (!onion || !contentId) return ''
|
||||
return `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(contentId)}`
|
||||
}
|
||||
const marker = '/filebrowser/'
|
||||
const idx = track.id.path.indexOf(marker)
|
||||
if (idx === -1) return ''
|
||||
const relative = track.id.path.slice(idx + marker.length)
|
||||
if (!relative) return ''
|
||||
const encoded = relative
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join('/')
|
||||
return `/app/filebrowser/api/raw/${encoded}`
|
||||
}
|
||||
|
||||
function librarySourceKey(source: ArchyMusicSource): string {
|
||||
return isPeerMusicSource(source) ? `peer:${source.Peer.onion}` : 'own'
|
||||
}
|
||||
|
||||
/** Stable per-track id: `TrackId` (`{ source, path }`) has no single string
|
||||
* identity on the wire, so one is derived here deterministically from the
|
||||
* same two fields — the same input always produces the same id. */
|
||||
function libraryTrackId(track: ArchyLibraryTrack): string {
|
||||
return `${librarySourceKey(track.id.source)}:${track.id.path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `music.list-tracks` records onto AIUI's `Song` shape.
|
||||
*
|
||||
* - Title/artist/album/duration are carried through from the extracted
|
||||
* tags (`title`, `artist`, `album`, `duration_secs` → `duration`).
|
||||
* - A track whose `artist` tag is absent falls back to `album_artist`, and
|
||||
* to `''` if that is absent too — never the literal `null`/`undefined`.
|
||||
* - Ordering is **not** recomputed here: `music.list-tracks`'s own
|
||||
* response is already deterministically ordered
|
||||
* `(disc, track number, title)` with `(source, path)` as the final
|
||||
* tiebreak (13-07) — re-sorting by a different key in the browser would
|
||||
* make the grid and the RPC disagree about what "first" means, so this
|
||||
* is a straight, order-preserving map.
|
||||
* - No cover art is ever set (`coverUrl` stays `undefined`): `Track`
|
||||
* carries no artwork field at all, and AIUI's own artwork sources are
|
||||
* dev-server-only Vite middleware, 404 on a node (13-CONTEXT.md
|
||||
* landmine) — `SongGrid`'s existing no-artwork fallback renders instead
|
||||
* of a broken image, unchanged.
|
||||
* - A peer-sourced track's `sources[0].type` differs from an own-library
|
||||
* track's, using the same `'funkwhale'`/`'plex'` literals `SONG_SOURCE_TYPE`
|
||||
* already pins above (13-06).
|
||||
* - No produced URL ever carries a credential in its query string.
|
||||
* - `null`/`undefined`/empty input produces `[]`, never `undefined`.
|
||||
*/
|
||||
export function adaptLibraryTracks(tracks: ArchyLibraryTrack[] | null | undefined): Song[] {
|
||||
return (tracks ?? []).map((track) => {
|
||||
const peer = isPeerMusicSource(track.id.source)
|
||||
const sourceType: SongSource['type'] = peer ? 'plex' : 'funkwhale'
|
||||
const artist = track.artist ?? track.album_artist ?? ''
|
||||
return {
|
||||
id: libraryTrackId(track),
|
||||
title: track.title,
|
||||
artist,
|
||||
album: track.album ?? undefined,
|
||||
year: track.year ?? undefined,
|
||||
duration: track.duration_secs,
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: peer ? 'Peer' : 'This node',
|
||||
url: buildLibraryTrackUrl(track),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Group `music.list-tracks` records into albums, keyed on
|
||||
* `(album_artist, album)` — the same derived-albums grouping
|
||||
* `13-MUSIC-MODEL.md` defines server-side for `music.list-albums`, computed
|
||||
* here over already-adapted `Song`s so a future album-detail view can reuse
|
||||
* it without a second RPC round trip. A track with no `album` tag forms no
|
||||
* album bucket (nothing to group it under) but is still present in
|
||||
* `adaptLibraryTracks`'s flat output. Grouping preserves the input's own
|
||||
* order — first-seen album first, tracks in the order they appear — so
|
||||
* calling this twice on the same input array yields identical output order.
|
||||
*/
|
||||
export function adaptLibraryAlbums(tracks: ArchyLibraryTrack[] | null | undefined): ArchyLibraryAlbum[] {
|
||||
const list = tracks ?? []
|
||||
const songs = adaptLibraryTracks(list)
|
||||
const albums: ArchyLibraryAlbum[] = []
|
||||
const index = new Map<string, ArchyLibraryAlbum>()
|
||||
|
||||
list.forEach((track, i) => {
|
||||
const album = track.album ?? ''
|
||||
if (!album) return
|
||||
const albumArtist = track.album_artist ?? track.artist ?? ''
|
||||
const key = `${albumArtist}::${album}`
|
||||
let bucket = index.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { album, albumArtist, tracks: [] }
|
||||
index.set(key, bucket)
|
||||
albums.push(bucket)
|
||||
}
|
||||
bucket.tracks.push(songs[i]!)
|
||||
})
|
||||
|
||||
return albums
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user