Archipelago — open-source initial import
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user