Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 07c37b83e8
1578 changed files with 333070 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<template>
<div
class="flex-shrink-0 inline-block overflow-hidden"
:class="[
sizeClass,
!noBorder && 'logo-gradient-border'
]"
>
<!-- Neode logo - always white -->
<svg
class="block w-full h-full logo-svg"
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Neode"
>
<rect width="1024" height="1024" fill="#030202" />
<rect
v-for="(r, i) in rects"
:key="i"
:x="r.x"
:y="r.y"
:width="r.w"
:height="r.h"
fill="white"
class="logo-square"
:style="{ '--delay': delays[i] + 'ms' }"
/>
</svg>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
size?: 'sm' | 'lg' | 'xl'
noBorder?: boolean
/** When true, fit to container (w-full h-full) instead of fixed size - for use inside logo-gradient-border */
fit?: boolean
}>(), { size: 'sm', noBorder: false, fit: false })
const sizeClass = props.fit
? 'w-full h-full max-w-full max-h-full'
: props.size === 'xl'
? 'w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80'
: props.size === 'lg'
? 'w-32 h-32 sm:w-48 sm:h-48'
: 'w-14 h-14'
// Parsed from favico-black.svg path - 20 rects
const rects = [
{ x: 357.614, y: 318, w: 71.007, h: 70.936 },
{ x: 436.152, y: 318, w: 72.082, h: 70.936 },
{ x: 515.766, y: 318, w: 72.082, h: 70.936 },
{ x: 595.379, y: 318, w: 71.007, h: 70.936 },
{ x: 595.379, y: 396.46, w: 71.007, h: 72.011 },
{ x: 673.917, y: 396.46, w: 72.083, h: 72.011 },
{ x: 278, y: 475.994, w: 72.083, h: 72.012 },
{ x: 357.614, y: 475.994, w: 71.007, h: 72.012 },
{ x: 436.152, y: 475.994, w: 72.082, h: 72.012 },
{ x: 515.766, y: 475.994, w: 72.082, h: 72.012 },
{ x: 595.379, y: 475.994, w: 71.007, h: 72.012 },
{ x: 673.917, y: 475.994, w: 72.083, h: 72.012 },
{ x: 278, y: 555.529, w: 72.083, h: 70.936 },
{ x: 357.614, y: 555.529, w: 71.007, h: 70.936 },
{ x: 595.379, y: 555.529, w: 71.007, h: 70.936 },
{ x: 673.917, y: 555.529, w: 72.083, h: 70.936 },
{ x: 357.614, y: 633.989, w: 71.007, h: 72.011 },
{ x: 436.152, y: 633.989, w: 72.082, h: 72.011 },
{ x: 515.766, y: 633.989, w: 72.082, h: 72.011 },
{ x: 595.379, y: 633.989, w: 71.007, h: 72.011 },
]
// Stagger delays (ms) - row-by-row top-to-bottom, left-to-right for a clean reveal
const delays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
</script>
<style scoped>
.logo-svg {
will-change: auto;
}
.logo-square {
opacity: 0;
animation: logo-square-in 3s ease-out infinite;
animation-delay: var(--delay, 0ms);
animation-fill-mode: both;
}
/* Fade in only - no scale/position change. Squares stay fixed. */
@keyframes logo-square-in {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 1; }
}
</style>
@@ -0,0 +1,664 @@
<template>
<Teleport to="body">
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-stretch justify-stretch p-0"
@click.self="store.close()"
>
<!-- Backdrop - blur like spotlight -->
<div class="app-launcher-backdrop absolute inset-0 bg-black/75 backdrop-blur-md"></div>
<!-- Panel - full-screen overlay -->
<div
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-none shadow-2xl"
:class="panelClasses"
style="border-radius: 0;"
>
<!-- Header bar - sticky on mobile -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
<div class="hidden md:flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ store.title || 'App' }}</span>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors disabled:opacity-70"
aria-label="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5 transition-transform duration-300"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Open in new tab"
title="Open in new tab"
@click="openInNewTab"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button
ref="closeBtnRef"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<!-- Loading indicator -->
<Transition name="content-fade">
<AppLoadingScreen
v-if="iframeLoading"
:icon="overlayIcon"
:title="store.title || 'App'"
:progress="loadProgress"
/>
</Transition>
<iframe
ref="iframeRef"
v-if="store.url && !iframeBlocked"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="onIframeLoad"
@error="onIframeError"
/>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2v-2m0-8h18M3 8v8m18-8v8" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Can't display in frame</h3>
<p class="text-white/50 text-sm mb-6">This app doesn't support embedded viewing.<br>Please open it in a new tab instead.</p>
<button
@click="openInNewTabAndClose"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</Transition>
<!-- Payment Confirmation Dialog -->
<Transition name="content-fade">
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h3 class="text-white font-semibold text-sm">Payment Request</h3>
<p class="text-white/50 text-xs">{{ store.title || 'App' }} wants to make a payment</p>
</div>
</div>
<div class="space-y-2 mb-4">
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Amount</span>
<span class="text-orange-400 font-bold text-lg">{{ pendingPayment.amount_sats.toLocaleString() }} sats</span>
</div>
<div v-if="pendingPayment.memo" class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Memo</span>
<span class="text-white/80 text-sm truncate ml-2">{{ pendingPayment.memo }}</span>
</div>
<div class="flex justify-between items-center p-3 bg-white/5 rounded-lg">
<span class="text-white/60 text-sm">Method</span>
<span class="text-white/80 text-sm capitalize">{{ pendingPayment.method || 'auto' }}</span>
</div>
</div>
<div v-if="paymentError" class="mb-3 alert-error">
<p class="text-xs">{{ paymentError }}</p>
</div>
<div class="flex gap-3">
<button @click="rejectPayment" class="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors">
Deny
</button>
<button @click="approvePayment" :disabled="paymentProcessing" class="flex-1 px-4 py-2.5 glass-button glass-button-warning rounded-lg text-sm font-medium disabled:opacity-50">
{{ paymentProcessing ? 'Paying...' : 'Approve' }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Nostr signing consent modal -->
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest {
request_id: string
amount_sats: number
memo?: string
method?: 'lightning' | 'ecash' | 'onchain' | 'auto'
invoice?: string
address?: string
}
const store = useAppLauncherStore()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
const iframeLoading = ref(true)
const iframeBlocked = ref(false)
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
// when present; AppLoadingScreen's <img> falls back to the default icon if the
// guessed asset 404s.
const overlayIcon = computed(() => {
const url = store.url
if (!url) return DEFAULT_APP_ICON
try {
const m = new URL(url, window.location.origin).pathname.match(/^\/app\/([a-z0-9._-]+)/i)
if (m?.[1]) return `/assets/img/app-icons/${m[1].toLowerCase()}.png`
} catch { /* not a parseable URL */ }
return DEFAULT_APP_ICON
})
// Faux load progress (cross-origin iframes give no real progress events): ease
// toward ~92% while loading, snap to 100% on load.
const loadProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
}
function startProgress() {
stopProgress()
loadProgress.value = 8
progressTimer = setInterval(() => {
loadProgress.value += Math.max(0.4, (92 - loadProgress.value) * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(iframeLoading, (loading) => {
if (loading) startProgress()
else { stopProgress(); loadProgress.value = 100 }
}, { immediate: true })
// Nostr identity picker state
const showIdentityPicker = ref(false)
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
id: string
name: string
did: string
pubkey: string
nostr_pubkey?: string
nostr_npub?: string
}
/** Get the stored identity for an app, or null if first launch */
function getStoredIdentity(appUrl: string): SelectedIdentity | null {
try {
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) as SelectedIdentity : null
} catch {
return null
}
}
/** Store the selected identity for an app */
function storeIdentity(appUrl: string, identity: SelectedIdentity) {
try {
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
localStorage.setItem(key, JSON.stringify(identity))
} catch { /* ignore */ }
}
/** Handle identity selection from the picker */
function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
if (store.url) {
storeIdentity(store.url, identity)
}
// Send identity to the iframe
sendSelectedIdentity(identity)
}
/** Send a specific identity to the iframe */
async function sendSelectedIdentity(identity: SelectedIdentity) {
try {
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({
method: 'identity.sign',
params: { id: identity.id, message: challenge }
})
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:identity',
did: identity.did,
name: identity.name,
pubkey: identity.pubkey,
nostr_pubkey: identity.nostr_pubkey || null,
nostr_npub: identity.nostr_npub || null,
challenge,
signature: sigRes.signature
}, '*')
} catch {
/* identity signing not available */
}
}
// Timers for iframe load detection
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let contentCheckId: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null }
}
// Wallet connect — payment request state
const pendingPayment = ref<PaymentRequest | null>(null)
const paymentProcessing = ref(false)
const paymentError = ref('')
const paymentOrigin = ref('')
function refreshIframe() {
isRefreshing.value = true
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
iframeRefreshKey.value++
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
}
}, 15000)
}
function openInNewTab() {
if (!store.url) return
// Inside the Archipelago companion app, open the app in the in-app WebView
// instead of window.open — which the WebView suppresses for noopener popups
// (so the tap silently no-ops). The native bridge is reliable; fall back to
// window.open in a plain mobile browser.
const native = (window as any).ArchipelagoNative
if (native && typeof native.openInApp === 'function') {
native.openInApp(store.url)
return
}
window.open(store.url, '_blank', 'noopener,noreferrer')
}
function openInNewTabAndClose() {
openInNewTab()
store.close()
}
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
iframeLoading.value = false
sendIdentityIfSupported()
// Clear the load timeout
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
// Check iframe content after a brief delay to let the app render
contentCheckId = setTimeout(checkIframeContent, 2000)
}
function onIframeError() {
clearTimers()
iframeLoading.value = false
iframeBlocked.value = true
}
/** Check if the iframe loaded meaningful content (same-origin only) */
function checkIframeContent() {
try {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return // Cross-origin — can't check, assume OK
const body = doc.body
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
iframeBlocked.value = true
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Cross-origin: can\'t access iframe, assume working', e)
}
}
/** Apps that support the Archipelago identity protocol (postMessage) */
function isIdentityAwareApp(url: string): boolean {
return url.includes('indeehub') || url.includes('indeedhub')
}
/** Send the user's identity to the iframe via postMessage.
* On first launch, shows the identity picker modal.
* On subsequent launches, uses the previously selected identity. */
async function sendIdentityIfSupported() {
if (!store.url || !isIdentityAwareApp(store.url)) return
// Check if we have a stored identity for this app
const stored = getStoredIdentity(store.url)
if (stored) {
// Use the previously selected identity
await sendSelectedIdentity(stored)
return
}
// First launch — show the identity picker
showIdentityPicker.value = true
return // Identity will be sent after selection via onIdentitySelected
}
function injectScrollbarHideIfSameOrigin() {
try {
const doc = iframeRef.value?.contentDocument
if (!doc) return
const style = doc.createElement('style')
style.textContent = `
* { -ms-overflow-style: none; scrollbar-width: none; }
*::-webkit-scrollbar { display: none; }
`
doc.head.appendChild(style)
// Escape from inside iframe → close overlay and return focus to launcher
doc.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') {
e.preventDefault()
window.parent.postMessage({ type: 'app-launcher-escape' }, '*')
}
})
} catch {
/* Cross-origin: cannot access iframe document */
}
}
const panelClasses = [
'glass-card',
'w-full h-full',
'max-w-none max-h-none',
]
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
function onMessage(e: MessageEvent) {
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
store.close()
}
// Iframe app requests identity on demand
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
sendIdentityIfSupported()
}
// Wallet connect — app requests a payment
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
handlePaymentRequest(e)
}
}
/** Handle incoming payment request from iframe app */
function handlePaymentRequest(e: MessageEvent) {
const data = e.data
if (!data.amount_sats || typeof data.amount_sats !== 'number' || data.amount_sats <= 0) {
sendPaymentResponse(e.origin, data.request_id, false, 'Invalid amount')
return
}
pendingPayment.value = {
request_id: data.request_id || `pay-${Date.now()}`,
amount_sats: data.amount_sats,
memo: data.memo,
method: data.method || 'auto',
invoice: data.invoice,
address: data.address,
}
paymentOrigin.value = e.origin
paymentError.value = ''
paymentProcessing.value = false
}
/** Send payment response back to the iframe */
function sendPaymentResponse(origin: string, requestId: string, success: boolean, error?: string, receipt?: Record<string, unknown>) {
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:payment-response',
request_id: requestId,
success,
error: error || null,
receipt: receipt || null,
}, origin || '*')
}
/** User approves the payment */
async function approvePayment() {
if (!pendingPayment.value || paymentProcessing.value) return
paymentProcessing.value = true
paymentError.value = ''
const pay = pendingPayment.value
const method = resolvePaymentMethod(pay)
try {
let receipt: Record<string, unknown> = {}
if (method === 'ecash') {
const res = await rpcClient.call<{ token: string; amount_sats: number }>({
method: 'wallet.ecash-send',
params: { amount_sats: pay.amount_sats },
})
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
} else if (method === 'lightning') {
if (pay.invoice) {
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: pay.invoice },
})
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
} else {
// Create and immediately return an invoice for the requester to display
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: pay.amount_sats, memo: pay.memo || '' },
})
receipt = { method: 'lightning', payment_request: res.payment_request, amount_sats: pay.amount_sats }
}
} else {
if (!pay.address) {
paymentError.value = 'No Bitcoin address provided for on-chain payment'
return
}
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: pay.address, amount: pay.amount_sats },
})
receipt = { method: 'onchain', txid: res.txid, amount_sats: pay.amount_sats }
}
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
pendingPayment.value = null
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Payment failed'
paymentError.value = msg
} finally {
paymentProcessing.value = false
}
}
/** User rejects the payment */
function rejectPayment() {
if (pendingPayment.value) {
sendPaymentResponse(paymentOrigin.value, pendingPayment.value.request_id, false, 'Payment denied by user')
pendingPayment.value = null
}
}
/** Resolve auto method based on amount */
function resolvePaymentMethod(pay: PaymentRequest): 'ecash' | 'lightning' | 'onchain' {
if (pay.method && pay.method !== 'auto') return pay.method
if (pay.invoice) return 'lightning'
if (pay.address) return 'onchain'
if (pay.amount_sats < 1000) return 'ecash'
if (pay.amount_sats > 500000) return 'onchain'
return 'lightning'
}
watch(
() => store.isOpen,
(open) => {
if (open) {
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
// Set max load timeout — if iframe never fires load, show fallback
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
}
}, 15000)
closeBtnRef.value?.focus()
} else {
isRefreshing.value = false
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
// Clear any pending payment when closing
if (pendingPayment.value) {
rejectPayment()
}
}
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
})
onBeforeUnmount(() => {
clearTimers()
stopProgress()
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
})
</script>
<style scoped>
.app-launcher-panel {
will-change: transform, opacity;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.3s ease;
}
.app-launcher-enter-active .app-launcher-backdrop {
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
}
.app-launcher-leave-active .app-launcher-backdrop {
transition: opacity 0.2s ease, backdrop-filter 0.2s ease;
}
.app-launcher-enter-active .app-launcher-panel {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.app-launcher-leave-active .app-launcher-panel {
transition: transform 0.25s cubic-bezier(0.55, 0, 1, 0.45), opacity 0.2s ease;
}
.app-launcher-enter-from {
opacity: 0;
}
.app-launcher-enter-from .app-launcher-backdrop {
opacity: 0;
backdrop-filter: blur(0);
}
.app-launcher-enter-from .app-launcher-panel {
transform: translateY(40px);
opacity: 0;
}
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-leave-to .app-launcher-backdrop {
opacity: 0;
backdrop-filter: blur(0);
}
.app-launcher-leave-to .app-launcher-panel {
transform: translateY(30px);
opacity: 0;
}
</style>
@@ -0,0 +1,81 @@
<template>
<div class="app-loading-screen absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="app-loading-icon">
<img :src="icon" :alt="title" @error="handleImageError" />
</div>
<p class="app-loading-title">{{ title }}</p>
<div class="app-loading-bar">
<div class="app-loading-fill" :style="{ width: `${clampedProgress}%` }"></div>
</div>
<p class="app-loading-hint">{{ hint }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = withDefaults(defineProps<{
icon: string
title: string
progress: number
hint?: string
}>(), {
hint: 'Loading…',
})
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.progress)))
</script>
<style scoped>
.app-loading-screen {
gap: 18px;
background: #0b0d12;
}
.app-loading-icon {
width: 84px;
height: 84px;
border-radius: 20px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
animation: app-loading-pulse 1.8s ease-in-out infinite;
}
.app-loading-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-loading-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.app-loading-bar {
width: min(240px, 60vw);
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.app-loading-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #fb923c, #f59e0b);
transition: width 0.3s ease;
}
.app-loading-hint {
margin: 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
@keyframes app-loading-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="relative" ref="containerRef">
<!-- Mobile/tablet: Online button launches CLI directly (no CLI label, no dropdown) -->
<button
type="button"
class="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg glass-card text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0 border border-white/10"
@click="selectCLI"
>
<img
src="/assets/img/logo-archipelago.svg"
alt="Archipelago"
class="w-5 h-5 shrink-0 object-contain opacity-90"
/>
<div class="flex items-center gap-1.5 shrink-0">
<div class="relative">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
</div>
<span class="text-xs text-white/80">Online</span>
</div>
</button>
<!-- Desktop: Full switcher with dropdown (lg and up) -->
<button
type="button"
class="hidden lg:flex items-center gap-2 px-3 py-2 rounded-lg glass-card text-white/90 hover:bg-white/10 hover:text-white transition-colors min-w-0 border border-white/10"
@click="showDropdown = !showDropdown"
>
<img
src="/assets/img/logo-archipelago.svg"
alt="Archipelago"
class="w-5 h-5 shrink-0 object-contain opacity-90"
/>
<span class="text-sm font-medium truncate max-w-[100px] sm:max-w-[120px]">Archipelago CLI</span>
<div class="flex items-center gap-1.5 shrink-0 pl-1 border-l border-white/20">
<div class="relative">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
</div>
<span class="text-xs text-white/80">Online</span>
</div>
<svg class="w-4 h-4 text-white/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Transition name="dropdown">
<div
v-if="showDropdown"
class="absolute right-0 top-full mt-1 py-1 min-w-[160px] rounded-lg glass-card shadow-xl z-50"
@click.stop
>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="inCLI ? 'bg-white/10 text-white' : 'text-white/80 hover:bg-white/10 hover:text-white'"
@click="selectCLI"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Archipelago CLI
</button>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="!inCLI ? 'bg-white/10 text-white' : 'text-white/80 hover:bg-white/10 hover:text-white'"
@click="selectWebUI"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Web UI
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useCLIStore } from '@/stores/cli'
const cliStore = useCLIStore()
const containerRef = ref<HTMLElement | null>(null)
const showDropdown = ref(false)
const inCLI = computed(() => cliStore.isOpen)
function selectCLI() {
showDropdown.value = false
cliStore.open()
}
function selectWebUI() {
showDropdown.value = false
cliStore.close()
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<template>
<!-- Desktop: subtle "frosted pill" link, sits at the top of the content flow
(the style from the Networking Profits page, now shared globally). -->
<button
type="button"
@click="$emit('click')"
:class="['hidden md:inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white text-sm transition-colors', desktopMargin]"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ label }}
</button>
<!-- Mobile: floating transparent button pinned 8px above the tab bar -->
<Teleport to="body">
<button
type="button"
@click="$emit('click')"
class="md:hidden mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ label }}</span>
</button>
</Teleport>
</template>
<script setup lang="ts">
/**
* Standard back button. Renders a transparent text link at the top on desktop
* and a floating transparent "glass" pill pinned above the tab bar on mobile —
* the pattern set by the Cloud detail pages (PeerFiles/CloudFolder).
*
* Presentational only: it emits `click`; the parent keeps its own navigation
* logic (router.push / router.back / conditional goBack).
*/
withDefaults(
defineProps<{
label?: string
/** Desktop bottom-margin utility (views vary between mb-4 and mb-6). */
desktopMargin?: string
}>(),
{ label: 'Back', desktopMargin: 'mb-4' }
)
defineEmits<{ (e: 'click'): void }>()
</script>
+91
View File
@@ -0,0 +1,91 @@
<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>
<div
ref="modalRef"
class="glass-card p-6 w-full relative z-10"
:class="[maxWidth, contentClass]"
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>
<slot />
<slot name="footer" />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
const props = withDefaults(defineProps<{
show: boolean
title: string
maxWidth?: string
zIndex?: string
contentClass?: string
}>(), {
maxWidth: 'max-w-md',
zIndex: 'z-[3000]',
contentClass: '',
})
const emit = defineEmits<{
close: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const zClass = computed(() => props.zIndex)
function close() {
emit('close')
}
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>
+374
View File
@@ -0,0 +1,374 @@
<template>
<Transition name="boot-fade">
<div v-if="visible" class="boot-screen">
<!-- Particle starfield -->
<canvas ref="canvasRef" class="boot-stars" />
<!-- Two-column layout: terminal left, orb right -->
<div class="boot-layout">
<!-- Left: Terminal log -->
<div class="boot-left">
<div class="boot-terminal" ref="terminalRef">
<p v-for="(line, i) in logLines" :key="i" class="boot-log-line" :class="line.type">
<span class="boot-log-ts">{{ line.prefix }}</span>
<span>{{ line.text }}</span>
</p>
<span class="boot-cursor">_</span>
</div>
<div class="boot-progress-wrap">
<svg class="boot-arc" viewBox="0 0 200 12" preserveAspectRatio="none">
<rect x="0" y="4" width="200" height="4" rx="2" fill="rgba(255,255,255,0.06)" />
<rect x="0" y="4" :width="progress * 2" height="4" rx="2" fill="url(#boot-grad)" />
<defs>
<linearGradient id="boot-grad" x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="rgba(255,255,255,0.5)" />
<stop offset="1" stop-color="rgba(255,255,255,0.9)" />
</linearGradient>
</defs>
</svg>
<span class="boot-pct">{{ Math.round(progress) }}%</span>
</div>
</div>
<!-- Right: The orb -->
<div class="boot-right">
<div class="boot-orb">
<!-- Viz ring segments -->
<div class="boot-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="boot-viz-seg"
:style="{ '--si': i, '--sd': `${(i / 48) * 360}deg` }"
:class="{ 'boot-seg-lit': i < litBars }"
/>
</div>
<!-- Center: gradient-bordered frame with cycling icons -->
<div class="boot-center-icon">
<div class="boot-icon-frame boot-gradient-border">
<Transition name="icon-morph" mode="out-in">
<div :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<img :src="iconSources[currentIcon]" class="boot-icon-img" />
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ ready: [] }>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const currentIcon = ref(0)
const progress = ref(0)
const litBars = ref(0)
const glitching = ref(false)
// Boot screen icons — from /assets/icon/ directory
const iconSources = [
'/assets/icon/bitcoin.svg',
'/assets/icon/cloud-done.svg',
]
interface LogLine { prefix: string; text: string; type: string }
const logLines = ref<LogLine[]>([])
const bootMessages = [
{ delay: 500, prefix: 'sys', text: 'Archipelago v0.1.0', type: 'info' },
{ delay: 1500, prefix: 'sec', text: 'Loading ed25519 keys...', type: 'info' },
{ delay: 3000, prefix: ' ok', text: 'Cryptographic keys loaded', type: 'success' },
{ delay: 4500, prefix: 'net', text: 'Binding to port 5678', type: 'info' },
{ delay: 5500, prefix: ' ok', text: 'Nginx proxy detected', type: 'success' },
{ delay: 7000, prefix: ' id', text: 'Initializing identity store...', type: 'info' },
{ delay: 8500, prefix: ' ok', text: 'DID resolver online', type: 'success' },
{ delay: 10000, prefix: 'btc', text: 'Connecting to Bitcoin node...', type: 'info' },
{ delay: 12000, prefix: 'lnd', text: 'Lightning daemon syncing', type: 'info' },
{ delay: 14000, prefix: ' ok', text: 'LND chain synced', type: 'success' },
{ delay: 16000, prefix: 'pod', text: 'Scanning containers...', type: 'info' },
{ delay: 17500, prefix: ' ok', text: '12 containers discovered', type: 'success' },
{ delay: 19000, prefix: 'sec', text: 'AppArmor profiles verified', type: 'success' },
{ delay: 20500, prefix: 'dwn', text: 'DWN node connected', type: 'success' },
{ delay: 22000, prefix: 'msh', text: 'Mesh radio initialized', type: 'success' },
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
]
// Starfield
let animFrame = 0
const stars: { x: number; y: number; z: number }[] = []
function initStars(c: HTMLCanvasElement) {
for (let i = 0; i < 180; i++) {
stars.push({ x: (Math.random() - 0.5) * c.width * 3, y: (Math.random() - 0.5) * c.height * 3, z: Math.random() * 1500 + 500 })
}
}
function drawStars(c: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#0a0a0a'
ctx.fillRect(0, 0, c.width, c.height)
const speed = 0.6 + (progress.value / 100) * 2.5
const cx = c.width / 2, cy = c.height / 2
for (const s of stars) {
s.z -= speed
if (s.z <= 0) { s.z = 1500; s.x = (Math.random() - 0.5) * c.width * 3; s.y = (Math.random() - 0.5) * c.height * 3 }
const sx = (s.x / s.z) * 300 + cx, sy = (s.y / s.z) * 300 + cy
if (sx < 0 || sx > c.width || sy < 0 || sy > c.height) continue
const size = Math.max(0.5, (1 - s.z / 1500) * 2)
const alpha = Math.min(1, (1 - s.z / 1500) * 1.2)
ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.7})`; ctx.fill()
}
animFrame = requestAnimationFrame(() => drawStars(c, ctx))
}
function triggerGlitch() { glitching.value = true; setTimeout(() => { glitching.value = false }, 200) }
// Health check
async function checkHealth(): Promise<boolean> {
try {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 3000)
const res = await fetch('/rpc/v1', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server.echo', params: { message: 'boot' } }),
signal: ac.signal,
})
clearTimeout(t)
return res.status !== 502 && res.status !== 503
} catch { return false }
}
let iconInterval: ReturnType<typeof setInterval> | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
let logTimeouts: ReturnType<typeof setTimeout>[] = []
function startPolling() {
iconInterval = setInterval(() => {
currentIcon.value = (currentIcon.value + 1) % iconSources.length
triggerGlitch()
}, 2500)
// Feed boot log messages
const lastMsgDelay = bootMessages[bootMessages.length - 1]!.delay
for (const msg of bootMessages) {
logTimeouts.push(setTimeout(() => {
logLines.value.push({ prefix: msg.prefix, text: msg.text, type: msg.type })
if (logLines.value.length > 8) logLines.value.shift()
const idx = bootMessages.indexOf(msg)
progress.value = Math.min(95, ((idx + 1) / bootMessages.length) * 100)
litBars.value = Math.round((progress.value / 100) * 48)
nextTick(() => { if (terminalRef.value) terminalRef.value.scrollTop = terminalRef.value.scrollHeight })
}, msg.delay))
}
// After the last message, poll for server readiness then immediately transition
let finished = false
logTimeouts.push(setTimeout(() => {
const finishBoot = () => {
if (finished) return
finished = true
stopPolling()
progress.value = 100
litBars.value = 48
if (import.meta.env.DEV) console.log('[Boot] finishBoot — emitting ready in 800ms')
setTimeout(() => {
if (import.meta.env.DEV) console.log('[Boot] emitting ready now')
emit('ready')
}, 800)
}
checkHealth().then(r => {
if (import.meta.env.DEV) console.log('[Boot] health check result:', r)
if (r) { finishBoot(); return }
pollInterval = setInterval(async () => {
const healthy = await checkHealth()
if (import.meta.env.DEV) console.log('[Boot] poll health:', healthy)
if (healthy) finishBoot()
}, 2000)
})
}, lastMsgDelay + 1500))
// Reset mock boot timer on fresh page load
fetch('/rpc/v1', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'server.echo', params: { message: 'boot-reset' } }),
}).catch(() => {})
}
function stopPolling() {
if (iconInterval) { clearInterval(iconInterval); iconInterval = null }
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
for (const t of logTimeouts) clearTimeout(t)
logTimeouts = []
}
function initCanvas() {
const c = canvasRef.value
if (!c) return
c.width = window.innerWidth; c.height = window.innerHeight
const ctx = c.getContext('2d')
if (ctx) { initStars(c); drawStars(c, ctx) }
}
let started = false
function startIfNeeded() {
if (started) return
started = true
startPolling()
nextTick(initCanvas)
}
watch(() => props.visible, v => { if (v) startIfNeeded() })
onMounted(() => { if (props.visible) startIfNeeded() })
onBeforeUnmount(() => { stopPolling(); cancelAnimationFrame(animFrame) })
</script>
<style scoped>
.boot-screen {
position: fixed; inset: 0; z-index: 9000;
display: flex; align-items: center; justify-content: center;
cursor: default; overflow: hidden;
}
.boot-stars { position: absolute; inset: 0; width: 100%; height: 100%; }
/* Two-column layout */
.boot-layout {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3rem;
max-width: 900px; width: 90%; padding: 0 1rem;
}
/* Left column: terminal */
.boot-left {
flex: 1; min-width: 0; max-width: 400px;
}
.boot-terminal {
max-height: 200px; overflow: hidden;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px; line-height: 1.8;
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 100%);
}
.boot-log-line { white-space: nowrap; overflow: hidden; animation: log-in 0.3s ease both; }
.boot-log-line.info { color: rgba(255,255,255,0.35); }
.boot-log-line.success { color: rgba(255,255,255,0.7); }
.boot-log-line.ready { color: white; font-weight: 600; text-shadow: 0 0 10px rgba(255,255,255,0.4); }
.boot-log-ts { color: rgba(255,255,255,0.15); margin-right: 8px; font-weight: 500; }
.boot-log-line.success .boot-log-ts { color: rgba(255,255,255,0.35); }
.boot-log-line.ready .boot-log-ts { color: rgba(255,255,255,0.5); }
@keyframes log-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
.boot-cursor { color: rgba(255,255,255,0.5); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
@keyframes blink { 50% { opacity: 0; } }
.boot-progress-wrap { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.boot-arc { flex: 1; height: 12px; }
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.3); min-width: 28px; text-align: right; }
/* Right column: orb */
.boot-right {
flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;
}
.boot-orb {
position: relative; width: 220px; height: 220px;
}
@media (min-width: 640px) { .boot-orb { width: 280px; height: 280px; } }
@media (min-width: 768px) { .boot-orb { width: 320px; height: 320px; } }
/* Viz ring */
.boot-viz-ring { position: absolute; inset: 0; --vr: 100px; }
@media (min-width: 640px) { .boot-viz-ring { --vr: 130px; } }
@media (min-width: 768px) { .boot-viz-ring { --vr: 150px; } }
.boot-viz-seg {
position: absolute; left: 50%; top: 50%;
width: 3px; height: 18px; margin-left: -1.5px; margin-top: -9px;
border-radius: 1.5px; transform-origin: center center;
transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr)));
background: rgba(255,255,255,0.05);
transition: background 0.4s ease, height 0.4s ease, box-shadow 0.4s ease;
}
.boot-seg-lit {
background: linear-gradient(to bottom, rgba(255,255,255,0.7), rgba(255,255,255,0.2));
box-shadow: 0 0 6px rgba(255,255,255,0.15);
height: 22px; margin-top: -11px;
}
/* Center icon */
.boot-center-icon {
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); z-index: 10;
filter: drop-shadow(0 0 30px rgba(255,255,255,0.1));
}
.boot-icon-frame {
width: 120px; height: 120px;
display: flex; align-items: center; justify-content: center;
}
@media (min-width: 640px) { .boot-icon-frame { width: 160px; height: 160px; } }
@media (min-width: 768px) { .boot-icon-frame { width: 200px; height: 200px; } }
/* Gradient border — circular, matches logo-gradient-border style */
.boot-gradient-border {
position: relative;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,0,0,0.8) 100%);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.boot-gradient-border::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 9999px;
background: #000;
z-index: 0;
}
.boot-pixel-wrap {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
position: relative; z-index: 1;
}
.boot-icon-img {
width: 55%; height: 55%; object-fit: contain;
filter: brightness(0) invert(1) drop-shadow(0 0 8px rgba(255,255,255,0.15));
}
/* Glitch */
.boot-glitch { animation: glitch 0.2s steps(3) both; }
@keyframes glitch {
0% { transform: translate(0); filter: none; }
25% { transform: translate(2px,-1px); filter: hue-rotate(90deg); }
50% { transform: translate(-2px,1px); filter: hue-rotate(-90deg) brightness(1.4); }
75% { transform: translate(1px,2px); filter: hue-rotate(45deg); }
100% { transform: translate(0); filter: none; }
}
/* Icon morph */
.icon-morph-enter-active { transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; }
.icon-morph-leave-active { transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease; }
.icon-morph-enter-from { opacity:0; transform: scale(0.5) rotate(-10deg); filter: blur(4px); }
.icon-morph-leave-to { opacity:0; transform: scale(1.4) rotate(10deg); filter: blur(4px); }
/* Boot screen fade out */
.boot-fade-leave-active { transition: opacity 1.2s ease; }
.boot-fade-leave-to { opacity: 0; }
/* Mobile: stack vertically */
@media (max-width: 767px) {
.boot-layout { flex-direction: column-reverse; gap: 2rem; }
.boot-left { max-width: 100%; }
.boot-orb { width: 200px; height: 200px; }
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<Teleport to="body">
<Transition name="cli-popup">
<div
v-if="cliStore.isOpen"
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
@click.self="cliStore.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="panelRef"
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
:style="panelStyle"
@mousedown="onPanelMouseDown"
>
<!-- Header: terminal icon + title + app switcher -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<div class="flex items-center gap-3 flex-1 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-white font-medium">CLI Access</span>
</div>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- Content: mock CLI in dev, SSH instructions in production -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<!-- Mock CLI interface (dev mode only) -->
<div
v-if="isDev"
class="flex-1 flex flex-col min-h-0 p-4 bg-black/80 rounded-b-lg font-mono text-sm"
>
<div ref="outputRef" class="flex-1 overflow-y-auto text-green-400/90 whitespace-pre-wrap break-words mb-2 min-h-0">{{ mockOutput }}</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-amber-400">archipelago@node</span>
<span class="text-white/60">~</span>
<span class="text-white/40">$</span>
<input
ref="cliInputRef"
v-model="mockCommand"
type="text"
class="flex-1 bg-transparent text-white outline-none border-none"
placeholder=" "
@keydown.enter="runMockCommand"
/>
</div>
</div>
<!-- SSH instructions (production only) -->
<div v-else class="flex-1 overflow-y-auto p-4 space-y-4">
<p class="text-white/80 text-sm">
Connect to this node via SSH to access the command line. Use the same host as this web interface.
</p>
<div class="space-y-3">
<div class="p-3 rounded-lg bg-white/5 font-mono text-sm">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">SSH Command</div>
<div class="flex items-center gap-2 flex-wrap">
<code class="text-green-400 break-all">{{ sshCommand }}</code>
<button
type="button"
class="shrink-0 px-2 py-1 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white text-xs transition-colors"
@click="copyCommand"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<div class="p-3 rounded-lg bg-white/5 text-sm space-y-1">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">Connection Details</div>
<div class="flex flex-col gap-1.5 text-white/80">
<div class="flex justify-between gap-4">
<span class="text-white/50">Host</span>
<span class="font-mono text-green-400">{{ host }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">User</span>
<span class="font-mono">archipelago</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">Password</span>
<span class="font-mono">archipelago</span>
</div>
</div>
</div>
<p class="text-white/50 text-xs">
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
</p>
<p class="text-white/40 text-xs">
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">F</kbd> to open this anytime.
</p>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useCLIStore } from '@/stores/cli'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const cliStore = useCLIStore()
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const outputRef = ref<HTMLElement | null>(null)
const cliInputRef = ref<HTMLInputElement | null>(null)
const copied = ref(false)
const mockCommand = ref('')
const mockOutput = ref(` ╔═══════════════════════════════════════════════════════════╗
║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║
║ Your sovereign Bitcoin infrastructure ║
╚═══════════════════════════════════════════════════════════╝
System Status:
─────────────────────────────────────────────────────────────
Mode: 🟢 Installed
Podman: 🟢 Installed
Bitcoin: 🟢 Running (blocks: syncing)
Lightning: 🟡 Stopped
Main Menu:
─────────────────────────────────────────────────────────────
r) Refresh - Update IP/status
w) Open Web UI - Launch graphical interface
1) Install to Disk - Permanently install Archipelago
2) Setup Bitcoin Core - Configure Bitcoin full node
3) Setup Lightning (LND) - Configure Lightning Network
4) Setup BTCPay Server - Bitcoin payment processor
5) View Logs - Monitor running services
6) Network Settings - Configure networking
7) System Info - View system information
q) Quit
`)
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const SAVED_POSITION_KEY = 'archipelago-cli-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
const isDev = import.meta.env.DEV
const host = computed(() => window.location.hostname)
const sshCommand = computed(() => `ssh archipelago@${host.value}`)
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
function loadSavedPosition() {
try {
const raw = localStorage.getItem(SAVED_POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw)
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
} else {
savedPosition.value = null
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved CLI position', e)
savedPosition.value = null
}
}
function savePosition(x: number, y: number) {
savedPosition.value = { x, y }
try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to save CLI position', e)
}
}
function runMockCommand() {
const cmd = mockCommand.value.trim()
if (!cmd) return
mockOutput.value += `\n archipelago@node ~ $ ${cmd}\n`
const lower = cmd.toLowerCase()
if (lower === 'r' || lower === 'refresh') {
mockOutput.value += ` Status refreshed.\n`
} else if (lower === 'w' || lower.startsWith('web')) {
mockOutput.value += ` Opening Web UI... (press C to return to CLI)\n`
} else if (lower === 'q' || lower === 'quit' || lower === 'exit') {
mockOutput.value += ` Goodbye! 🏝️\n`
cliStore.close()
} else if (lower === 'help' || lower === '?') {
mockOutput.value += ` Type r, w, 1-7, or q. Press C to switch to Web UI.\n`
} else {
mockOutput.value += ` Unknown command. Type 'help' or 'r' for menu.\n`
}
mockCommand.value = ''
nextTick(() => {
outputRef.value?.scrollTo({ top: outputRef.value.scrollHeight, behavior: 'smooth' })
})
}
async function copyCommand() {
try {
await navigator.clipboard.writeText(sshCommand.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = sshCommand.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
useModalKeyboard(panelRef, computed(() => cliStore.isOpen), () => cliStore.close())
watch(
() => cliStore.isOpen,
(open) => {
if (open) {
loadSavedPosition()
if (isDev) {
nextTick(() => cliInputRef.value?.focus())
}
}
}
)
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<style scoped>
.cli-popup-enter-active,
.cli-popup-leave-active {
transition: opacity 0.2s ease;
}
.cli-popup-enter-from,
.cli-popup-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,45 @@
<template>
<Transition name="companion-slide">
<div
v-if="relayConnected"
class="flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-300"
:class="companionActive
? 'bg-orange-500/10 border border-orange-500/20'
: 'bg-white/5 border border-white/10'"
:title="companionActive ? 'Companion app connected' : 'Remote relay ready'"
>
<svg
class="w-5 h-5 flex-shrink-0 transition-colors duration-200"
:class="companionActive
? (companionInputActive ? 'text-orange-400' : 'text-orange-400/70')
: 'text-white/20'"
fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"
>
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
<span
class="text-xs hidden sm:inline transition-colors duration-200"
:class="companionActive ? 'text-orange-400/80' : 'text-white/30'"
>{{ companionActive ? 'Companion' : 'Relay' }}</span>
<span
v-if="companionInputActive"
class="ml-auto w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"
/>
</div>
</Transition>
</template>
<script setup lang="ts">
import { relayConnected, companionActive, companionInputActive } from '@/api/remote-relay'
</script>
<style scoped>
.companion-slide-enter-active { transition: opacity 0.3s ease, max-height 0.3s ease; }
.companion-slide-leave-active { transition: opacity 0.2s ease, max-height 0.2s ease; }
.companion-slide-enter-from,
.companion-slide-leave-to { opacity: 0; max-height: 0; }
</style>
@@ -0,0 +1,137 @@
<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"
@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"
aria-label="Close companion modal"
@click="dismiss"
>
&times;
</button>
<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="mb-4">
<a
:href="companionDownloadUrl"
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"
target="_blank"
rel="noopener noreferrer"
>
Download companion app
</a>
<div class="hidden md:flex justify-center">
<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>
<a
:href="companionDownloadUrl"
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
>
Download companion app
</a>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO } from '@/composables/useDemoIntro'
const STORAGE_KEY = 'neode_companion_intro_seen'
// Absolute URL so the QR works when scanned by a phone (a relative path has no
// host to resolve). Points at the companion APK hosted on the 146 release server
// (publicly reachable) rather than the local node's /packages copy.
// The demo serves the APK from its own public origin instead, so the QR never
// exposes the release-server address.
const DEFAULT_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/archipelago-companion.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
const visible = ref(false)
const qrDataUrl = ref('')
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
onMounted(() => {
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
// Delay slightly so it doesn't compete with login animation
setTimeout(() => { visible.value = true }, 5000)
}
} catch {
// localStorage unavailable
}
})
watch(visible, async (isVisible) => {
if (!isVisible) return
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
width: 112,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}, { immediate: true, flush: 'post' })
function dismiss() {
visible.value = false
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); }
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div class="flex items-center gap-2">
<!-- Status Indicator -->
<div class="relative">
<div
class="w-3 h-3 rounded-full transition-colors"
:class="statusClass"
></div>
<div
v-if="isRunning"
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
:class="statusClass"
></div>
</div>
<!-- Status Text -->
<span class="text-sm font-medium" :class="textClass">
{{ statusText }}
</span>
<!-- Health Badge (if running) -->
<span
v-if="isRunning && health"
class="px-2 py-0.5 rounded text-xs font-medium"
:class="healthClass"
>
{{ healthText }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
state:
| 'created'
| 'running'
| 'stopped'
| 'exited'
| 'paused'
| 'unknown'
| 'stopping'
| 'starting'
| 'restarting'
| 'installing'
| 'updating'
| 'removing'
| 'installed'
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
const props = withDefaults(defineProps<Props>(), {
health: 'unknown',
})
const isRunning = computed(() => props.state === 'running')
const statusClass = computed(() => {
switch (props.state) {
case 'running':
return 'bg-green-400'
case 'stopped':
case 'exited':
case 'installed':
return 'bg-gray-400'
case 'paused':
case 'starting':
case 'stopping':
case 'restarting':
case 'installing':
case 'updating':
case 'removing':
return 'bg-yellow-400'
default:
return 'bg-red-400'
}
})
const textClass = computed(() => {
switch (props.state) {
case 'running':
return 'text-green-400'
case 'stopped':
case 'exited':
case 'installed':
return 'text-gray-400'
case 'paused':
case 'starting':
case 'stopping':
case 'restarting':
case 'installing':
case 'updating':
case 'removing':
return 'text-yellow-400'
default:
return 'text-red-400'
}
})
const statusText = computed(() => {
switch (props.state) {
case 'running':
return 'Running'
case 'stopped':
return 'Stopped'
case 'exited':
return 'Exited'
case 'paused':
return 'Paused'
case 'created':
return 'Created'
case 'installed':
return 'Installed'
case 'starting':
return 'Starting…'
case 'stopping':
return 'Stopping…'
case 'restarting':
return 'Restarting…'
case 'installing':
return 'Installing…'
case 'updating':
return 'Updating…'
case 'removing':
return 'Removing…'
default:
return 'Unknown'
}
})
const healthClass = computed(() => {
switch (props.health) {
case 'healthy':
return 'bg-green-500/20 text-green-400 border border-green-500/30'
case 'unhealthy':
return 'bg-red-500/20 text-red-400 border border-red-500/30'
case 'starting':
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
default:
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
}
})
const healthText = computed(() => {
switch (props.health) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'starting':
return 'Starting'
default:
return 'Unknown'
}
})
</script>
@@ -0,0 +1,21 @@
<template>
<div
v-if="store.isActive"
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10"
title="Controller connected - use arrows & Enter to navigate"
>
<svg class="w-5 h-5 text-amber-400/90 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="8" width="16" height="10" rx="2" stroke-width="2" />
<circle cx="9" cy="13" r="1.5" fill="currentColor" />
<circle cx="15" cy="13" r="1.5" fill="currentColor" />
<path stroke-linecap="round" stroke-width="2" d="M12 10v2M11 11h2" />
</svg>
<span class="text-xs text-white/70 hidden sm:inline">Controller</span>
</div>
</template>
<script setup lang="ts">
import { useControllerStore } from '@/stores/controller'
const store = useControllerStore()
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-8 transition-opacity duration-300"
:class="{ 'opacity-0 pointer-events-none': !show }"
>
<RouterLink
v-for="(goal, idx) in goals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
class="goal-card glass-card p-6 block"
:class="{ 'home-card-animate': animate }"
:style="{ '--card-stagger': idx }"
>
<div class="flex items-start justify-between mb-4">
<!-- App icons for goals with required apps, emoji fallback otherwise -->
<div v-if="goalAppIcons(goal).length > 0" class="flex items-center gap-1.5 shrink-0">
<img
v-for="icon in goalAppIcons(goal)"
:key="icon.appId"
:src="icon.url"
:alt="icon.appId"
class="w-8 h-8 rounded-lg object-contain bg-white/5 border border-white/10 p-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</div>
<div v-else class="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<span class="text-xl">{{ goalIcon(goal.icon) }}</span>
</div>
<span class="goal-status-badge" :class="statusBadgeClass(goal.id)">
<span v-if="goalStatuses[goal.id] === 'completed'" class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
<span v-else-if="goalStatuses[goal.id] === 'in-progress'" class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>
{{ statusLabel(goal.id) }}
</span>
</div>
<h3 class="text-lg font-semibold text-white mb-1">{{ goal.title }}</h3>
<p class="text-sm text-white/55 mb-4 leading-relaxed">{{ goal.subtitle }}</p>
<div class="flex items-center justify-between">
<span class="text-xs text-white/40">{{ goal.estimatedTime }}</span>
<span class="text-xs text-white/50 flex items-center gap-1">
{{ goal.difficulty === 'beginner' ? t('easyHome.beginner') : t('easyHome.intermediate') }}
</span>
</div>
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { GOALS } from '@/data/goals'
import { useGoalStore } from '@/stores/goals'
import type { GoalDefinition } from '@/types/goals'
defineProps<{
show: boolean
animate: boolean
}>()
const { t } = useI18n()
const goalStore = useGoalStore()
const goals = GOALS
const goalStatuses = goalStore.goalStatuses
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.png',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
filebrowser: '/assets/img/app-icons/file-browser.webp',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrumx.png',
electrumx: '/assets/img/app-icons/electrumx.png',
}
function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
return goal.requiredApps
.filter((appId) => APP_ICON_MAP[appId] !== undefined)
.map((appId) => ({ appId, url: APP_ICON_MAP[appId] as string }))
}
function goalIcon(icon: string): string {
const icons: Record<string, string> = {
shop: '🏪',
payments: '⚡',
photos: '📸',
files: '📁',
lightning: '⚡',
identity: '🔑',
backup: '💾',
}
return icons[icon] || '📦'
}
function statusLabel(goalId: string): string {
const status = goalStatuses[goalId]
if (status === 'completed') return t('easyHome.done')
if (status === 'in-progress') return t('easyHome.inProgress')
return t('easyHome.start')
}
function statusBadgeClass(goalId: string): string {
const status = goalStatuses[goalId]
if (status === 'completed') return 'goal-status-badge-completed'
if (status === 'in-progress') return 'goal-status-badge-in-progress'
return 'goal-status-badge-not-started'
}
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<span class="text-3xl">{{ icon }}</span>
</div>
<h3 class="text-lg font-semibold text-white/80 mb-2">{{ title }}</h3>
<p class="text-sm text-white/50 max-w-sm mb-6">{{ description }}</p>
<button
v-if="actionLabel"
@click="$emit('action')"
class="glass-button px-5 py-2.5 rounded-lg text-sm font-medium"
>
{{ actionLabel }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
icon?: string
title: string
description: string
actionLabel?: string
}>()
defineEmits<{
action: []
}>()
</script>
@@ -0,0 +1,141 @@
<template>
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="audioPlayer.currentName.value"
ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar"
>
<!-- Progress bar (clickable) -->
<div
class="h-1 bg-white/10 cursor-pointer"
@click="onProgressClick"
>
<div
class="h-full bg-orange-500 transition-all duration-200"
:style="{ width: audioPlayer.progress.value + '%' }"
/>
</div>
<div class="flex items-center gap-3 px-4 py-2.5">
<!-- Play/Pause -->
<button
class="flex-shrink-0 w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
@click="togglePlay"
>
<svg v-if="audioPlayer.loading.value" class="w-5 h-5 animate-spin text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<svg v-else-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</button>
<!-- Track info -->
<div class="flex-1 min-w-0">
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
<p v-else class="text-sm font-medium text-white/90 truncate">{{ audioPlayer.currentName.value }}</p>
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
</div>
<!-- Close -->
<button
class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center transition-colors"
@click="audioPlayer.stop()"
>
<svg class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const audioPlayer = useAudioPlayer()
const barEl = ref<HTMLElement | null>(null)
// Publish the player's height as a CSS variable so page scroll containers can
// reserve space for it (the same mechanism the mobile tab bar uses). This is
// what pushes the rest of the site up instead of letting the fixed bar overlap
// and block the bottom controls — on desktop AND mobile, on every page.
function setPlayerHeightVar() {
if (typeof document === 'undefined') return
const h = barEl.value?.offsetHeight || 60
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
document.documentElement.classList.add('audio-active')
}
function clearPlayerHeightVar() {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--audio-player-height', '0px')
document.documentElement.classList.remove('audio-active')
}
watch(() => audioPlayer.currentName.value, (name) => {
if (name) {
nextTick(setPlayerHeightVar)
} else {
clearPlayerHeightVar()
}
}, { immediate: true })
onBeforeUnmount(clearPlayerHeightVar)
function togglePlay() {
if (audioPlayer.playing.value) {
audioPlayer.pause()
} else if (audioPlayer.currentSrc.value) {
audioPlayer.play(audioPlayer.currentSrc.value, audioPlayer.currentName.value)
}
}
function onProgressClick(e: MouseEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const time = ratio * audioPlayer.duration.value
audioPlayer.seek(time)
}
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.audio-player-bar {
/* Sit directly above the mobile tab bar (its height is published as
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
resolves to 0px and the bar docks flush to the bottom of the viewport. */
bottom: var(--mobile-tab-bar-height, 0px);
background: rgba(15, 15, 15, 0.55);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border-top: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.3);
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>
@@ -0,0 +1,34 @@
<template>
<BaseModal :show="show" :title="title" max-width="max-w-lg" content-class="max-h-[80vh] overflow-y-auto" @close="$emit('close')">
<div class="text-white/80 prose prose-invert max-w-none">
<p class="whitespace-pre-wrap">{{ content }}</p>
</div>
<div v-if="relatedPath" class="mt-4">
<router-link
:to="relatedPath"
class="inline-flex items-center gap-2 px-4 py-2 glass-button rounded-lg text-sm font-medium"
@click="$emit('close')"
>
Go to related page
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</router-link>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import BaseModal from '@/components/BaseModal.vue'
defineProps<{
show: boolean
title: string
content: string
relatedPath?: string
}>()
defineEmits<{
close: []
}>()
</script>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="relative" ref="pickerRef">
<button
@click="isOpen = !isOpen"
class="w-full flex items-center gap-3 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white hover:bg-white/10 transition-colors"
>
<!-- Selected Identity -->
<div v-if="selectedIdentity" class="flex items-center gap-2 flex-1 min-w-0">
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(selectedIdentity.purpose)">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span class="truncate">{{ selectedIdentity.name }}</span>
<span class="text-white/40 text-xs font-mono truncate">{{ truncateDid(selectedIdentity.did) }}</span>
</div>
<div v-else class="flex-1 text-white/50">Select identity...</div>
<!-- Chevron -->
<svg class="w-4 h-4 text-white/40 shrink-0 transition-transform" :class="{ 'rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="content-fade">
<div v-if="isOpen" class="absolute left-0 right-0 mt-1 z-20 glass-card p-1 rounded-lg max-h-48 overflow-y-auto">
<div v-if="loading" class="p-3 text-center text-white/50 text-sm">Loading...</div>
<div v-else-if="identities.length === 0" class="p-3 text-center text-white/50 text-sm">No identities</div>
<button
v-for="id in identities"
:key="id.id"
@click="selectIdentity(id)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-white/80 hover:bg-white/10 transition-colors"
:class="{ 'bg-white/10': modelValue === id.id }"
>
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="purposeColor(id.purpose)">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-1">
<span class="truncate">{{ id.name }}</span>
<span v-if="id.is_default" class="text-yellow-400 text-xs">&#9733;</span>
</div>
<p class="text-white/40 text-xs font-mono truncate">{{ truncateDid(id.did) }}</p>
</div>
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { rpcClient } from '@/api/rpc-client'
interface Identity {
id: string
name: string
purpose: string
pubkey: string
did: string
is_default: boolean
}
const props = defineProps<{
modelValue?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', id: string): void
(e: 'select', identity: Identity): void
}>()
const isOpen = ref(false)
const loading = ref(false)
const identities = ref<Identity[]>([])
const pickerRef = ref<HTMLElement | null>(null)
const selectedIdentity = computed(() =>
identities.value.find(i => i.id === props.modelValue)
)
function purposeColor(purpose: string): string {
switch (purpose) {
case 'personal': return 'bg-blue-500/20 text-blue-400'
case 'business': return 'bg-orange-500/20 text-orange-400'
case 'anonymous': return 'bg-purple-500/20 text-purple-400'
default: return 'bg-white/10 text-white/60'
}
}
function truncateDid(did: string): string {
if (did.length <= 30) return did
return did.slice(0, 18) + '...' + did.slice(-8)
}
function selectIdentity(id: Identity) {
emit('update:modelValue', id.id)
emit('select', id)
isOpen.value = false
}
function onClickOutside(e: MouseEvent) {
if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
async function loadIdentities() {
loading.value = true
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
// Auto-select default if no value set
if (!props.modelValue) {
const defaultId = identities.value.find(i => i.is_default)
if (defaultId) {
emit('update:modelValue', defaultId.id)
emit('select', defaultId)
}
}
} catch {
identities.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadIdentities()
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
@@ -0,0 +1,120 @@
<template>
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
<div class="flex items-center gap-4 mb-5 -mt-2">
<img
v-if="app?.icon"
:src="app.icon"
:alt="app?.title || ''"
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
/>
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<h3 class="text-xl font-semibold text-white leading-snug">
{{ t('marketplace.installModalTitle', { name: app?.title || appId }) }}
</h3>
</div>
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
<div v-else class="space-y-2">
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<select
v-model="selected"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
</select>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div>
<template #footer>
<div class="flex gap-2 mt-6">
<button
type="button"
class="flex-1 glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-semibold py-2.5"
:disabled="loading || !selected"
@click="confirm"
>
{{ t('common.install') }}
</button>
<button
type="button"
class="rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium px-5 py-2.5 transition-colors"
@click="emit('close')"
>
{{ t('common.cancel') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from './BaseModal.vue'
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
import { displayVersion } from '@/utils/version'
const props = defineProps<{
show: boolean
appId: string
app: { id: string; title?: string; icon?: string } | null
}>()
const emit = defineEmits<{
close: []
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
confirm: [version: string]
}>()
const { t } = useI18n()
const loading = ref(false)
const versions = ref<CatalogVersionInfo[]>([])
const selected = ref('')
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
function optionLabel(v: CatalogVersionInfo): string {
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
let label = displayVersion(v.version)
if (v.deprecated) label += ' (deprecated)'
if (v.eol) label += ` · EOL ${v.eol}`
return label
}
async function load() {
loading.value = true
versions.value = []
selected.value = ''
try {
const info = await rpcClient.getPackageVersions(props.appId)
// catalog_versions() returns the list default(=latest)-first, so versions[0]
// is the latest — pre-select it.
versions.value = info.versions || []
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
} catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed.
selected.value = 'latest'
} finally {
loading.value = false
}
}
function confirm() {
if (!selected.value) return
emit('confirm', selected.value)
}
watch(
() => props.show,
(open) => {
if (open) void load()
},
{ immediate: true },
)
</script>
@@ -0,0 +1,432 @@
<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>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Open Channel
</button>
</div>
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">Loading channels...</p>
</div>
<!-- Error -->
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-white/70 mb-2">No channels yet</p>
<p class="text-white/50 text-sm">Open a channel to start sending and receiving Lightning payments.</p>
</div>
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing channels...
</div>
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ error }}
</div>
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
:class="{ 'bg-white/5': compact }"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
Close
</button>
</div>
<!-- Peer -->
<p class="text-white/50 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<!-- Capacity Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-white/60 mb-1">
<span>Local: {{ formatSats(ch.local_balance) }}</span>
<span>Remote: {{ formatSats(ch.remote_balance) }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full overflow-hidden flex">
<div
class="bg-blue-400 h-full transition-all"
:style="{ width: capacityPercent(ch.local_balance, ch.capacity) + '%' }"
></div>
<div
class="bg-orange-400 h-full transition-all"
:style="{ width: capacityPercent(ch.remote_balance, ch.capacity) + '%' }"
></div>
</div>
<p class="text-white/40 text-xs mt-1 text-center">
Capacity: {{ formatSats(ch.capacity) }}
</p>
</div>
<!-- Funding tx -->
<div v-if="fundingTxid(ch)" class="flex justify-end">
<button
@click="openInMempool(fundingTxid(ch))"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="fundingTxid(ch)"
>
Funding tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Peer URI</label>
<input
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input
v-model.number="openForm.amount"
type="number"
min="20000"
placeholder="100000"
class="w-full input-glass"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
<!-- Fee selection -->
<div>
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="openForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="openForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="openForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === openForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="openForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="openForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one sats per vByte takes precedence when both are set</p>
</div>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="openForm.private" type="checkbox" class="accent-blue-500" />
<span class="text-white/60 text-sm">Private channel (unannounced)</span>
</label>
<p class="text-white/40 text-xs mt-1">Some nodes (e.g. LSPs like Olympus) only accept unannounced channels</p>
</div>
</div>
<div v-if="openError" class="mt-3 alert-error">
<p class="text-xs">{{ openError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showOpenModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
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
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' },
]
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
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,
})
const openForm = ref(defaultOpenForm())
const openingChannel = ref(false)
const openError = ref<string | null>(null)
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 : ''
}
function openInMempool(txid: string) {
if (!txid) return
// Overlay the explorer above the current page — never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
}
function capacityPercent(amount: number, capacity: number): number {
if (capacity <= 0) return 0
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
}
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
const form = openForm.value
if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
openError.value = 'Custom fee requires target confirmations or sats per vByte'
return null
}
async function openChannel() {
if (openingChannel.value) return
openError.value = null
const uri = openForm.value.peerUri.trim()
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
const fee = feeParams()
if (!fee) return
const parts = uri.split('@')
const pubkey = parts[0]
const address = parts[1] || undefined
openingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount, private: openForm.value.private, ...fee },
// Server may wait up to 35s for a synchronous peer connect before opening
timeout: 60000,
})
showOpenModal.value = false
openForm.value = defaultOpenForm()
await loadChannels()
} catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel'
} finally {
openingChannel.value = false
}
}
function confirmClose(ch: Channel) {
closeTarget.value = ch
closeError.value = null
}
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
})
closeTarget.value = null
await loadChannels()
} catch (err: unknown) {
closeError.value = err instanceof Error ? err.message : 'Failed to close channel'
} finally {
closingChannel.value = false
}
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>
+172
View File
@@ -0,0 +1,172 @@
<template>
<canvas
ref="canvasRef"
class="monitoring-chart"
:width="width"
:height="height"
></canvas>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
export interface ChartDataset {
label: string
data: number[]
color: string
}
const props = withDefaults(
defineProps<{
datasets: ChartDataset[]
labels?: string[]
width?: number
height?: number
yMax?: number
yLabel?: string
showGrid?: boolean
}>(),
{
width: 400,
height: 180,
showGrid: true,
},
)
const canvasRef = ref<HTMLCanvasElement | null>(null)
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = props.width * dpr
canvas.height = props.height * dpr
canvas.style.width = `${props.width}px`
canvas.style.height = `${props.height}px`
ctx.scale(dpr, dpr)
const w = props.width
const h = props.height
const pad = { top: 10, right: 12, bottom: 24, left: 44 }
const plotW = w - pad.left - pad.right
const plotH = h - pad.top - pad.bottom
// Clear
ctx.clearRect(0, 0, w, h)
if (!props.datasets.length || !props.datasets[0]?.data.length) {
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.font = '12px system-ui'
ctx.textAlign = 'center'
ctx.fillText('No data yet', w / 2, h / 2)
return
}
// Compute y range
let yMax = props.yMax ?? 0
if (!yMax) {
for (const ds of props.datasets) {
for (const v of ds.data) {
if (v > yMax) yMax = v
}
}
yMax = yMax * 1.1 || 1
}
const maxPoints = Math.max(...props.datasets.map((d) => d.data.length))
// Grid lines
if (props.showGrid) {
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
ctx.lineWidth = 1
const gridCount = 4
for (let i = 0; i <= gridCount; i++) {
const y = pad.top + (plotH / gridCount) * i
ctx.beginPath()
ctx.moveTo(pad.left, y)
ctx.lineTo(pad.left + plotW, y)
ctx.stroke()
}
// Y-axis labels
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '10px system-ui'
ctx.textAlign = 'right'
for (let i = 0; i <= gridCount; i++) {
const y = pad.top + (plotH / gridCount) * i
const val = yMax - (yMax / gridCount) * i
ctx.fillText(formatValue(val), pad.left - 6, y + 3)
}
}
// Draw each dataset
for (const ds of props.datasets) {
if (!ds.data.length) continue
ctx.strokeStyle = ds.color
ctx.lineWidth = 1.5
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.beginPath()
for (let i = 0; i < ds.data.length; i++) {
const x = pad.left + (i / Math.max(maxPoints - 1, 1)) * plotW
const y = pad.top + plotH - (ds.data[i]! / yMax) * plotH
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
// Area fill
ctx.globalAlpha = 0.08
ctx.fillStyle = ds.color
ctx.lineTo(pad.left + ((ds.data.length - 1) / Math.max(maxPoints - 1, 1)) * plotW, pad.top + plotH)
ctx.lineTo(pad.left, pad.top + plotH)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1.0
}
// X-axis labels (first, middle, last)
if (props.labels && props.labels.length > 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)'
ctx.font = '10px system-ui'
ctx.textAlign = 'center'
const indices = [0, Math.floor(props.labels.length / 2), props.labels.length - 1]
for (const idx of indices) {
if (idx >= 0 && idx < props.labels.length) {
const x = pad.left + (idx / Math.max(props.labels.length - 1, 1)) * plotW
ctx.fillText(props.labels[idx]!, pad.left + plotW + pad.right > w ? x : x, h - 6)
}
}
}
}
function formatValue(val: number): string {
if (val >= 1_000_000_000) return `${(val / 1_000_000_000).toFixed(1)}G`
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`
return val.toFixed(val < 10 ? 1 : 0)
}
watch(
() => [props.datasets, props.labels, props.width, props.height],
() => draw(),
{ deep: true },
)
onMounted(() => {
draw()
window.addEventListener('resize', draw)
})
onUnmounted(() => {
window.removeEventListener('resize', draw)
})
</script>
@@ -0,0 +1,183 @@
<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() {
router.push(LND_DETAILS_PATH).catch(() => {})
}
function snooze() {
const until = Date.now() + SNOOZE_MS
snoozedUntil.value = until
try { localStorage.setItem(SNOOZE_KEY, String(until)) } catch { /* noop */ }
}
onBeforeUnmount(stopPolling)
</script>
<template>
<Teleport to="body">
<Transition name="seed-banner">
<div
v-if="show"
role="alert"
class="fixed bottom-4 left-4 right-4 z-[95] mx-auto w-auto max-w-lg rounded-xl border border-orange-400/30 p-4 seed-banner-glass md:left-auto md:right-6 md:bottom-6"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">Back up your Lightning wallet seed</p>
<p class="mt-0.5 text-sm text-white/70">
Your Lightning wallet has a recovery seed that hasn't been backed up yet.
Write it down now so your funds survive a disk failure.
</p>
<div class="mt-3 flex gap-2">
<button
type="button"
class="glass-button glass-button-warning rounded-lg px-4 py-2 text-sm font-medium"
@click="backUpNow"
>Back up now</button>
<button
type="button"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="snooze"
>Later</button>
</div>
</div>
<button
type="button"
aria-label="Dismiss for now"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
@click="snooze"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.seed-banner-glass {
background: rgba(15, 15, 20, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.seed-banner-enter-active,
.seed-banner-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.seed-banner-enter-from,
.seed-banner-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+666
View File
@@ -0,0 +1,666 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import type { NodePosition } from '@/stores/mesh'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const mesh = useMeshStore()
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
const markersLayer = ref<L.LayerGroup | null>(null)
const linesLayer = ref<L.LayerGroup | null>(null)
// Whether we have any position data to show
const hasPositions = computed(() => mesh.nodePositions.size > 0 || mesh.federatedPositions.size > 0)
// Location sharing state
const sharingLocation = ref(false)
const locationSource = ref<'browser' | 'device'>('browser')
const locationError = ref('')
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
let geoWatchId: number | null = null
function toggleLocationSharing() {
if (sharingLocation.value) {
stopSharing()
} else {
startSharing()
}
}
function switchSource(source: 'browser' | 'device') {
locationSource.value = source
if (sharingLocation.value) {
stopSharing()
startSharing()
}
}
function startSharing() {
locationError.value = ''
if (locationSource.value === 'browser') {
if (!navigator.geolocation) {
locationError.value = 'Geolocation not supported'
return
}
geoWatchId = navigator.geolocation.watchPosition(
(pos) => {
mesh.updateSelfPosition(pos.coords.latitude, pos.coords.longitude, 'This Node')
sharingLocation.value = true
locationError.value = ''
},
(err) => {
locationError.value = err.code === 1 ? 'Location permission denied' : err.message
sharingLocation.value = false
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 },
)
sharingLocation.value = true
} else {
// Device GPS — position data comes from deadman/mesh GPS module
sharingLocation.value = true
}
}
function stopSharing() {
if (geoWatchId !== null) {
navigator.geolocation.clearWatch(geoWatchId)
geoWatchId = null
}
sharingLocation.value = false
mesh.nodePositions.delete(-1)
}
function createMarkerIcon(type: 'self' | 'online' | 'offline'): L.DivIcon {
const colorMap = {
self: { bg: '#fb923c', border: '#f59e0b', shadow: 'rgba(251,146,60,0.5)' },
online: { bg: '#4ade80', border: '#22c55e', shadow: 'rgba(74,222,128,0.4)' },
offline: { bg: '#6b7280', border: '#4b5563', shadow: 'rgba(107,114,128,0.3)' },
}
const c = colorMap[type]
const size = type === 'self' ? 16 : 12
const pulse = type === 'self'
? `<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:${size + 12}px;height:${size + 12}px;border-radius:50%;background:${c.shadow};animation:mesh-map-pulse 2s infinite;"></div>`
: ''
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size + 12, size + 12],
iconAnchor: [(size + 12) / 2, (size + 12) / 2],
popupAnchor: [0, -(size / 2 + 6)],
html: `
${pulse}
<div style="
width:${size}px;height:${size}px;
border-radius:50%;
background:${c.bg};
border:2px solid ${c.border};
box-shadow:0 0 8px ${c.shadow};
position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%);
z-index:2;
"></div>
`,
})
}
/// Marker for an Archipelago node (federation peer) — the little Archy logo
/// in a glowing badge, distinct from the plain colored dots used for raw
/// LoRa mesh-radio peers. Trusted nodes get the warm orange ring/glow the
/// rest of the map already uses for "this is us/ours"; Observer nodes get a
/// cooler blue so the trust boundary is visible at a glance.
function createFederatedMarkerIcon(trusted: boolean): L.DivIcon {
const ring = trusted ? '#fb923c' : '#38bdf8'
const glow = trusted ? 'rgba(251,146,60,0.55)' : 'rgba(56,189,248,0.5)'
const size = 30
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 2)],
html: `
<div style="
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:${size + 14}px; height:${size + 14}px; border-radius:50%;
background:${glow}; animation:mesh-map-pulse 2.4s infinite;
"></div>
<div style="
width:${size}px; height:${size}px; border-radius:9px;
background:#0b0d14; border:2px solid ${ring};
box-shadow:0 0 10px ${glow}, 0 2px 6px rgba(0,0,0,0.5);
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
display:flex; align-items:center; justify-content:center;
z-index:3; overflow:hidden;
">
<img src="/assets/icon/apple-touch-icon-180x180-v2.png" width="${size - 6}" height="${size - 6}"
style="border-radius:6px;object-fit:cover;" alt="" />
</div>
`,
})
}
function buildFederatedPopupContent(name: string, onion: string, trusted: boolean): string {
const badge = trusted
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">TRUSTED</span>'
: '<span style="display:inline-block;background:rgba(56,189,248,0.2);color:#38bdf8;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">OBSERVER</span>'
const onionShort = onion.length > 20 ? `${onion.slice(0, 10)}...${onion.slice(-6)}` : onion
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:170px;">
<div style="display:flex;align-items:center;gap:6px;font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
📡 ${name}${badge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:6px;word-break:break-all;">
${onionShort}
</div>
<div style="font-size:0.78rem;color:rgba(255,255,255,0.6);">Archipelago node</div>
</div>
`
}
function getSignalBars(rssi: number | null): string {
if (rssi === null) return 'Unknown'
if (rssi >= -70) return 'Strong'
if (rssi >= -90) return 'Good'
if (rssi >= -110) return 'Weak'
return 'Very Weak'
}
function formatLastHeard(timestamp: string): string {
const now = Date.now()
const then = new Date(timestamp).getTime()
const diffSecs = Math.floor((now - then) / 1000)
if (diffSecs < 60) return 'Just now'
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`
return `${Math.floor(diffSecs / 86400)}d ago`
}
function truncatePubkey(pubkey: string | null): string {
if (!pubkey) return 'No pubkey'
if (pubkey.length <= 16) return pubkey
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
function buildPopupContent(
name: string,
pubkey: string | null,
rssi: number | null,
lastHeard: string,
hops: number,
isSelf: boolean,
): string {
const signal = getSignalBars(rssi)
const heard = formatLastHeard(lastHeard)
const truncPk = truncatePubkey(pubkey)
const selfBadge = isSelf
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">THIS NODE</span>'
: ''
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:160px;">
<div style="font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
${name}${selfBadge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:8px;word-break:break-all;">
${truncPk}
</div>
<div style="display:flex;flex-direction:column;gap:3px;font-size:0.78rem;">
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Signal: <span style="color:${rssi !== null && rssi >= -90 ? '#4ade80' : '#fbbf24'};">${signal}</span>${rssi !== null ? ` (${rssi} dBm)` : ''}</div>` : ''}
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Hops: <span style="color:rgba(255,255,255,0.9);">${hops}</span></div>` : ''}
<div style="color:rgba(255,255,255,0.7);">Last heard: <span style="color:rgba(255,255,255,0.9);">${heard}</span></div>
</div>
</div>
`
}
function initMap() {
if (!mapContainer.value || map) return
const el = mapContainer.value
const rect = el.getBoundingClientRect()
// If container has no height yet, retry
if (rect.height < 10) {
setTimeout(initMap, 150)
return
}
map = L.map(el, {
zoomControl: true,
attributionControl: true,
center: [30, 0],
zoom: 3,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19,
detectRetina: true,
}).addTo(map)
markersLayer.value = L.layerGroup().addTo(map)
linesLayer.value = L.layerGroup().addTo(map)
// Give Leaflet a frame to measure, then invalidate
requestAnimationFrame(() => {
if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
// Style attribution for dark theme
const attrib = el.querySelector('.leaflet-control-attribution')
if (attrib instanceof HTMLElement) {
attrib.style.background = 'rgba(0,0,0,0.6)'
attrib.style.color = 'rgba(255,255,255,0.4)'
attrib.style.fontSize = '0.65rem'
}
updateMarkers()
}
function updateMarkers() {
if (!map || !markersLayer.value || !linesLayer.value) return
markersLayer.value.clearLayers()
linesLayer.value.clearLayers()
const positions = mesh.nodePositions
const fedPositions = mesh.federatedPositions
if (positions.size === 0 && fedPositions.size === 0) return
const bounds: L.LatLngExpression[] = []
const selfPos = positions.get(-1)
// Find which contact_ids are in the peers list (for online status)
const peerMap = new Map(mesh.peers.map(p => [p.contact_id, p]))
positions.forEach((pos: NodePosition, contactId: number) => {
const isSelf = contactId === -1
const peer = peerMap.get(contactId)
const isOnline = isSelf || !!peer
const marker = L.marker([pos.lat, pos.lng], {
icon: createMarkerIcon(isSelf ? 'self' : isOnline ? 'online' : 'offline'),
})
const name = isSelf
? (mesh.status?.self_advert_name ?? 'This Node')
: (peer?.advert_name ?? pos.label ?? `Node ${contactId}`)
const pubkey = isSelf ? null : (peer?.pubkey_hex ?? null)
const rssi = peer?.rssi ?? null
const lastHeard = isSelf ? new Date().toISOString() : (peer?.last_heard ?? pos.timestamp)
const hops = peer?.hops ?? 0
marker.bindPopup(buildPopupContent(name, pubkey, rssi, lastHeard, hops, isSelf), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([pos.lat, pos.lng])
// Draw dashed line from self to each connected peer
if (!isSelf && selfPos) {
const line = L.polyline(
[[selfPos.lat, selfPos.lng], [pos.lat, pos.lng]],
{
color: isOnline ? 'rgba(74,222,128,0.4)' : 'rgba(107,114,128,0.3)',
weight: 1.5,
dashArray: '6, 8',
opacity: 0.7,
},
)
linesLayer.value!.addLayer(line)
}
})
// Archipelago nodes (federation peers who opted into location sharing) —
// own logo marker, no signal/hops (that's a radio-peer concept).
fedPositions.forEach((fed) => {
const marker = L.marker([fed.lat, fed.lng], {
icon: createFederatedMarkerIcon(fed.trusted),
zIndexOffset: 500,
})
marker.bindPopup(buildFederatedPopupContent(fed.name ?? 'Archipelago Node', fed.onion, fed.trusted), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([fed.lat, fed.lng])
})
// Fit map to show all markers
if (bounds.length > 1) {
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 })
} else if (bounds.length === 1 && bounds[0]) {
map.setView(bounds[0], 12)
}
}
function handleResize() {
if (map) {
map.invalidateSize()
}
}
// Watch for changes in node positions and peers
watch(
() => [mesh.nodePositions.size, mesh.peers.length, mesh.federatedPositions.size],
() => {
updateMarkers()
},
)
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
window.addEventListener('resize', handleResize)
if (mapContainer.value) {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { height } = entry.contentRect
if (!map && height > 10) {
initMap()
} else if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
resizeObserver.observe(mapContainer.value)
}
// Fallback init
setTimeout(initMap, 300)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stopSharing()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (map) {
map.remove()
map = null
}
markersLayer.value = null
linesLayer.value = null
})
</script>
<template>
<div class="mesh-map-outer">
<div ref="mapContainer" class="mesh-map-inner"></div>
<!-- Floating hint when no positions -->
<div v-if="!hasPositions && !sharingLocation" class="mesh-map-hint">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span>{{ locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' }}</span>
</div>
<!-- Location sharing overlay -->
<div class="mesh-map-location-bar">
<div class="mesh-map-location-row">
<svg class="mesh-map-location-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span class="mesh-map-location-label">Share Location</span>
<button
class="mesh-map-toggle"
:class="{ active: sharingLocation }"
role="switch"
:aria-checked="sharingLocation"
@click="toggleLocationSharing"
>
<span class="mesh-map-toggle-knob" />
</button>
</div>
<!-- Source selector (visible when sharing and device GPS available) -->
<div v-if="sharingLocation && hasDeviceGps" class="mesh-map-source-row">
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'browser' }"
@click="switchSource('browser')"
>This Machine</button>
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'device' }"
@click="switchSource('device')"
>Mesh Radio GPS</button>
</div>
<div v-if="locationError" class="mesh-map-location-error">
{{ locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError }}
</div>
</div>
</div>
</template>
<style>
/* Must be unscoped — Leaflet creates DOM nodes dynamically */
/* CRITICAL: Override Tailwind's img { max-width: 100% } which breaks Leaflet tiles */
.mesh-map-inner img {
max-width: none !important;
max-height: none !important;
}
.mesh-map-outer {
width: 100%;
height: 100%;
min-height: 420px;
position: relative;
overflow: hidden;
}
.mesh-map-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a2e;
}
/* Leaflet adds .leaflet-container to the element itself (not a child) */
.mesh-map-inner.leaflet-container {
background: #1a1a2e;
}
.mesh-map-hint {
position: absolute;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 400;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
white-space: nowrap;
pointer-events: none;
}
/* ─── Location sharing overlay ─── */
.mesh-map-location-bar {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 500;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mesh-map-location-row {
display: flex;
align-items: center;
gap: 8px;
}
.mesh-map-location-icon {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.mesh-map-location-label {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
flex: 1;
}
/* Toggle switch */
.mesh-map-toggle {
width: 36px;
height: 20px;
/* The global mobile rule forces buttons to min-height:44px, which stretches
this switch and pushes the knob off-centre. Pin it back to the pill size. */
min-height: 20px !important;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
position: relative;
padding: 0;
flex-shrink: 0;
transition: all 0.25s ease;
}
.mesh-map-toggle.active {
background: rgba(251, 146, 60, 0.35);
border-color: rgba(251, 146, 60, 0.5);
}
.mesh-map-toggle-knob {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
position: absolute;
top: 2px;
left: 2px;
transition: all 0.25s ease;
}
.mesh-map-toggle.active .mesh-map-toggle-knob {
left: 18px;
background: #fb923c;
}
/* Source selector */
.mesh-map-source-row {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 2px;
}
.mesh-map-source-btn {
flex: 1;
padding: 4px 8px;
font-size: 0.7rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.mesh-map-source-btn:hover {
color: rgba(255, 255, 255, 0.7);
}
.mesh-map-source-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.mesh-map-location-error {
font-size: 0.7rem;
color: #ef4444;
}
</style>
<style>
/* Global styles for Leaflet popup theming - must not be scoped */
.mesh-map-popup .leaflet-popup-content-wrapper {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: #fff;
}
.mesh-map-popup .leaflet-popup-tip {
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mesh-map-popup .leaflet-popup-close-button {
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
padding: 4px 8px;
}
.mesh-map-popup .leaflet-popup-close-button:hover {
color: rgba(255, 255, 255, 0.9);
}
/* Marker wrapper reset */
.mesh-map-marker-wrapper {
background: none !important;
border: none !important;
}
/* Pulse animation for self marker */
@keyframes mesh-map-pulse {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
}
/* Dark theme for Leaflet zoom controls */
.mesh-map-inner .leaflet-control-zoom a {
background: rgba(0, 0, 0, 0.7) !important;
color: rgba(255, 255, 255, 0.8) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.mesh-map-inner .leaflet-control-zoom a:hover {
background: rgba(0, 0, 0, 0.85) !important;
color: #fff !important;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<!-- Compact mode: small pill for chat fullscreen -->
<div v-if="compact" class="chat-mode-pill-inner" @click="handleCompactClick">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="text-xs font-medium">{{ currentLabel }}</span>
</div>
<!-- Full mode switcher -->
<div v-else class="mode-switcher mode-switcher-full">
<button
v-for="m in modes"
:key="m.id"
@click="uiMode.setMode(m.id)"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': uiMode.mode === m.id }"
>
{{ m.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUIModeStore } from '@/stores/uiMode'
import type { UIMode } from '@/types/api'
const props = defineProps<{
compact?: boolean
}>()
const uiMode = useUIModeStore()
const router = useRouter()
const modes: { id: UIMode; label: string }[] = [
{ id: 'easy', label: 'Easy' },
{ id: 'gamer', label: 'Pro' },
]
const currentLabel = computed(() => {
const found = modes.find(m => m.id === uiMode.mode)
return found ? found.label : 'Pro'
})
function handleCompactClick() {
const newMode = uiMode.cycleMode()
router.push(newMode === 'chat' ? '/dashboard/chat' : '/dashboard')
}
</script>
@@ -0,0 +1,322 @@
<template>
<Teleport to="body">
<Transition name="identity-picker">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<!-- Main panel -->
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
:aria-label="`Select identity for ${appName}`"
class="relative z-10 w-full max-w-lg"
>
<!-- Header: screensaver-style glass disc + radial viz ring -->
<div class="relative mb-6 flex flex-col items-center">
<div class="nostr-hero">
<!-- Radial viz segments exact screensaver pattern, 48 bars, #FAFAFA -->
<div class="nostr-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="nostr-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<!-- Glass disc exact logo-gradient-border from screensaver -->
<div class="nostr-glass-border">
<div class="nostr-glass-inner">
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
</div>
<div v-else-if="identities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div>
<button
v-for="identity in identities"
:key="identity.id"
type="button"
role="radio"
:aria-checked="selectedId === identity.id"
:aria-label="`Identity: ${identity.name}`"
class="w-full text-left p-3 rounded-lg transition-all duration-200"
:class="selectedId === identity.id
? 'bg-white/10 ring-1 ring-white/20'
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
@click="selectedId = identity.id"
>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
:class="avatarClasses(identity.purpose)"
>
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ identity.name }}</span>
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">default</span>
</div>
<div class="mt-0.5">
<span v-if="identity.nostr_npub" class="text-white/35 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
<span v-else class="text-red-400/60 text-xs">No Nostr key</span>
</div>
</div>
<div class="shrink-0">
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
</div>
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
</div>
</div>
</button>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
Cancel
</button>
<button
@click="confirm"
:disabled="!selectedId || !hasNostrKey"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="selectedId && hasNostrKey
? 'bg-white/10 text-white hover:bg-white/15'
: 'bg-white/[0.03] text-white/40'"
>
Authenticate
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
NIP-07 &middot; SECP256K1 &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { rpcClient } from '@/api/rpc-client'
interface Identity {
id: string
name: string
purpose: string
pubkey: string
did: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
}
const props = defineProps<{
show: boolean
appName: string
}>()
const emit = defineEmits<{
select: [identity: Identity]
cancel: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const identities = ref<Identity[]>([])
const selectedId = ref<string | null>(null)
const loading = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null
})
watch(() => props.show, async (open) => {
if (open) await loadIdentities()
})
onMounted(() => {
if (props.show) loadIdentities()
})
async function loadIdentities() {
loading.value = true
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id
} catch {
identities.value = []
} finally {
loading.value = false
}
}
function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value)
if (selected) emit('select', selected)
}
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
function avatarClasses(purpose: string): string {
switch (purpose) {
case 'business': return 'bg-blue-500/15 text-blue-400'
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
default: return 'bg-white/10 text-white/80'
}
}
</script>
<style scoped>
/* ── Hero container ── */
.nostr-hero {
position: relative;
width: 148px;
height: 148px;
}
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
.nostr-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.nostr-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(-60px);
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
@keyframes seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
}
/* ── Glass disc — exact screensaver logo-gradient-border ── */
.nostr-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.nostr-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Cinema icon — breathing glow ── */
.nostr-cinema-svg {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
animation: cinema-breathe 4s ease-in-out infinite;
}
@keyframes cinema-breathe {
0%, 100% {
opacity: 0.7;
transform: scale(1);
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
}
50% {
opacity: 1;
transform: scale(1.08);
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
}
}
/* ── Modal transitions ── */
.identity-picker-enter-active,
.identity-picker-leave-active {
transition: opacity 0.4s ease;
}
.identity-picker-enter-active > .relative {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.identity-picker-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.identity-picker-enter-from { opacity: 0; }
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.identity-picker-leave-to { opacity: 0; }
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>
@@ -0,0 +1,152 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="deny"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-md w-full relative z-10"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Nostr Signing Request</h3>
<button
@click="deny"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3 mb-6">
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">App</p>
<p class="text-white text-sm font-medium">{{ appName }}</p>
</div>
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Method</p>
<p class="text-white text-sm font-medium">{{ method }}</p>
</div>
<div v-if="contentPreview" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Content</p>
<p class="text-white/80 text-sm font-mono break-all">{{ contentPreview }}</p>
</div>
<div v-if="eventKind !== undefined" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Event Kind</p>
<p class="text-white text-sm font-medium">{{ eventKind }} <span class="text-white/50">({{ eventKindLabel }})</span></p>
</div>
</div>
<label class="flex items-center gap-2 mb-4 cursor-pointer">
<input
v-model="rememberChoice"
type="checkbox"
class="w-4 h-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50"
/>
<span class="text-white/70 text-sm">Remember for this app</span>
</label>
<div class="flex gap-3">
<button @click="deny" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium">
Deny
</button>
<button @click="approve" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30">
Approve
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Contacts',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
9734: 'Zap Request',
9735: 'Zap Receipt',
10002: 'Relay List',
30023: 'Long-form Content',
}
const props = defineProps<{
show: boolean
appName: string
method: string
eventKind?: number
content?: string
}>()
const emit = defineEmits<{
approve: [remember: boolean]
deny: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const rememberChoice = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('deny'))
const contentPreview = computed(() => {
if (!props.content) return ''
return props.content.length > 200 ? props.content.slice(0, 200) + '...' : props.content
})
const eventKindLabel = computed(() => {
if (props.eventKind === undefined) return ''
return EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown'
})
function approve() {
emit('approve', rememberChoice.value)
}
function deny() {
emit('deny')
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>
@@ -0,0 +1,25 @@
<template>
<button
type="button"
data-controller-ignore
class="w-full flex items-center gap-2 text-white/80 hover:text-white transition-colors"
title="Open CLI (F)"
@click="openCLI"
>
<div class="relative shrink-0">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
</div>
<span class="text-xs font-medium">Online</span>
</button>
</template>
<script setup lang="ts">
import { useCLIStore } from '@/stores/cli'
const cliStore = useCLIStore()
function openCLI() {
cliStore.open()
}
</script>
@@ -0,0 +1,92 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="showInstallPrompt"
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-6 md:bottom-6 md:max-w-sm z-[9998]"
>
<div class="glass-card p-4 flex items-center gap-4 shadow-xl">
<img
src="/assets/icon/pwa-192x192-v2.png"
alt="Archipelago"
class="w-14 h-14 rounded-xl shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="text-white font-medium">Install Archipelago</p>
<p class="text-white/70 text-sm">Add to your home screen for quick access</p>
</div>
<div class="flex gap-2 shrink-0">
<button
@click="dismiss"
class="px-3 py-2 text-sm text-white/70 hover:text-white transition-colors"
>
Not now
</button>
<button
@click="install"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Install
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const showInstallPrompt = ref(false)
let deferredPrompt: { prompt: () => Promise<{ outcome: string }> } | null = null
const DISMISS_KEY = 'archipelago_pwa_install_dismissed'
onMounted(() => {
// Don't show in kiosk mode, if already dismissed, or if already installed
if (localStorage.getItem('kiosk') === 'true') return
if (sessionStorage.getItem(DISMISS_KEY) === '1') return
if (window.matchMedia('(display-mode: standalone)').matches) return
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
const handler = (e: Event) => {
e.preventDefault()
deferredPrompt = e as unknown as { prompt: () => Promise<{ outcome: string }> }
showInstallPrompt.value = true
}
window.addEventListener('beforeinstallprompt', handler)
;(window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler = handler
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', (window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler as EventListener)
})
function dismiss() {
showInstallPrompt.value = false
sessionStorage.setItem(DISMISS_KEY, '1')
}
async function install() {
if (!deferredPrompt) return
const result = await deferredPrompt.prompt()
const outcome = result?.outcome ?? 'dismissed'
showInstallPrompt.value = false
deferredPrompt = null
if (outcome === 'accepted') {
sessionStorage.removeItem(DISMISS_KEY)
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,98 @@
<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'
const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null
onMounted(() => {
// Listen for service worker updates
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Service worker updated, reload the page
window.location.reload()
})
// Check for updates periodically
const checkForUpdates = async () => {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
await registration.update()
}
}
// Check for updates every 5 minutes
setInterval(checkForUpdates, 5 * 60 * 1000)
// Check when user returns to tab (helps with cached PWA)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkForUpdates()
}
})
// Listen for updatefound event
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration) {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
updateCallback = async () => {
if (newWorker.state === 'installed' && registration.waiting) {
// Skip waiting and activate the new service worker
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
}
}
// The kiosk is unattended — nobody can click "Update Now",
// so it kept running stale bundles forever (stale FIPS state,
// old assets). Auto-apply there; prompt everywhere else.
if (localStorage.getItem('kiosk')) {
updateCallback()
} else {
showUpdatePrompt.value = true
}
}
})
}
})
}
})
}
})
function dismissUpdate() {
showUpdatePrompt.value = false
}
async function handleUpdate() {
if (updateCallback) {
await updateCallback()
}
}
</script>
@@ -0,0 +1,177 @@
<template>
<BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] as const)"
:key="m"
@click="receiveMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : m === 'ecash' ? t('receiveBitcoin.ecash') : 'Ark' }}</button>
</div>
<!-- Lightning -->
<div v-if="receiveMethod === 'lightning'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.amountSats') }}</label>
<input v-model.number="invoiceAmount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div>
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<button @click="copyText(invoiceResult)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
</div>
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<button @click="copyText(onchainAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</div>
<!-- Ark -->
<div v-if="receiveMethod === 'ark'">
<div v-if="arkAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">Your Ark address</p>
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
</div>
</div>
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="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 } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n()
defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; received: [] }>()
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')
const invoiceResult = ref('')
const onchainAddress = ref('')
const arkAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false)
const error = ref('')
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
if (!canvas || !data) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(canvas, prefix ? `${prefix}${data}` : data, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
})
} catch { /* QR rendering failed silently */ }
}
function close() {
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
error.value = ''
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function receive() {
processing.value = true
error.value = ''
try {
if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
})
invoiceResult.value = res.payment_request
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
if (!res.address) {
throw new Error('LND did not return a Bitcoin address')
}
onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else if (receiveMethod.value === 'ark') {
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
if (!res.address) throw new Error('barkd did not return an Ark address')
arkAddress.value = res.address
nextTick(() => renderQr(res.address, arkQrCanvas.value))
} else {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
// redeemed at its mint, anything else is reissued as Fedimint notes.
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
})
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashResult.value = res.received_sats != null
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
: t('receiveBitcoin.tokenReceivedSuccess')
emit('received')
}
} catch (err: unknown) {
error.value = receiveMethod.value === 'onchain'
? explainReceiveAddressFailure(err)
: err instanceof Error ? err.message : 'Failed'
} finally {
processing.value = false
}
}
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<Teleport to="body">
<Transition name="screensaver">
<div
v-if="store.isActive"
class="screensaver-container fixed inset-0 z-[3000] bg-black cursor-pointer"
@click="store.deactivate()"
@keydown.escape="store.deactivate()"
>
<!-- ASCII variant (every 3rd activation) -->
<div v-if="store.isAsciiMode" class="screensaver-ascii-content">
<BitcoinFaceAscii />
</div>
<!-- Normal logo with audio viz ring -->
<div v-else class="screensaver-content">
<ScreensaverRing />
<!-- Logo in center -->
<div class="screensaver-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
import { useScreensaverStore } from '@/stores/screensaver'
const store = useScreensaverStore()
// Dismiss on any key (except when typing)
function onKeyDown(e: KeyboardEvent) {
if (store.isActive) {
store.deactivate()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.screensaver-enter-active,
.screensaver-leave-active {
transition: opacity 0.5s ease;
}
.screensaver-enter-from,
.screensaver-leave-to {
opacity: 0;
}
/* Explicit viewport centering */
.screensaver-container {
position: fixed;
inset: 0;
display: grid;
place-items: center;
}
.screensaver-content {
position: relative;
display: grid;
place-items: center;
}
.screensaver-logo-wrapper {
position: absolute;
inset: 0;
display: grid;
place-items: center;
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
/* ASCII variant — centered Bitcoin face animation */
.screensaver-ascii-content {
display: flex;
align-items: center;
justify-content: center;
transform: scale(2);
}
@media (min-width: 640px) {
.screensaver-ascii-content {
transform: scale(2.5);
}
}
@media (min-width: 768px) {
.screensaver-ascii-content {
transform: scale(3);
}
}
</style>
@@ -0,0 +1,20 @@
<template>
<div class="logo-gradient-border screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
<!-- Squares logo -->
<div class="screensaver-logo-squares absolute inset-[3px] flex items-center justify-center">
<AnimatedLogo size="xl" no-border fit />
</div>
</div>
</template>
<script setup lang="ts">
import AnimatedLogo from '@/components/AnimatedLogo.vue'
</script>
<style scoped>
.screensaver-logo-squares {
opacity: 1;
position: relative;
z-index: 1;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="viz-ring" :class="sizeClass">
<div
v-for="(_, i) in segmentCount"
:key="i"
class="viz-segment"
:style="getSegmentStyle(i)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
/** Visual size: 'default' matches the screensaver; 'compact' drops the
* min-width breakpoints (useful inside overlays on narrower canvases). */
size?: 'default' | 'compact'
/** Override segment count. Defaults to 48 (screensaver standard). */
segmentCount?: number
}>(), { size: 'default', segmentCount: 48 })
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
function getSegmentStyle(i: number) {
const deg = (i / props.segmentCount) * 360
return {
'--segment-index': i,
'--segment-deg': `${deg}deg`,
}
}
</script>
<style scoped>
.viz-ring {
position: relative;
pointer-events: none;
}
.viz-ring-default {
width: 280px;
height: 280px;
--viz-radius: 140px;
}
@media (min-width: 640px) {
.viz-ring-default {
width: 360px;
height: 360px;
--viz-radius: 180px;
}
}
@media (min-width: 768px) {
.viz-ring-default {
width: 400px;
height: 400px;
--viz-radius: 200px;
}
}
.viz-ring-compact {
width: 240px;
height: 240px;
--viz-radius: 120px;
}
@media (min-width: 768px) {
.viz-ring-compact {
width: 320px;
height: 320px;
--viz-radius: 160px;
}
}
.viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
border-radius: 2px;
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: segment-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--segment-index) * 0.02s);
}
@media (min-width: 768px) {
.viz-segment {
height: 28px;
margin-top: -14px;
}
}
/* 5 normal loops (10s) then stronger longer expression (4s) — total 14s */
@keyframes segment-pulse {
0% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
7.1% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
14.3%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
21.4%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
28.6%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
35.7%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
42.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
50% { opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
57.1%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
64.3%{ opacity: 0.9; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
71.4%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
78.6%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
85.7%{ opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1.5); }
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
}
</style>
@@ -0,0 +1,148 @@
<template>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" 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 (['auto', 'lightning', 'onchain', 'ecash', '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' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</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">
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-if="resultTxid" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
</div>
<div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div>
<div v-if="resultArk" class="mb-3 alert-success">
<p class="text-xs">{{ resultArk }}</p>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" 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') : t('common.send') }}
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
const amount = ref<number>(0)
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const resultArk = ref('')
const ecashToken = ref('')
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
if (amt <= 0) return 'lightning'
if (amt < 1000) return 'ecash'
if (amt > 500000) return 'onchain'
return 'lightning'
})
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
ecashToken.value = ''
emit('close')
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
async function send() {
if (!amount.value || processing.value) return
processing.value = true
error.value = ''
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
const method = effectiveMethod.value
try {
if (method === 'ark') {
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
await rpcClient.call<{ sent: boolean }>({
method: 'wallet.ark-send',
params: { destination: dest.value.trim(), amount_sats: amount.value },
// Ark sends can wait on round participation.
timeout: 130000,
})
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
} else if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({
method: 'lnd.payinvoice',
params: { payment_request: dest.value.trim() },
})
resultHash.value = res.payment_hash
} else {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: dest.value.trim(), amount: amount.value },
})
resultTxid.value = res.txid
}
emit('sent')
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
processing.value = false
}
}
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="glass-card p-6 animate-pulse" :class="className">
<!-- Header skeleton -->
<div v-if="showHeader" class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-lg bg-white/10 shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-white/10 rounded w-2/3"></div>
<div class="h-3 bg-white/5 rounded w-1/3"></div>
</div>
</div>
<!-- Content skeleton lines -->
<div class="space-y-3">
<div v-for="i in lines" :key="i" class="h-3 bg-white/8 rounded" :style="{ width: lineWidth(i) }"></div>
</div>
<!-- Stats grid skeleton -->
<div v-if="showStats" class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
<div v-for="s in 4" :key="s" class="bg-white/5 rounded-lg p-3">
<div class="h-2 bg-white/8 rounded w-1/2 mb-2"></div>
<div class="h-5 bg-white/10 rounded w-3/4"></div>
</div>
</div>
<!-- Action buttons skeleton -->
<div v-if="showActions" class="flex gap-3 mt-4">
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
<div class="h-9 bg-white/8 rounded-lg flex-1"></div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
lines?: number
showHeader?: boolean
showStats?: boolean
showActions?: boolean
className?: string
}>(), {
lines: 3,
showHeader: true,
showStats: false,
showActions: false,
className: '',
})
function lineWidth(index: number): string {
const widths = ['100%', '85%', '70%', '90%', '60%']
return widths[(index - 1) % widths.length] ?? '100%'
}
</script>
+793
View File
@@ -0,0 +1,793 @@
<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=7" 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
}
}
})
// Check if user has seen intro
// Also detect returning users who cleared cache: if we're on a /dashboard route,
// the backend session is still active so the user has been here before.
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
const seenIntro = storedSeenIntro || isOnDashboard
// Persist the flag so subsequent loads don't re-check
if (!storedSeenIntro && isOnDashboard) {
localStorage.setItem('neode_intro_seen', '1')
}
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(() => {
if (seenIntro) {
showSplash.value = false
document.body.classList.add('splash-complete')
emit('complete')
} else {
window.addEventListener('keydown', handleEnterKey)
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleEnterKey)
if (introTypingTimeout) {
clearTimeout(introTypingTimeout)
introTypingTimeout = null
}
// Clear all scheduled timers to prevent firing on unmounted component
for (const id of pendingTimers) clearTimeout(id)
pendingTimers.length = 0
// Clear video time update interval
if (videoTimeUpdateInterval) {
clearInterval(videoTimeUpdateInterval)
videoTimeUpdateInterval = null
}
})
</script>
<style scoped>
.splash-fade-enter-active {
transition: opacity 0.5s ease;
}
.splash-fade-leave-active {
transition: opacity 1s ease-out, transform 1s ease-out;
}
.splash-fade-enter-from,
.splash-fade-leave-to {
opacity: 0;
}
.splash-fade-leave-to {
transform: scale(1.1);
}
/* Wide logo zooms out towards user when login modal comes in */
.splash-fade-leave-active .logo-container {
transition: transform 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
}
.splash-fade-leave-to .logo-container {
transform: scale(1.4);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Welcome message fade out */
.welcome-fade-enter-active {
transition: opacity 0.8s ease-out;
}
.welcome-fade-leave-active {
transition: opacity 0.6s ease-in;
}
.welcome-fade-enter-from,
.welcome-fade-leave-to {
opacity: 0;
}
.welcome-fade-out {
opacity: 0;
transition: opacity 0.6s ease-in;
}
/* Logo zoom bounce animation - smooth and buttery */
.logo-zoom-enter-active {
transition: all 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.logo-zoom-leave-active {
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.logo-zoom-enter-from {
opacity: 0;
transform: scale(0.7);
}
.logo-zoom-enter-to {
opacity: 1;
transform: scale(1);
}
.logo-zoom-leave-from {
opacity: 1;
transform: scale(1);
}
.logo-zoom-leave-to {
opacity: 0;
transform: scale(1.05);
}
.logo-zoom-bounce {
animation: logoZoomBounce 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes logoZoomBounce {
0% {
transform: scale(0.85);
opacity: 0;
}
50% {
transform: scale(1.02);
opacity: 0.9;
}
75% {
transform: scale(0.98);
opacity: 0.95;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Container to keep the typing text centered */
.typing-container {
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
}
/* Intro typing cursor - block style, yellow blink (Archipelago style) */
.intro-typing-caret {
display: inline-block;
width: 4px;
min-width: 4px;
height: 1.2em;
background: #fbbf24;
margin-left: 2px;
vertical-align: text-bottom;
animation: intro-caret-blink 0.5s step-end infinite;
}
@keyframes intro-caret-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Ensure text wraps smoothly on mobile */
.font-mono {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Smooth line transitions for mobile */
@media (max-width: 640px) {
.font-mono span {
display: inline-block;
max-width: 100%;
}
}
/* Background zoom transition - matches OnboardingWrapper style */
.bg-zoom-transition {
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 1.2s ease-out;
transform: scale(1);
transform-origin: center center;
will-change: transform, opacity;
}
.bg-zoom-transition.bg-zoom-in {
transform: scale(1.15);
}
/* Tap to start - logo grow then zoom out to background */
.tap-overlay-zoom-out {
background-color: rgba(0, 0, 0, 0.4);
transition: background-color 0.6s cubic-bezier(0.4, 0, 0.2, 1);
animation: tap-overlay-fade 1s ease-out forwards;
}
@keyframes tap-overlay-fade {
0% { background-color: rgba(0, 0, 0, 0.4); }
30% { background-color: rgba(0, 0, 0, 0.35); }
100% { background-color: rgba(0, 0, 0, 0); }
}
.perspective-1000 {
perspective: 1000px;
}
.tap-logo-launch {
animation: tap-logo-launch 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
transform-origin: center center;
will-change: transform, opacity;
}
@keyframes tap-logo-launch {
0% { transform: scale(1); opacity: 1; }
15% { transform: scale(1.2); opacity: 1; }
25% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
}
/* Tap to start - "Enter the Exit" big behind logo */
.tap-to-start-content {
min-height: 12rem;
}
.tap-to-start-text {
position: absolute;
z-index: 0;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.35) 0%,
rgba(0, 0, 0, 0.35) 38%,
rgba(0, 0, 0, 0.35) 40%,
rgba(255, 255, 255, 0.5) 48%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.5) 52%,
rgba(0, 0, 0, 0.35) 60%,
rgba(0, 0, 0, 0.35) 100%
);
background-size: 250% 100%;
background-position: 0% 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: tap-to-start-flare-wipe 14s ease-in-out infinite;
}
@keyframes tap-to-start-flare-wipe {
0%, 82%, 100% {
background-position: 0% 0;
}
88% {
background-position: 100% 0;
}
}
.tap-to-start-logo {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
overflow: visible;
}
.intro-logo-viz-ring {
position: absolute;
left: 50%;
top: 50%;
width: 1px;
height: 1px;
transform: translate(-50%, -50%);
z-index: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
--viz-radius: 7rem;
}
.intro-logo-viz-ring.intro-logo-viz-visible {
opacity: 1;
}
.intro-logo-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
margin-top: -12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.15));
border-radius: 2px;
transform-origin: center center;
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
animation: intro-viz-pulse 2.5s ease-in-out infinite;
animation-delay: calc(var(--segment-index, 0) * 0.02s);
}
@keyframes intro-viz-pulse {
0%, 100% { opacity: 0.4; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.5); }
50% { opacity: 1; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1); }
}
@media (min-width: 640px) {
.intro-logo-viz-ring { --viz-radius: 8rem; }
.intro-logo-viz-segment { height: 26px; margin-top: -13px; }
}
@media (min-width: 768px) {
.intro-logo-viz-ring { --viz-radius: 9rem; }
.intro-logo-viz-segment { height: 28px; margin-top: -14px; }
}
.tap-to-start-logo :deep(.logo-gradient-border) {
width: 12rem;
height: 12rem;
position: relative;
z-index: 1;
}
@media (min-width: 640px) {
.tap-to-start-content {
min-height: 14rem;
}
.tap-to-start-logo :deep(.logo-gradient-border) {
width: 14rem;
height: 14rem;
}
}
@media (min-width: 768px) {
.tap-to-start-content {
min-height: 16rem;
}
.tap-to-start-logo :deep(.logo-gradient-border) {
width: 16rem;
height: 16rem;
}
}
</style>
+405
View File
@@ -0,0 +1,405 @@
<template>
<Teleport to="body">
<Transition name="spotlight">
<div
v-if="spotlightStore.isOpen"
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
@click.self="spotlightStore.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="panelRef"
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
:style="panelStyle"
@mousedown="onPanelMouseDown"
>
<!-- Header: drag handle grip + search -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<div class="flex-1 flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search or type a command..."
class="flex-1 bg-transparent text-white placeholder-white/50 outline-none text-base"
@keydown="onInputKeydown"
/>
</div>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<div class="flex-1 overflow-y-auto max-h-[60vh] min-h-[200px]">
<!-- Recent items (when no query and we have recent) -->
<div v-if="!query.trim() && spotlightStore.recentItems.length > 0" class="p-2 border-b border-white/10">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">Recent</div>
<button
v-for="(item, idx) in spotlightStore.recentItems"
:key="`recent-${item.id}-${item.timestamp}`"
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(idx)"
@click="selectRecent(item)"
>
<span class="text-white/90">{{ item.label }}</span>
<span class="text-xs text-white/40">{{ item.type }}</span>
</button>
</div>
<!-- Search results or help tree -->
<template v-if="query.trim()">
<div v-if="filteredItems.length > 0" class="p-2">
<button
v-for="(item, idx) in filteredItems"
:key="item.id + item.section"
type="button"
class="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(idx)"
@click="selectItem(item)"
>
<span class="text-white/90">{{ item.label }}</span>
<span class="text-xs text-white/40">{{ item.section }}</span>
</button>
</div>
<div v-else class="p-8 text-center text-white/50">
No results for "{{ query }}"
</div>
</template>
<template v-else>
<!-- Help tree when no search -->
<div v-for="section in helpTree" :key="section.id" class="p-2">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">{{ section.label }}</div>
<button
v-for="(item, idx) in section.items"
:key="item.id"
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(recentOffset + getFlatIndex(section.id, idx))"
@click="selectHelpItem(section, item)"
>
<span class="text-white/90">{{ item.label }}</span>
</button>
</div>
</template>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import Fuse from 'fuse.js'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
const router = useRouter()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const appLauncherStore = useAppLauncherStore()
const inputRef = ref<HTMLInputElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const query = ref('')
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const staticItems = flattenForSearch()
// Build dynamic app items from installed packages
const dynamicAppItems = computed<SearchableItem[]>(() => {
const pkgs = appStore.packages
return Object.entries(pkgs).map(([id, pkg]) => ({
id: `app-${id}`,
label: pkg.manifest?.title || id,
path: `__launch_app__:${id}`,
type: 'action' as const,
section: 'Installed Apps',
}))
})
const allSearchableItems = computed(() => [...staticItems, ...dynamicAppItems.value])
const fuse = computed(() => new Fuse(allSearchableItems.value, {
keys: ['label', 'section'],
threshold: 0.4,
}))
const filteredItems = computed(() => {
const q = query.value.trim()
if (!q) return []
const results = fuse.value.search(q)
return results.map((r) => r.item)
})
const recentOffset = computed(() =>
!query.value.trim() && spotlightStore.recentItems.length > 0 ? spotlightStore.recentItems.length : 0
)
const selectableCount = computed(() => {
if (query.value.trim()) return filteredItems.value.length
return recentOffset.value + allSearchableItems.value.length
})
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
const SAVED_POSITION_KEY = 'archipelago-spotlight-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
function loadSavedPosition() {
try {
const raw = localStorage.getItem(SAVED_POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw)
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
} else {
savedPosition.value = null
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved spotlight position', e)
savedPosition.value = null
}
}
function savePosition(x: number, y: number) {
savedPosition.value = { x, y }
try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to save spotlight position', e)
}
}
function getFlatIndex(sectionId: string, itemIdx: number): number {
let idx = 0
for (const s of helpTree) {
if (s.id === sectionId) return idx + itemIdx
idx += s.items.length
}
return -1
}
function getItemClass(index: number) {
const selected = spotlightStore.selectedIndex
return index === selected
? 'bg-amber-500/20 text-amber-200'
: 'hover:bg-white/10 text-white/90'
}
function launchInstalledApp(appId: string) {
const pkg = appStore.packages[appId]
if (!pkg) return
let lanAddress = pkg.installed?.['interface-addresses']?.main?.['lan-address']
if (lanAddress && lanAddress.includes('localhost')) {
lanAddress = lanAddress.replace('localhost', window.location.hostname)
}
if (lanAddress) {
appLauncherStore.open({ url: lanAddress, title: pkg.manifest?.title || appId })
} else {
router.push(`/dashboard/apps/${appId}`).catch(() => {})
}
}
function selectItem(item: SearchableItem) {
spotlightStore.addRecentItem({
id: item.id,
label: item.label,
path: item.path,
type: item.type,
})
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
} else if (item.path === '__cli__') {
cliStore.open()
} else if (item.path) {
router.push(item.path)
} else if (item.content) {
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
}
}
function selectHelpItem(section: { id: string }, item: { id: string; label: string; path?: string; content?: string; relatedPath?: string }) {
const type = section.id === 'navigate' ? 'navigate' : section.id === 'learn' ? 'learn' : 'action'
spotlightStore.addRecentItem({
id: item.id,
label: item.label,
path: item.path,
type,
})
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
} else if (item.path === '__cli__') {
cliStore.open()
} else if (item.path) {
router.push(item.path)
} else if (item.content) {
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
}
}
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' | 'goal' }) {
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
return
}
if (item.path === '__cli__') {
cliStore.open()
return
}
if (item.path) {
router.push(item.path)
return
}
if (item.type === 'learn') {
for (const s of helpTree) {
const helpItem = s.items.find((i) => i.id === item.id)
if (helpItem?.content) {
spotlightStore.showHelpModal({ title: helpItem.label, content: helpItem.content, relatedPath: helpItem.relatedPath })
return
}
}
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
spotlightStore.close()
e.preventDefault()
e.stopPropagation()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
spotlightStore.setSelectedIndex(
Math.min(spotlightStore.selectedIndex + 1, Math.max(0, selectableCount.value - 1))
)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
spotlightStore.setSelectedIndex(Math.max(spotlightStore.selectedIndex - 1, 0))
return
}
if (e.key === 'Enter') {
e.preventDefault()
const idx = spotlightStore.selectedIndex
if (query.value.trim()) {
const item = filteredItems.value[idx]
if (item) selectItem(item)
return
}
if (idx < recentOffset.value) {
const item = spotlightStore.recentItems[idx]
if (item) selectRecent(item)
return
}
const helpIdx = idx - recentOffset.value
let count = 0
for (const s of helpTree) {
for (const item of s.items) {
if (count === helpIdx) {
selectHelpItem(s, item)
return
}
count++
}
}
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
watch(
() => spotlightStore.isOpen,
(open) => {
if (open) {
query.value = ''
loadSavedPosition()
nextTick(() => {
inputRef.value?.focus()
spotlightStore.setSelectedIndex(0)
})
}
}
)
watch(
[query, filteredItems],
() => {
spotlightStore.setSelectedIndex(0)
}
)
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<style scoped>
.spotlight-enter-active,
.spotlight-leave-active {
transition: opacity 0.2s ease;
}
.spotlight-enter-from,
.spotlight-leave-to {
opacity: 0;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<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>
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
import type { ToastVariant } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
function variantClass(variant: ToastVariant): string {
switch (variant) {
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
case 'error': return 'bg-black/70 border-red-500/30 backdrop-blur-md'
default: return 'bg-black/70 border-blue-500/30 backdrop-blur-md'
}
}
</script>
<style scoped>
.toast-stack-enter-active {
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-stack-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-stack-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-stack-leave-to {
opacity: 0;
transform: translateX(50%);
}
.toast-stack-move {
transition: transform 0.3s ease;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
tabindex="-1"
data-controller-ignore
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="[modelValue ? 'bg-orange-500' : 'bg-white/15', disabled ? 'opacity-40 cursor-not-allowed' : '']"
@click="$emit('update:modelValue', !modelValue)"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="modelValue ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
disabled?: boolean
ariaLabel?: string
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
@@ -0,0 +1,190 @@
<template>
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh see
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. -->
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
<button
v-for="f in filters"
:key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@click="activeFilter = f.key"
>
{{ f.label }}<span v-if="countFor(f.key)" class="text-white/35"> · {{ countFor(f.key) }}</span>
</button>
</div>
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div>
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
<div
v-for="tx in filteredTransactions"
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
@click="openInMempool(tx)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
: 'bg-red-500/10'"
>
<svg
class="w-4 h-4"
:class="tx.direction === 'incoming'
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
: 'text-red-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
</span>
<span
v-if="isOnchain(tx)"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0
? 'bg-yellow-500/15 text-yellow-400'
: tx.num_confirmations < 3
? 'bg-green-500/15 text-green-400'
: 'bg-white/10 text-white/50'"
>
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : tx.kind === 'ark' ? 'bg-teal-500/15 text-teal-400' : 'bg-blue-500/15 text-blue-400'"
>
{{ kindLabel(tx) }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
// Which rail the transaction happened on; absent = onchain (older backends)
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
const props = defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
// The Ark chip only appears once an Ark transaction exists — most nodes
// don't run the barkd sidecar.
const filters = computed<Array<{ key: FilterKey; label: string }>>(() => [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
...(props.transactions.some(tx => tx.kind === 'ark') ? [{ key: 'ark' as const, label: 'Ark' }] : []),
])
const activeFilter = ref<FilterKey>('all')
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
if (f === 'all') return true
if (f === 'onchain') return isOnchain(tx)
if (f === 'lightning') return tx.kind === 'lightning'
if (f === 'ark') return tx.kind === 'ark'
return tx.kind === 'cashu' || tx.kind === 'fedimint'
}
const filteredTransactions = computed(() => props.transactions.filter(tx => matchesFilter(tx, activeFilter.value)))
function countFor(f: FilterKey): number {
if (f === 'all') return 0
return props.transactions.filter(tx => matchesFilter(tx, f)).length
}
function close() {
emit('close')
}
// Only on-chain transactions exist in mempool; Lightning/ecash rows don't link
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
function kindLabel(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Lightning'
if (tx.kind === 'cashu') return 'Cashu'
if (tx.kind === 'fedimint') return 'Fedimint'
if (tx.kind === 'ark') return 'Ark'
return ''
}
function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return
// Overlay the explorer above the current page — never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${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,541 @@
<template>
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Protocol tabs -->
<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>
<!-- ===================== 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 class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</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>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@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>
</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>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@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>
</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>
<!-- ===================== 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>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@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>
</div>
</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'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>()
const tabs = [
{ key: 'channels' as const, label: 'Channels' },
{ key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
{ key: 'ark' as const, label: 'Ark' },
]
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('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
// ---- Cashu mints ----
const mints = ref<string[]>([])
const newMint = ref('')
const loadingMints = ref(false)
const savingMints = ref(false)
const mintError = ref('')
const mintsSavedOk = ref(false)
// ---- Fedimint federations ----
interface Federation {
federation_id: string
name?: string
balance_sats: number
}
const federations = ref<Federation[]>([])
const inviteCode = ref('')
const joiningFed = ref(false)
const fedError = ref('')
const fedJoinedOk = ref(false)
// ---- Ark (barkd sidecar, wallet.ark-* RPCs) ----
interface ArkStatus {
available: boolean
wallet_ready: boolean
config?: { network: string; ark_server: string; esplora: string }
}
interface ArkBalance {
spendable_sats: number
pending_sats: number
onchain_sats: number
}
const arkStatus = ref<ArkStatus | null>(null)
const arkBalance = ref<ArkBalance | null>(null)
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
const arkAddress = ref('')
const arkCopied = ref(false)
const loadingArk = ref(false)
const savingArk = ref(false)
const arkBoarding = ref(false)
const arkOffboarding = ref(false)
const arkError = ref('')
const arkOk = ref('')
const arkBusy = computed(() => savingArk.value || arkBoarding.value || arkOffboarding.value)
watch(
() => props.show,
(open) => {
if (open) {
loadMints()
if (fedimintBackendReady) loadFederations()
loadArk()
}
},
)
async function loadMints() {
loadingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
newMint.value = ''
try {
const res = await rpcClient.call<{ mints: string[] }>({ method: 'streaming.list-mints' })
mints.value = res.mints || []
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to load mints'
mints.value = []
} finally {
loadingMints.value = false
}
}
function addMint() {
mintError.value = ''
mintsSavedOk.value = false
const url = newMint.value.trim().replace(/\/+$/, '')
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
mintError.value = 'Mint URL must start with http:// or https://'
return
}
if (mints.value.some((m) => m.replace(/\/+$/, '') === url)) {
mintError.value = 'That mint is already in the list'
return
}
mints.value.push(url)
newMint.value = ''
}
function removeMint(idx: number) {
if (mints.value.length <= 1) return
mints.value.splice(idx, 1)
mintsSavedOk.value = false
}
async function saveMints() {
if (mints.value.length === 0) return
savingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
try {
await rpcClient.call<{ mints: string[]; updated: boolean }>({
method: 'streaming.configure-mints',
params: { mints: mints.value },
})
mintsSavedOk.value = true
emit('changed')
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to save mints'
} finally {
savingMints.value = false
}
}
async function loadFederations() {
fedError.value = ''
try {
const res = await rpcClient.call<{ federations: Federation[] }>({ method: 'wallet.fedimint-list' })
federations.value = res.federations || []
} catch {
federations.value = []
}
}
async function joinFederation() {
if (!fedimintBackendReady || !inviteCode.value.trim()) return
const before = federations.value.length
joiningFed.value = true
fedError.value = ''
fedJoinedOk.value = false
try {
await rpcClient.call<{ federation_id: string }>({
method: 'wallet.fedimint-join',
params: { invite_code: inviteCode.value.trim() },
// Joining a federation is heavy (downloads the federation config + joins
// the consensus); it routinely takes longer than the default 15s. Give it
// headroom past the backend's own 60s clientd timeout.
timeout: 90000,
})
inviteCode.value = ''
await loadFederations()
fedJoinedOk.value = true
emit('changed')
} catch (err: unknown) {
// A slow join often still completes server-side after the client gives up,
// so don't cry failure blindly — re-check the list. If a new federation
// appeared, the join actually worked; surface success instead of a scary
// (and wrong) timeout error.
await loadFederations()
if (federations.value.length > before) {
inviteCode.value = ''
fedJoinedOk.value = true
emit('changed')
} else {
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
}
} finally {
joiningFed.value = false
}
}
async function loadArk() {
loadingArk.value = true
arkError.value = ''
arkOk.value = ''
arkAddress.value = ''
try {
const status = await rpcClient.call<ArkStatus>({ method: 'wallet.ark-status' })
arkStatus.value = status
if (status.config) arkConfig.value = { ...status.config }
if (status.available) {
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
} else {
arkBalance.value = null
}
} catch {
arkStatus.value = { available: false, wallet_ready: false }
arkBalance.value = null
} finally {
loadingArk.value = false
}
}
async function fetchArkAddress(onchain: boolean) {
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ address: string }>({
method: 'wallet.ark-address',
params: { onchain },
})
arkAddress.value = res.address
arkCopied.value = false
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
}
}
async function copyArkAddress() {
if (!arkAddress.value) return
try {
await navigator.clipboard.writeText(arkAddress.value)
arkCopied.value = true
} catch {
/* clipboard unavailable (http) — the address is selectable */
}
}
async function boardArk() {
arkBoarding.value = true
arkError.value = ''
arkOk.value = ''
try {
// Boards everything; waits on Ark round participation, so give it room.
await rpcClient.call({ method: 'wallet.ark-board', timeout: 130000 })
arkOk.value = 'Boarding started — funds appear as spendable once the round confirms.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to board funds'
} finally {
arkBoarding.value = false
}
}
async function offboardArk() {
arkOffboarding.value = true
arkError.value = ''
arkOk.value = ''
try {
await rpcClient.call({ method: 'wallet.ark-offboard', timeout: 130000 })
arkOk.value = 'Offboard requested — funds return on-chain with the next round.'
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to offboard funds'
} finally {
arkOffboarding.value = false
}
}
async function saveArkConfig() {
savingArk.value = true
arkError.value = ''
arkOk.value = ''
try {
const res = await rpcClient.call<{ config: ArkStatus['config'] }>({
method: 'wallet.ark-configure',
params: { ...arkConfig.value },
})
if (res.config) arkConfig.value = { ...res.config }
arkOk.value = 'Ark configuration saved.'
emit('changed')
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to save Ark configuration'
} finally {
savingArk.value = false
}
}
function close() {
mintError.value = ''
mintsSavedOk.value = false
fedError.value = ''
arkError.value = ''
arkOk.value = ''
emit('close')
}
</script>
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
afterEach(() => {
document.body.style.overflow = ''
})
it('locks page scroll while open and restores it when closed', async () => {
const wrapper = mount(BaseModal, {
props: { show: true, title: 'Test modal' },
slots: { default: '<p>Modal content</p>' },
attachTo: document.body,
})
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('')
wrapper.unmount()
})
})
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import LineChart from '../LineChart.vue'
// Mock canvas context
const mockContext = {
clearRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
closePath: vi.fn(),
setLineDash: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
scale: vi.fn(),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn(),
}),
canvas: { width: 600, height: 200 },
strokeStyle: '',
fillStyle: '',
lineWidth: 0,
font: '',
textAlign: '',
textBaseline: '',
globalAlpha: 1,
}
beforeEach(() => {
vi.clearAllMocks()
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockContext)
})
describe('LineChart', () => {
const sampleDatasets = [
{ label: 'CPU', data: [10, 20, 30, 40, 50], color: '#fb923c' },
]
it('renders a canvas element', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('accepts datasets prop', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.exists()).toBe(true)
})
it('renders with empty datasets', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: [] },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('renders with multiple datasets', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [
{ label: 'CPU', data: [10, 20, 30], color: '#fb923c' },
{ label: 'Memory', data: [50, 60, 70], color: '#4ade80' },
],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts optional height and width props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
height: 300,
width: 600,
},
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('600')
expect(canvas.attributes('height')).toBe('300')
})
it('uses default width of 400 and height of 180', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('400')
expect(canvas.attributes('height')).toBe('180')
})
it('renders with dataset containing single data point', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [{ label: 'Test', data: [42], color: '#3b82f6' }],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts yMax and yLabel props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
yMax: 100,
yLabel: 'Percent',
},
})
expect(wrapper.exists()).toBe(true)
})
})
@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshMap from '../MeshMap.vue'
const meshState = vi.hoisted(() => ({
nodePositions: new Map(),
federatedPositions: new Map(),
peers: [],
status: null,
deadmanStatus: null,
updateSelfPosition: vi.fn(),
}))
vi.mock('@/stores/mesh', () => ({
useMeshStore: () => meshState,
}))
vi.mock('leaflet', () => ({
default: {
map: vi.fn(() => ({
invalidateSize: vi.fn(),
fitBounds: vi.fn(),
remove: vi.fn(),
})),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
divIcon: vi.fn((opts) => opts),
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
polyline: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
},
}))
describe('MeshMap', () => {
beforeEach(() => {
meshState.nodePositions.clear()
meshState.federatedPositions.clear()
meshState.peers = []
meshState.status = null
meshState.deadmanStatus = null
meshState.updateSelfPosition.mockClear()
})
it('treats denied browser location as optional for peer positions', async () => {
let errorHandler!: (error: { code: number; message: string }) => void
const watchPosition = vi.fn((_success, error) => {
errorHandler = error
return 7
})
const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
}))
vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch },
})
vi.stubGlobal('ResizeObserver', resizeObserver)
const wrapper = mount(MeshMap)
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
await wrapper.get('[role="switch"]').trigger('click')
errorHandler({ code: 1, message: 'denied' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
expect(wrapper.text()).not.toContain('location sharing is required')
vi.unstubAllGlobals()
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import PWAInstallPrompt from '../PWAInstallPrompt.vue'
describe('PWAInstallPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
// Mock matchMedia to return non-standalone
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})
it('renders without errors', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.exists()).toBe(true)
})
it('does not show prompt initially', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('shows prompt after beforeinstallprompt event', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire the beforeinstallprompt event
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Install Archipelago')
})
it('hides prompt when dismissed', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Show prompt
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
// Click dismiss button
const dismissBtn = wrapper.findAll('button').find(b => b.text().includes('Not now'))
expect(dismissBtn).toBeDefined()
await dismissBtn!.trigger('click')
expect(sessionStorage.getItem('archipelago_pwa_install_dismissed')).toBe('1')
})
it('does not show if already dismissed this session', async () => {
sessionStorage.setItem('archipelago_pwa_install_dismissed', '1')
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire beforeinstallprompt — should not show
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('does not show in standalone mode', async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
})
@@ -0,0 +1,99 @@
<template>
<div class="cloud-toolbar">
<!-- Breadcrumbs -->
<nav class="cloud-breadcrumbs">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="cloud-breadcrumb-item"
:class="{ 'cloud-breadcrumb-active': i === breadcrumbs.length - 1 }"
@click="i < breadcrumbs.length - 1 && $emit('navigate', crumb.path)"
>
<svg v-if="i === 0" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span v-else>{{ crumb.name }}</span>
<svg v-if="i < breadcrumbs.length - 1" class="w-3 h-3 text-white/30 mx-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</nav>
<!-- Actions -->
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="cloud-view-toggle">
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'grid' }"
title="Grid view"
@click="$emit('update:viewMode', 'grid')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'list' }"
title="List view"
@click="$emit('update:viewMode', 'list')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden md:inline">Upload</span>
</button>
<button class="glass-button cloud-toolbar-btn" title="Refresh" @click="$emit('refresh')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
@change="handleFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid'
}>()
const emit = defineEmits<{
navigate: [path: string]
refresh: []
upload: [files: File[]]
'update:viewMode': [mode: 'list' | 'grid']
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function triggerUpload() {
fileInput.value?.click()
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files.length > 0) {
emit('upload', Array.from(input.files))
input.value = ''
}
}
</script>
+136
View File
@@ -0,0 +1,136 @@
<template>
<button
class="cloud-file-item group"
data-controller-container
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,435 @@
<template>
<Teleport to="body">
<Transition name="lightbox-fade">
<div
v-if="show"
class="lightbox-backdrop"
@click.self="close"
@keydown="onKeydown"
tabindex="0"
ref="backdropEl"
>
<!-- Top bar -->
<div class="lightbox-topbar">
<div class="flex items-center gap-3 min-w-0">
<span v-if="mediaItems.length > 1" class="text-sm text-white/50">
{{ currentIndex + 1 }} / {{ mediaItems.length }}
</span>
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
</div>
<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>
<!-- Navigation arrows -->
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-prev"
@click.stop="prev"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-next"
@click.stop="next"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Media content -->
<div class="lightbox-content" @click.stop>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="w-12 h-12 border-3 border-white/10 border-t-white/70 rounded-full animate-spin"></div>
</div>
<!-- Image -->
<img
v-else-if="currentItem && currentUrl && isImageFile(currentItem)"
:src="currentUrl"
:alt="currentItem.name"
class="lightbox-media-img"
@error="onMediaError"
/>
<!-- Video -->
<video
v-else-if="currentItem && currentUrl && isVideoFile(currentItem)"
ref="videoEl"
:src="currentUrl"
:key="currentUrl"
class="lightbox-media-video"
controls
autoplay
@dblclick="toggleFullscreen"
@error="onMediaError"
/>
<!-- Audio -->
<div
v-else-if="currentItem && currentUrl && isAudioFile(currentItem)"
class="lightbox-audio-container"
>
<div class="lightbox-audio-artwork">
<svg class="w-20 h-20 text-orange-400/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<p class="text-white/70 text-sm mt-4 truncate max-w-xs">{{ currentItem.name }}</p>
<audio
:src="currentUrl"
:key="currentUrl"
controls
autoplay
class="lightbox-audio-player"
@error="onMediaError"
/>
</div>
<!-- Error -->
<div v-else-if="mediaError" class="flex flex-col items-center justify-center gap-3">
<svg class="w-12 h-12 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-white/40 text-sm">Failed to load media</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
const props = defineProps<{
items: FileBrowserItem[]
startIndex: number
show: boolean
fetchBlobUrl: (path: string) => Promise<string>
streamUrl?: (path: string) => Promise<string>
}>()
const emit = defineEmits<{
close: []
}>()
const currentIndex = ref(0)
const loading = ref(false)
const mediaError = ref(false)
const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
const videoEl = ref<HTMLVideoElement | null>(null)
const urlCache = new Map<string, string>()
const mediaItems = computed(() =>
props.items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
)
const currentItem = computed(() => mediaItems.value[currentIndex.value] ?? null)
function isImageFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'image'
}
function isVideoFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'video'
}
function isAudioFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'audio'
}
async function loadMedia(item: FileBrowserItem) {
loading.value = true
mediaError.value = false
currentUrl.value = null
try {
const cached = urlCache.get(item.path)
if (cached) {
currentUrl.value = cached
} else {
// Use streaming URL for video/audio (avoids downloading entire file into blob)
// Use blob URL for images (needed for rendering)
const isStreamable = isVideoFile(item) || isAudioFile(item)
if (isStreamable && props.streamUrl) {
const url = await props.streamUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
} else {
const url = await props.fetchBlobUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
}
}
} catch {
mediaError.value = true
} finally {
loading.value = false
}
}
function preloadAdjacent() {
const items = mediaItems.value
if (items.length <= 1) return
const prevIdx = (currentIndex.value - 1 + items.length) % items.length
const nextIdx = (currentIndex.value + 1) % items.length
for (const idx of [prevIdx, nextIdx]) {
const item = items[idx]
if (item && !urlCache.has(item.path) && isImageFile(item)) {
props.fetchBlobUrl(item.path).then(url => {
urlCache.set(item.path, url)
}).catch(() => {})
}
}
}
function prev() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value - 1 + len) % len
}
function next() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value + 1) % len
}
function close() {
emit('close')
}
function toggleFullscreen() {
const el = videoEl.value
if (!el) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
el.requestFullscreen().catch(() => {})
}
}
function onMediaError() {
mediaError.value = true
loading.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
}
watch(currentItem, (item) => {
if (item) {
loadMedia(item)
preloadAdjacent()
}
})
watch(() => props.show, async (visible) => {
if (visible) {
currentIndex.value = props.startIndex
const item = mediaItems.value[props.startIndex]
if (item) {
await loadMedia(item)
preloadAdjacent()
}
await nextTick()
backdropEl.value?.focus()
}
}, { immediate: true })
onUnmounted(() => {
for (const url of urlCache.values()) {
URL.revokeObjectURL(url)
}
urlCache.clear()
})
</script>
<style scoped>
.lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
outline: none;
}
.lightbox-topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 100%);
z-index: 10;
}
.lightbox-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s;
cursor: pointer;
}
.lightbox-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 3rem;
height: 3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s;
cursor: pointer;
z-index: 10;
}
.lightbox-nav:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
border-color: rgba(255, 255, 255, 0.15);
}
.lightbox-nav-prev { left: 1rem; }
.lightbox-nav-next { right: 1rem; }
.lightbox-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 3.5rem 1rem;
}
.lightbox-media-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
}
.lightbox-media-video {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 0;
background: black;
cursor: pointer;
}
.lightbox-audio-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
}
.lightbox-audio-artwork {
width: 12rem;
height: 12rem;
border-radius: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(251, 146, 60, 0.12) 0%, rgba(251, 146, 60, 0.04) 100%);
border: 1px solid rgba(251, 146, 60, 0.15);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.lightbox-audio-player {
width: 100%;
max-width: 24rem;
margin-top: 1rem;
border-radius: 2rem;
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9);
}
/* Transitions */
.lightbox-fade-enter-active,
.lightbox-fade-leave-active {
transition: opacity 0.25s ease;
}
.lightbox-fade-enter-from,
.lightbox-fade-leave-to {
opacity: 0;
}
/* Mobile */
@media (max-width: 768px) {
.lightbox-content { padding: 3rem 0; }
.lightbox-nav { width: 2.5rem; height: 2.5rem; }
.lightbox-nav-prev { left: 0.5rem; }
.lightbox-nav-next { right: 0.5rem; }
.lightbox-audio-artwork { width: 8rem; height: 8rem; }
.lightbox-media-video { border-radius: 0; }
/* The close button used to sit in the top bar, which lands under the
status bar / notch safe area on most phones and is awkward to reach.
Detach it from the top bar and pin it bottom-center, under the media,
for mobile only — desktop keeps it in the top bar. */
.lightbox-topbar { padding-right: 1rem; }
.lightbox-topbar .lightbox-btn {
position: fixed;
top: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
left: 50%;
right: auto;
transform: translateX(-50%);
z-index: 20;
}
}
</style>
@@ -0,0 +1,255 @@
<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>
</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))"
@click="save"
>
{{ shared ? 'Share' : 'Stop Sharing' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const props = defineProps<{
filename: string
filepath: string
isDir: boolean
existingItemId?: string
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const shared = ref(false)
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
const priceSats = ref<number>(100)
const saving = ref(false)
const errorMsg = ref<string | null>(null)
const successMsg = ref<string | null>(null)
// If we have an existing item, load its state
onMounted(async () => {
try {
const res = await rpcClient.call<{ items: Array<{
id: string
filename: string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
availability: string | { allpeers?: unknown; nobody?: unknown }
}> }>({ method: 'content.list-mine' })
const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
)
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
} 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)
}
})
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) => i.filename === props.filename || i.filename === props.filepath
)
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) => i.filename === props.filename || i.filename === props.filepath
)?.id
// Add if not in catalog
if (!itemId) {
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
const mimeMap: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', wav: 'audio/wav',
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
}
const addRes = await rpcClient.call<{ item: { id: string } }>({
method: 'content.add',
params: {
filename: (props.filepath || props.filename).replace(/^\/+/, ''),
mime_type: mimeMap[ext] || 'application/octet-stream',
description: '',
},
})
itemId = addRes.item.id
}
// Set pricing
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value
}
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
// Set availability to all peers
await rpcClient.call({
method: 'content.set-availability',
params: { id: itemId, availability: 'all_peers' },
})
const label =
accessType.value === 'paid'
? `Shared for ${priceSats.value} sats`
: accessType.value === 'peers_only'
? 'Shared with peers'
: 'Shared (free)'
successMsg.value = label
}
setTimeout(() => emit('saved'), 800)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,182 @@
<template>
<div ref="containerRef" class="network-map-container">
<svg ref="svgRef" class="w-full h-full"></svg>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
did: string
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
app_count: number
is_self: boolean
}
interface MapLink {
source: string
target: string
}
const props = defineProps<{
nodes: MapNode[]
links: MapLink[]
}>()
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
type SimNode = MapNode & d3.SimulationNodeDatum
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
case 'observer': return '#fb923c'
case 'untrusted': return '#ef4444'
default: return '#9ca3af'
}
}
function nodeRadius(n: MapNode): number {
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
const container = containerRef.value!
const width = container.clientWidth
const height = container.clientHeight
svg.attr('viewBox', `0 0 ${width} ${height}`)
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
// Center the self-node
const selfNode = simNodes.find(n => n.is_self)
if (selfNode) {
selfNode.fx = width / 2
selfNode.fy = height / 2
}
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
const g = svg.append('g')
// Links
const link = g.append('g')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
})
.attr('stroke-width', 2)
.attr('stroke-dasharray', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
})
// Node groups
const node = g.append('g')
.selectAll<SVGGElement, SimNode>('g')
.data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) simulation!.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation!.alphaTarget(0)
if (!d.is_self) { d.fx = null; d.fy = null }
})
)
// Node circles
node.append('circle')
.attr('r', d => nodeRadius(d))
.attr('fill', d => trustColor(d.trust_level))
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
// Node labels
node.append('text')
.text(d => d.label)
.attr('dy', d => nodeRadius(d) + 14)
.attr('text-anchor', 'middle')
.attr('fill', 'rgba(255,255,255,0.7)')
.attr('font-size', '11px')
.attr('font-family', "'Avenir Next', sans-serif")
// Tooltip
node.append('title')
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimNode).x!)
.attr('y1', d => (d.source as SimNode).y!)
.attr('x2', d => (d.target as SimNode).x!)
.attr('y2', d => (d.target as SimNode).y!)
node.attr('transform', d => `translate(${d.x},${d.y})`)
})
}
onMounted(() => {
render()
resizeObserver = new ResizeObserver(() => render())
if (containerRef.value) resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
simulation?.stop()
resizeObserver?.disconnect()
})
watch(graphSignature, () => render())
</script>
<style scoped>
.network-map-container {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
min-height: 400px;
width: 100%;
}
</style>
@@ -0,0 +1,66 @@
<template>
<BaseModal
:show="show"
title="Request to Peer"
max-width="max-w-md"
:z-index="'z-[3200]'"
@close="$emit('cancel')"
>
<p class="text-white/70 text-sm mb-1">
Send a connection request to
<span class="text-white font-medium">{{ targetLabel }}</span>
</p>
<p class="text-white/45 text-xs mb-4">
They'll see your request and approve or decline it. Approved peers connect
at the Peer level — never trusted automatically.
</p>
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
<textarea
v-model="message"
rows="3"
maxlength="280"
placeholder="Hey — mind if we peer?"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60 resize-none"
></textarea>
<p class="text-[11px] text-white/30 text-right mt-1">{{ message.length }}/280</p>
<div class="flex gap-2 mt-4">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('cancel')">
Cancel
</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="sending"
@click="$emit('send', message.trim() || undefined)"
>
{{ sending ? 'Sending' : 'Send Request' }}
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
const props = defineProps<{
show: boolean
targetLabel: string
sending?: boolean
}>()
defineEmits<{
send: [message: string | undefined]
cancel: []
}>()
const message = ref('')
watch(
() => props.show,
(visible) => {
if (visible) message.value = ''
},
)
</script>
@@ -0,0 +1,240 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Device Detected' : 'Set Up Your Mesh Radio'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
>
<!-- Step 1: detection + hardware graphic -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-44 h-44 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>
<p class="text-white/50 text-xs mt-2">
Connect it to join the off-grid mesh chat, block headers, and payments
keep flowing even without internet.
</p>
<div class="flex gap-2 mt-6">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="dismiss">
Not Now
</button>
<button class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" @click="step = 2">
Set Up
</button>
</div>
</div>
<!-- Step 2: configuration with presets -->
<div v-else>
<p class="text-white/60 text-xs mb-4">
Archipelago presets are pre-selected confirm the region and you're on the mesh.
</p>
<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' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} set on the radio via its MeshCore app if it differs.
</p>
<p v-else-if="effectiveKind === 'meshcore' && selectedRegion" class="text-[11px] text-sky-300/80 mt-1">
MeshCore radios keep their flashed RF settings make sure the radio is on the {{ selectedRegion.band }} MHz band for {{ selectedRegion.code }}.
</p>
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
</p>
</div>
<!-- Node name -->
<div>
<label class="block text-sm text-white/80 mb-1">Name on the mesh</label>
<input v-model="form.name" maxlength="24" placeholder="e.g. basement-node"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<!-- Channel (Meshtastic secondary channel / MeshCore group channel;
Reticulum has no channel concept IFAC lives in the daemon config) -->
<div v-if="effectiveKind !== 'reticulum'">
<label class="block text-sm text-white/80 mb-1">Channel</label>
<input v-model="form.channel" maxlength="11"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
<p class="text-[11px] text-white/40 mt-1">
Archipelago nodes find each other on the "archipelago" channel; the public default channel stays active for interop.
</p>
</div>
<!-- Firmware (advanced) -->
<div>
<button class="text-xs text-orange-300 hover:text-orange-200" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '' : '' }} Advanced
</button>
<div v-if="showAdvanced" class="mt-2">
<label class="block text-sm text-white/80 mb-1">Radio firmware</label>
<select v-model="form.deviceKind" 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="auto">Auto-detect (Meshcore Meshtastic RNode)</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum / RNode</option>
</select>
<p class="text-[11px] text-white/40 mt-1">
Pin this if you know what's flashed on the board — auto-detect probes each firmware in order.
</p>
</div>
</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="connect"
>
{{ connecting ? 'Connecting' : 'Connect to Mesh' }}
</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 } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
const mesh = useMeshStore()
const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const showAdvanced = ref(false)
const connecting = ref(false)
const error = ref('')
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
)
const 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',
deviceKind: 'auto',
})
const selectedRegion = computed(() => regionByCode(form.value.region))
// The firmware whose options we surface: the explicit pin wins, else the
// last detected/connected type, else meshtastic-style (where presets apply).
const effectiveKind = computed(() => {
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
const t = (mesh.status?.device_type ?? '').toLowerCase()
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
})
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
// (Re)apply presets each time a new device surfaces the modal
watch(show, (visible) => {
if (visible) {
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'
form.value.deviceKind = mesh.status?.device_kind ?? 'auto'
}
})
function dismiss() {
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
}
async function connect() {
connecting.value = true
error.value = ''
try {
const path = devicePath.value
await mesh.configure({
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: form.value.deviceKind,
})
mesh.dismissDetectedDevice(path)
void router.push('/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
connecting.value = false
}
}
</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>