feat(ui): dual-ecash wallet settings, buy-peer-files, seed backup, assorted fixes
- Tabbed Wallet Settings modal (Cashu + Fedimint) and dual-balance wallet card - Buy a peer's paid file (ecash / node Lightning / on-chain / external QR) - Recovery-phrase reveal + backup section; onboarding seed retry resilience - NetBird HTTPS launch, remote-control two-finger scroll + external-open - Shared BackButton, single-v version label, mesh Bitcoin header toggles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd567cd165
commit
87769cbfbf
@ -2159,6 +2159,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
// Mesh Networking (LoRa radio via Meshcore)
|
||||
// =====================================================================
|
||||
case 'mesh.status': {
|
||||
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
|
||||
return res.json({
|
||||
result: {
|
||||
enabled: true,
|
||||
@ -2173,6 +2174,8 @@ app.post('/rpc/v1', (req, res) => {
|
||||
messages_sent: 23,
|
||||
messages_received: 47,
|
||||
detected_devices: ['/dev/ttyUSB0'],
|
||||
announce_block_headers: globalThis.__meshHeaders.announce_block_headers,
|
||||
receive_block_headers: globalThis.__meshHeaders.receive_block_headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -2278,7 +2281,10 @@ app.post('/rpc/v1', (req, res) => {
|
||||
|
||||
case 'mesh.configure': {
|
||||
console.log(`[Mesh] Configure:`, params)
|
||||
return res.json({ result: { configured: true } })
|
||||
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
|
||||
if (params && typeof params.announce_block_headers === 'boolean') globalThis.__meshHeaders.announce_block_headers = params.announce_block_headers
|
||||
if (params && typeof params.receive_block_headers === 'boolean') globalThis.__meshHeaders.receive_block_headers = params.receive_block_headers
|
||||
return res.json({ result: { configured: true, ...globalThis.__meshHeaders } })
|
||||
}
|
||||
|
||||
case 'mesh.send-invoice': {
|
||||
@ -3015,6 +3021,55 @@ app.post('/rpc/v1', (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
case 'seed.reveal': {
|
||||
if (!params || !params.password) {
|
||||
return res.json({ error: { code: -32000, message: 'Password is required to reveal the recovery phrase' } })
|
||||
}
|
||||
// Demo gate: accept any non-empty password; reject "wrong" to exercise the error path.
|
||||
if (params.password === 'wrong') {
|
||||
return res.json({ error: { code: -32000, message: 'Incorrect password' } })
|
||||
}
|
||||
const demo = 'legal winner thank year wave sausage worth useful legal winner thank yellow able cabin dad debris during dose talent layer crater proud drift movie'.split(' ')
|
||||
return res.json({ result: { words: demo, word_count: demo.length } })
|
||||
}
|
||||
|
||||
case 'update.list-mirrors': {
|
||||
globalThis.__mockMirrors ||= [
|
||||
{ url: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json', label: 'Origin (vps2)' },
|
||||
]
|
||||
return res.json({ result: { mirrors: globalThis.__mockMirrors } })
|
||||
}
|
||||
|
||||
case 'update.get-source': {
|
||||
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
|
||||
return res.json({
|
||||
result: {
|
||||
source: globalThis.__swarmPrefs.source,
|
||||
provide_dht: globalThis.__swarmPrefs.provide_dht,
|
||||
swarm_available: false, // default build has no iroh-swarm feature
|
||||
swarm_enabled: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'update.set-source': {
|
||||
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
|
||||
if (params && (params.source === 'origin' || params.source === 'swarm')) {
|
||||
globalThis.__swarmPrefs.source = params.source
|
||||
}
|
||||
if (params && typeof params.provide === 'boolean') {
|
||||
globalThis.__swarmPrefs.provide_dht = params.provide
|
||||
}
|
||||
return res.json({
|
||||
result: {
|
||||
source: globalThis.__swarmPrefs.source,
|
||||
provide_dht: globalThis.__swarmPrefs.provide_dht,
|
||||
swarm_available: false,
|
||||
swarm_enabled: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'network.list-requests': {
|
||||
return res.json({
|
||||
result: {
|
||||
|
||||
@ -290,6 +290,18 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-clientd",
|
||||
"title": "Fedimint Client",
|
||||
"version": "0.8.0",
|
||||
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
|
||||
"repoUrl": "https://github.com/minmoto/fmcd"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-gateway",
|
||||
"title": "Fedimint Gateway",
|
||||
|
||||
@ -139,6 +139,41 @@ function deepElementFromPoint(x: number, y: number): Element | null {
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest scrollable ancestor of `el` for the given delta, hopping out
|
||||
* of same-origin iframes when needed. Synthetic WheelEvents are untrusted and
|
||||
* never actually scroll the page, so two-finger scroll must call scrollBy on a
|
||||
* real scroll container — this locates it (e.g. the right-hand app frame). (#7)
|
||||
*/
|
||||
function findScrollable(el: Element | null, dx: number, dy: number): Element | null {
|
||||
let node: Element | null = el
|
||||
let guard = 0
|
||||
while (node && guard++ < 60) {
|
||||
const win = node.ownerDocument?.defaultView
|
||||
const style = win?.getComputedStyle(node)
|
||||
if (style) {
|
||||
const oy = style.overflowY
|
||||
const ox = style.overflowX
|
||||
const isRoot = node === node.ownerDocument?.scrollingElement
|
||||
const canY =
|
||||
(oy === 'auto' || oy === 'scroll' || isRoot) &&
|
||||
node.scrollHeight > node.clientHeight + 1
|
||||
const canX =
|
||||
(ox === 'auto' || ox === 'scroll' || isRoot) &&
|
||||
node.scrollWidth > node.clientWidth + 1
|
||||
if ((dy !== 0 && canY) || (dx !== 0 && canX)) return node
|
||||
}
|
||||
if (node.parentElement) {
|
||||
node = node.parentElement
|
||||
} else if (win?.frameElement) {
|
||||
node = win.frameElement as Element // same-origin iframe → continue in parent doc
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The actually-focused element, descending through same-origin iframes. */
|
||||
function deepActiveElement(): Element | null {
|
||||
let el: Element | null = document.activeElement
|
||||
@ -267,10 +302,19 @@ function handleMessage(data: string) {
|
||||
break
|
||||
}
|
||||
case 's': {
|
||||
const dy = msg.y ?? 0
|
||||
document.dispatchEvent(new WheelEvent('wheel', {
|
||||
bubbles: true, deltaY: dy * 100, deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
}))
|
||||
// Scroll the element under the virtual cursor (incl. inside same-origin
|
||||
// app frames like the right-hand panel), not the top document. A synthetic
|
||||
// wheel event won't scroll — call scrollBy on a real scroll container. (#7)
|
||||
const dy = (msg.y ?? 0) * 100
|
||||
const dx = (msg.x ?? 0) * 100
|
||||
const start = deepElementFromPoint(cursorX, cursorY)
|
||||
const scroller = findScrollable(start, dx, dy)
|
||||
if (scroller) {
|
||||
scroller.scrollBy({ left: dx, top: dy })
|
||||
} else {
|
||||
const win = start?.ownerDocument?.defaultView ?? window
|
||||
win.scrollBy(dx, dy)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -308,6 +352,30 @@ function doConnect() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the companion (phone) to open a URL in its own browser.
|
||||
*
|
||||
* "Open in external browser" apps can't be usefully opened on the kiosk when a
|
||||
* companion is driving it — `window.open` lands on the kiosk, which the phone
|
||||
* user never sees. When a companion is active we forward the URL over the relay
|
||||
* socket ({"t":"o","url"}); the backend routes it to the phone, which opens it.
|
||||
*
|
||||
* Returns true if the request was forwarded (caller should NOT open locally),
|
||||
* false if there's no active companion (caller should open normally).
|
||||
*/
|
||||
export function requestExternalOpen(url: string): boolean {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return false
|
||||
if (!companionActive.value) return false
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return false
|
||||
try {
|
||||
ws.send(JSON.stringify({ t: 'o', url }))
|
||||
if (import.meta.env.DEV) console.log('[RemoteRelay] Forwarded external-open to companion:', url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the remote relay listener. Connects to /ws/remote-relay. */
|
||||
export function startRemoteRelay() {
|
||||
shouldReconnect = true
|
||||
|
||||
49
neode-ui/src/components/BackButton.vue
Normal file
49
neode-ui/src/components/BackButton.vue
Normal 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>
|
||||
285
neode-ui/src/components/WalletSettingsModal.vue
Normal file
285
neode-ui/src/components/WalletSettingsModal.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<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>
|
||||
|
||||
<!-- ===================== 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 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>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } 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: []; changed: [] }>()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'cashu' as const, label: 'Cashu Mints' },
|
||||
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
|
||||
]
|
||||
const activeTab = ref<'cashu' | 'fedimint'>('cashu')
|
||||
|
||||
// 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('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
loadMints()
|
||||
if (fedimintBackendReady) loadFederations()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
joiningFed.value = true
|
||||
fedError.value = ''
|
||||
try {
|
||||
await rpcClient.call<{ federation_id: string }>({
|
||||
method: 'wallet.fedimint-join',
|
||||
params: { invite_code: inviteCode.value.trim() },
|
||||
})
|
||||
inviteCode.value = ''
|
||||
await loadFederations()
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
|
||||
} finally {
|
||||
joiningFed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
mintError.value = ''
|
||||
mintsSavedOk.value = false
|
||||
fedError.value = ''
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@ -4,6 +4,7 @@ import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
// Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable
|
||||
if (!navigator.clipboard) {
|
||||
@ -31,6 +32,11 @@ app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// Global version formatter — normalizes version labels to a single "v" prefix
|
||||
// (some sources already carry one, which produced "vv1.2.3"). Use `$ver(x)` in
|
||||
// templates instead of hard-coding a `v` prefix.
|
||||
app.config.globalProperties.$ver = displayVersion
|
||||
|
||||
app.config.errorHandler = (err, _instance, info) => {
|
||||
console.error('[Vue Error]', err, info)
|
||||
const { error } = useToast()
|
||||
|
||||
@ -3,6 +3,26 @@ import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||
* driving this kiosk, hand the URL to the phone instead so it opens in the
|
||||
* phone's browser rather than the (often headless / unattended) kiosk display.
|
||||
* Falls back to a local `window.open` when no companion is active.
|
||||
*/
|
||||
function openExternal(launchUrl: string) {
|
||||
// Resolve to an absolute URL so the phone can open it (window.open also
|
||||
// handles absolute URLs fine).
|
||||
let absolute = launchUrl
|
||||
try {
|
||||
absolute = new URL(launchUrl, window.location.origin).href
|
||||
} catch {
|
||||
/* keep as-is */
|
||||
}
|
||||
if (requestExternalOpen(absolute)) return
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
@ -29,8 +49,16 @@ const NEW_TAB_APP_IDS = new Set([
|
||||
'nginx-proxy-manager',
|
||||
'uptime-kuma',
|
||||
'gitea',
|
||||
// netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
||||
// PKCE), so it's served over HTTPS and must open in a real tab — a
|
||||
// self-signed-HTTPS iframe is blocked by the browser (you can't accept the
|
||||
// cert warning inside an iframe).
|
||||
'netbird',
|
||||
])
|
||||
|
||||
// Apps served over HTTPS (self-signed) rather than plain HTTP.
|
||||
const HTTPS_APP_IDS = new Set(['netbird'])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
@ -127,12 +155,16 @@ const APP_ID_TO_PORT: Record<string, string> = {
|
||||
'nginx-proxy-manager': '8081',
|
||||
'uptime-kuma': '3002',
|
||||
gitea: '3001',
|
||||
// Without this, directAppUrl('netbird') returns null and netbird falls
|
||||
// through to the iframe (and never gets its https URL) — issue #15.
|
||||
netbird: '8087',
|
||||
}
|
||||
|
||||
function directAppUrl(appId: string): string | null {
|
||||
const port = APP_ID_TO_PORT[appId]
|
||||
if (!port || typeof window === 'undefined') return null
|
||||
return `http://${window.location.hostname}:${port}`
|
||||
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
|
||||
return `${scheme}://${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
|
||||
@ -192,7 +224,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const mobile = isMobileViewport()
|
||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||
if (launchUrl && !mobile) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@ -212,12 +244,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
const titleHintId = inferAppIdFromTitle(payload.title)
|
||||
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
|
||||
// its OIDC dashboard) must be launched over https — a stale http URL hits
|
||||
// the TLS port and 400s. Upgrade the scheme defensively in every path.
|
||||
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
if (u.protocol === 'http:') {
|
||||
u.protocol = 'https:'
|
||||
launchUrl = u.href
|
||||
}
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
if (resolvedId) recordAppLaunch(resolvedId)
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@ -226,7 +271,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// native launchers and keep the user inside Archipelago.
|
||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
recordAppLaunch(resolvedId)
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@ -238,7 +283,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
|
||||
// Unknown apps that block iframes — open directly in new tab
|
||||
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,9 @@ export interface MeshStatus {
|
||||
messages_sent: number
|
||||
messages_received: number
|
||||
detected_devices?: string[]
|
||||
/** Bitcoin block-header send/receive prefs (issue #28). */
|
||||
announce_block_headers?: boolean
|
||||
receive_block_headers?: boolean
|
||||
}
|
||||
|
||||
export interface MeshPeer {
|
||||
|
||||
@ -729,6 +729,36 @@ input[type="radio"]:active + * {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Transparent "frosted" variant for back buttons — the light counterpart to
|
||||
the solid black .glass-button. Used by the floating mobile back pill so it
|
||||
reads as transparent over content rather than a black slab. */
|
||||
.back-button-glass {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button-glass:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.back-button-glass:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Glass button color variants */
|
||||
.glass-button-warning {
|
||||
background: rgba(251, 146, 60, 0.2);
|
||||
|
||||
22
neode-ui/src/utils/version.ts
Normal file
22
neode-ui/src/utils/version.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Format a version string for display with exactly one leading "v".
|
||||
*
|
||||
* Version strings reach the UI from several sources — manifests (bare like
|
||||
* "1.7.96"), node/federation state and the update RPC (sometimes already
|
||||
* "v1.7.96"). Templates used to hard-code a `v` prefix (`v{{ version }}`),
|
||||
* which produced "vv1.7.96" whenever the source already carried a "v". This
|
||||
* normalizes both shapes to a single "v".
|
||||
*/
|
||||
export function displayVersion(v?: string | null): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
const bare = String(v).trim().replace(/^v+/i, '')
|
||||
return bare ? `v${bare}` : ''
|
||||
}
|
||||
|
||||
// Exposed globally as `$ver` (see main.ts) so templates can normalize version
|
||||
// labels without a per-file import.
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$ver: (v?: string | null) => string
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,6 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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>
|
||||
{{ backButtonText }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button (teleported to escape CSS transform containing block) -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl 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>{{ backButtonText }}</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<div v-if="pkg">
|
||||
<AppHeroSection
|
||||
@ -101,6 +82,7 @@ import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
||||
<p class="text-white/70">
|
||||
@ -115,17 +116,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Back link -->
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
|
||||
>
|
||||
<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>
|
||||
Back to Settings
|
||||
</RouterLink>
|
||||
|
||||
<!-- Add-registry modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
@ -192,8 +182,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Registry {
|
||||
url: string
|
||||
|
||||
@ -2,25 +2,7 @@
|
||||
<div class="cloud-folder-container flex flex-col h-full">
|
||||
<!-- Desktop Back Button + Header -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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>
|
||||
{{ backLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl 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>{{ backLabel }}</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton :label="backLabel" @click="goBack" />
|
||||
|
||||
<!-- Folder Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
@ -172,6 +154,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
||||
import FileGrid from '../components/cloud/FileGrid.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
@ -288,9 +271,17 @@ const section = computed(() => {
|
||||
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
||||
// Whether we're at the section's root folder. Derived from the route (the URL
|
||||
// is the source of truth) rather than cloudStore.currentPath, which is async
|
||||
// and still holds the previous/blank path on first entry — that staleness is
|
||||
// what made entering e.g. "Photos and videos" wrongly show "Back to Parent
|
||||
// Folder" and break the back action.
|
||||
const atSectionRoot = computed(() =>
|
||||
!section.value || routeFolderPath.value === section.value.initialPath
|
||||
)
|
||||
const backLabel = computed(() => {
|
||||
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
|
||||
return cloudStore.currentPath !== section.value.initialPath ? 'Back to Parent Folder' : 'Back to Cloud'
|
||||
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
|
||||
})
|
||||
|
||||
// Initialize native file browser when entering a native-UI section
|
||||
@ -388,8 +379,8 @@ async function navigateCloudPath(path: string) {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
|
||||
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
|
||||
if (useNativeUI.value && !atSectionRoot.value) {
|
||||
navigateCloudPath(parentCloudPath(routeFolderPath.value))
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/cloud')
|
||||
|
||||
@ -1,15 +1,7 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="$router.back()"
|
||||
class="mb-4 flex items-center gap-2 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<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>
|
||||
{{ t('containerDetails.back') }}
|
||||
</button>
|
||||
<BackButton :label="t('containerDetails.back')" @click="$router.back()" />
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@ -135,6 +127,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
type ContainerStateValue =
|
||||
| 'created'
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Web5" @click="$router.push('/dashboard/web5')" />
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<button @click="$router.push('/dashboard/web5')" class="glass-button glass-button-sm px-3 py-1.5 text-sm">
|
||||
← Back
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold text-white">Credentials</h1>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white">Credentials</h1>
|
||||
<p class="text-white/70">Issue, view, and verify W3C Verifiable Credentials</p>
|
||||
</div>
|
||||
|
||||
@ -205,6 +201,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Identity {
|
||||
id: string
|
||||
|
||||
@ -174,8 +174,12 @@ const isGlitching = ref(false)
|
||||
const backgroundImage = computed(() => {
|
||||
const mapped = ROUTE_BACKGROUNDS[route.path]
|
||||
if (mapped) return mapped
|
||||
// Cloud subpages (folders) use the same background as Cloud
|
||||
// Detail/sub pages inherit their parent tab's background so they stay
|
||||
// visually "inside" the section instead of snapping to the home backdrop.
|
||||
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.jpg'
|
||||
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
|
||||
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
|
||||
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.jpg'
|
||||
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
||||
return 'bg-home.jpg'
|
||||
})
|
||||
|
||||
@ -143,7 +143,7 @@
|
||||
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} v{{ featuredBannerApp?.version }}</span>
|
||||
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,24 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-6 mobile-scroll-pad">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="router.push('/dashboard/web5')" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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>
|
||||
Web5
|
||||
</button>
|
||||
<!-- Mobile Back Button -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="router.push('/dashboard/web5')"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl 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>Web5</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton label="Web5" desktop-margin="mb-6" @click="router.push('/dashboard/web5')" />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="hidden md:block mb-8">
|
||||
@ -158,6 +140,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
|
||||
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
|
||||
import FleetAlerts from './fleet/FleetAlerts.vue'
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Back button -->
|
||||
<button @click="goBack" class="flex items-center gap-2 text-white/60 hover:text-white mb-6 transition-colors">
|
||||
<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>{{ t('goalDetail.backToGoals') }}</span>
|
||||
</button>
|
||||
<BackButton :label="t('goalDetail.backToGoals')" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<!-- Goal not found -->
|
||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
||||
@ -175,6 +169,7 @@ import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
|
||||
@ -132,11 +132,13 @@
|
||||
:wallet-onchain="walletOnchain"
|
||||
:wallet-lightning="walletLightning"
|
||||
:wallet-ecash="walletEcash"
|
||||
:wallet-fedimint="walletFedimint"
|
||||
:wallet-transactions="walletTransactions"
|
||||
:is-dev="isDev"
|
||||
@show-send="showSendModal = true"
|
||||
@show-receive="showReceiveModal = true"
|
||||
@show-transactions="showTransactionsModal = true"
|
||||
@show-wallet-settings="showWalletSettingsModal = true"
|
||||
@faucet="devFaucet"
|
||||
@open-in-mempool="openInMempool"
|
||||
/>
|
||||
@ -274,6 +276,7 @@
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -284,6 +287,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||
import WalletSettingsModal from '@/components/WalletSettingsModal.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
@ -486,11 +490,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false)
|
||||
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
|
||||
|
||||
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
|
||||
|
||||
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0)
|
||||
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
||||
@ -498,6 +502,7 @@ function openInMempool(txHash: string) { router.push({ name: 'app-session', para
|
||||
async function loadWeb5Status() {
|
||||
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false; walletOnchain.value = 0; walletLightning.value = 0 }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { walletEcash.value = 0 }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { walletFedimint.value = 0 }
|
||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] }
|
||||
}
|
||||
|
||||
|
||||
@ -1,27 +1,6 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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>
|
||||
{{ backButtonLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
:style="{
|
||||
bottom: bottomPosition,
|
||||
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
|
||||
}"
|
||||
>
|
||||
<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>{{ backButtonLabel }}</span>
|
||||
</button>
|
||||
<BackButton :label="backButtonLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- Loading State -->
|
||||
@ -65,7 +44,7 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -130,7 +109,7 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -377,13 +356,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { handleImageError } from './apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@ -9,6 +9,7 @@ import MeshMap from '@/components/MeshMap.vue'
|
||||
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
|
||||
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
@ -37,6 +38,7 @@ const connectingDevice = ref<string | null>(null)
|
||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||
const mobileShowChat = ref(false)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let wsUnsub: (() => void) | null = null
|
||||
|
||||
// The Public channel (always available on Meshcore)
|
||||
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
|
||||
@ -332,6 +334,14 @@ onMounted(async () => {
|
||||
mesh.fetchDeadmanStatus()
|
||||
mesh.fetchBlockHeaders()
|
||||
}, 5000)
|
||||
|
||||
// Instant peer updates (#48): the backend nudges the data-model revision when
|
||||
// it discovers/updates a mesh peer, so refetch peers on the WS push rather
|
||||
// than waiting for the next 5s poll tick. The poll above stays as a backstop
|
||||
// (e.g. for peers going offline, which isn't pushed).
|
||||
wsUnsub = wsClient.subscribe(() => {
|
||||
mesh.fetchPeers()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -340,6 +350,7 @@ onUnmounted(() => {
|
||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||
if (wsUnsub) { wsUnsub(); wsUnsub = null }
|
||||
})
|
||||
|
||||
// Active chat name for the header
|
||||
|
||||
@ -1,24 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-6">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="router.push('/dashboard/web5')" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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>
|
||||
Web5
|
||||
</button>
|
||||
<!-- Mobile Back Button -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="router.push('/dashboard/web5')"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl 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>Web5</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="router.push(backTarget)" />
|
||||
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
@ -233,10 +215,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
@ -297,7 +280,15 @@ interface FiredAlert {
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Monitoring is reachable from Web5 (the monitoring link) AND from the Home
|
||||
// "System" card (which passes ?from=home). The back button follows the entry
|
||||
// point so it returns where the user came from instead of always Web5.
|
||||
const cameFromHome = computed(() => route.query.from === 'home')
|
||||
const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboard/web5'))
|
||||
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
|
||||
@ -42,6 +42,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FIPS activation status (non-blocking — onboarding never waits on it) -->
|
||||
<div v-if="fipsLabel" class="flex items-center justify-center gap-2 mb-4 text-xs sm:text-sm">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="fipsReady ? 'bg-green-400' : 'bg-orange-400 animate-pulse'"
|
||||
></span>
|
||||
<span class="text-white/60">{{ fipsLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Set Password Button -->
|
||||
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
|
||||
<button
|
||||
@ -57,17 +66,56 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const setPasswordButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
// FIPS auto-activates in a detached task after the seed is written, so it can
|
||||
// lag a few seconds behind onboarding on slow hardware. Surface a gentle status
|
||||
// so the encrypted-transport bring-up reads as "in progress", never "stuck".
|
||||
// This is purely informational — it never blocks moving on to set a password.
|
||||
const fipsLabel = ref('')
|
||||
const fipsReady = ref(false)
|
||||
let fipsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let fipsTries = 0
|
||||
|
||||
async function pollFips() {
|
||||
try {
|
||||
const s = await rpcClient.call<{ key_present?: boolean; service_active?: boolean }>({
|
||||
method: 'fips.status',
|
||||
timeout: 8000,
|
||||
})
|
||||
// Only relevant once a seed-derived FIPS key exists; otherwise stay silent.
|
||||
if (s.key_present) {
|
||||
if (s.service_active) {
|
||||
fipsReady.value = true
|
||||
fipsLabel.value = 'Private connection ready'
|
||||
return // settled — stop polling
|
||||
}
|
||||
fipsLabel.value = 'Securing your private connection…'
|
||||
}
|
||||
} catch {
|
||||
// Backend still booting — ignore and keep polling.
|
||||
}
|
||||
fipsTries += 1
|
||||
if (fipsTries < 20) {
|
||||
fipsTimer = setTimeout(pollFips, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
setPasswordButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
pollFips()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (fipsTimer) clearTimeout(fipsTimer)
|
||||
})
|
||||
|
||||
function goToLogin() {
|
||||
|
||||
@ -31,8 +31,16 @@
|
||||
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<!-- Error (genuine failure — server-starting hiccups retry silently) -->
|
||||
<div v-if="errorMessage" class="text-center">
|
||||
<p class="text-red-400 text-sm mb-3">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="generateSeed"
|
||||
class="path-action-button path-action-button--continue mx-auto"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
@ -115,23 +123,42 @@ function stopTimers() {
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
// Transient errors mean the backend is still booting (slow first boot) — we
|
||||
// retry these silently. Anything else is a genuine failure the user should see.
|
||||
function isServerStartingError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return /502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)
|
||||
}
|
||||
|
||||
async function generateSeed() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate' })
|
||||
// seed.generate is idempotent server-side, so retries are safe. Use a
|
||||
// longer timeout than the default 15s — key derivation + disk writes can be
|
||||
// slow on first boot, and we don't want a spurious abort to look like a
|
||||
// failure.
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate', timeout: 30000 })
|
||||
stopTimers()
|
||||
words.value = res.words
|
||||
loading.value = false
|
||||
waitingForServer.value = false
|
||||
} catch {
|
||||
} catch (err) {
|
||||
loading.value = false
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
if (isServerStartingError(err)) {
|
||||
// Backend not ready yet — keep waiting, retry silently.
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(generateSeed, 4000)
|
||||
} else {
|
||||
// Genuine failure — stop the silent loop and surface it with a manual retry.
|
||||
stopTimers()
|
||||
waitingForServer.value = false
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to generate seed'
|
||||
}
|
||||
retryTimer = setTimeout(generateSeed, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -258,7 +258,15 @@ async function verify() {
|
||||
errorMessage.value = 'Verification failed. Please try again.'
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Verification failed'
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
// Words already matched the local copy, so a network/server hiccup here is
|
||||
// transient — the backend just needs a moment. Don't alarm the user; let
|
||||
// them tap Verify again.
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)) {
|
||||
errorMessage.value = 'Server is still starting. Please tap Verify again in a moment.'
|
||||
} else {
|
||||
errorMessage.value = msg || 'Verification failed'
|
||||
}
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
|
||||
@ -1,26 +1,9 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Header with back button -->
|
||||
<!-- Header with back button — shared component so peer files match local
|
||||
files (CloudFolder) on both desktop and mobile. -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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>
|
||||
Back to Cloud
|
||||
</button>
|
||||
|
||||
<!-- Mobile Back Button -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl 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>Back to Cloud</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton label="Back to Cloud" @click="goBack" />
|
||||
|
||||
<!-- Peer Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
@ -276,14 +259,144 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Payment method picker / Lightning invoice QR (#46) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="payItem"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
@click.self="closePayModal"
|
||||
>
|
||||
<div class="glass-card w-full max-w-md p-5 rounded-2xl relative">
|
||||
<button
|
||||
class="absolute top-3 right-3 text-white/50 hover:text-white transition-colors"
|
||||
@click="closePayModal"
|
||||
>
|
||||
<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>
|
||||
|
||||
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
|
||||
<p class="text-xs text-white/50 mb-4 truncate">
|
||||
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
||||
</p>
|
||||
|
||||
<!-- Step 1: choose a payment method -->
|
||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="downloading === payItem.id"
|
||||
@click="payWithEcash"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ downloading === payItem.id ? 'Paying…' : 'Pay from this node’s ecash wallet' }}</span>
|
||||
<span class="block text-sm text-white/50">Instant, using your ecash balance</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying"
|
||||
@click="payWithLightning"
|
||||
>
|
||||
<svg class="w-6 h-6 text-yellow-400 shrink-0" 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>
|
||||
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
|
||||
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payWithInvoice"
|
||||
>
|
||||
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">Pay from another wallet (QR)</span>
|
||||
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payOnchain"
|
||||
>
|
||||
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ onchainPaying ? 'Sending…' : 'Pay on-chain from my node' }}</span>
|
||||
<span class="block text-sm text-white/50">Sends Bitcoin on-chain from your node’s wallet (slower)</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Lightning invoice -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" 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>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" 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>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
<button
|
||||
v-if="invoiceError"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
|
||||
@click="payMode = 'choose'"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import QRCode from 'qrcode'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
peerId?: string
|
||||
@ -335,6 +448,23 @@ const transportPill = computed(() => {
|
||||
const previewUrls = reactive<Record<string, string>>({})
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// ─── Payment picker (#46) ────────────────────────────────────────────────
|
||||
// When buying a paid item the user chooses how to pay: their local ecash
|
||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||
// they can pay from any external wallet by scanning a QR.
|
||||
const payItem = ref<CatalogItem | null>(null)
|
||||
const payMode = ref<'choose' | 'invoice'>('choose')
|
||||
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
|
||||
const invoiceQr = ref('')
|
||||
const invoiceWaiting = ref(false)
|
||||
const invoiceError = ref('')
|
||||
const invoiceCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
const onchainPaying = ref(false)
|
||||
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Video player modal state
|
||||
const videoPlayerItem = ref<CatalogItem | null>(null)
|
||||
const videoPlayerUrl = ref<string | null>(null)
|
||||
@ -496,54 +626,30 @@ function getItemPrice(access: CatalogItem['access']): number {
|
||||
async function downloadFile(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
|
||||
const price = getItemPrice(item.access)
|
||||
if (price > 0) {
|
||||
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
|
||||
openPayModal(item)
|
||||
return
|
||||
}
|
||||
|
||||
// Free / peers-only download: stream straight from the Range-capable proxy
|
||||
// (B3) to disk instead of pulling the whole file as a base64 blob over RPC.
|
||||
// The old base64-over-RPC path buffered the entire file in node memory AND
|
||||
// the browser, which failed outright for large files (#38). A tiny probe
|
||||
// first surfaces the real server error (peer unreachable on mesh and Tor)
|
||||
// instead of a generic failure (#30).
|
||||
downloading.value = item.id
|
||||
try {
|
||||
const price = getItemPrice(item.access)
|
||||
|
||||
if (price > 0) {
|
||||
// Check ecash balance first
|
||||
try {
|
||||
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
|
||||
method: 'wallet.ecash-balance',
|
||||
})
|
||||
const balance = balanceRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet first.`
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
// Paid download: mint ecash + download atomically
|
||||
const result = await rpcClient.call<{ data?: string; error?: string; price_sats?: number }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (result?.data) {
|
||||
triggerDownload(result.data, item)
|
||||
} else if (result?.error) {
|
||||
purchaseError.value = `Payment failed: ${result.error}`
|
||||
}
|
||||
} else {
|
||||
// Free / peers-only download: stream straight from the Range-capable
|
||||
// proxy (B3) to disk instead of pulling the whole file as a base64 blob
|
||||
// over RPC. The old base64-over-RPC path buffered the entire file in node
|
||||
// memory AND the browser, which failed outright for large files (#38).
|
||||
// A tiny probe first surfaces the real server error (peer unreachable on
|
||||
// mesh and Tor) instead of a generic failure (#30).
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
const probe = await probePeerContent(streamUrl)
|
||||
if (probe !== true) {
|
||||
purchaseError.value = probe
|
||||
return
|
||||
}
|
||||
streamDownload(streamUrl, item)
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
const probe = await probePeerContent(streamUrl)
|
||||
if (probe !== true) {
|
||||
purchaseError.value = probe
|
||||
return
|
||||
}
|
||||
streamDownload(streamUrl, item)
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||
} finally {
|
||||
@ -551,6 +657,280 @@ async function downloadFile(item: CatalogItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPayModal(item: CatalogItem) {
|
||||
payItem.value = item
|
||||
payMode.value = 'choose'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceWaiting.value = false
|
||||
invoiceError.value = ''
|
||||
invoiceCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
function closePayModal() {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
payItem.value = null
|
||||
invoiceWaiting.value = false
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
|
||||
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
|
||||
* the payment and release the file (address is the gate token). Slower than LN
|
||||
* because the seller waits for the tx to appear/confirm.
|
||||
*/
|
||||
async function payOnchain() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || onchainPaying.value) return
|
||||
|
||||
onchainPaying.value = true
|
||||
lnError.value = ''
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
onchainPaying.value = false
|
||||
return
|
||||
}
|
||||
const addr = req.address
|
||||
const send = await rpcClient.call<{ txid?: string; error?: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr, amount: req.amount_sats },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!send?.txid) {
|
||||
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
|
||||
onchainPaying.value = false
|
||||
return
|
||||
}
|
||||
// Broadcast — now wait for the seller to see it and release.
|
||||
pollOnchain(addr)
|
||||
} catch (e: unknown) {
|
||||
lnError.value = e instanceof Error ? e.message : 'Could not pay on-chain'
|
||||
onchainPaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOnchain(address: string) {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) { onchainPaying.value = false; return }
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
method: 'content.onchain-status',
|
||||
params: { onion, content_id: item.id, address },
|
||||
timeout: 30000,
|
||||
})
|
||||
if (res?.paid) {
|
||||
const dl = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-onchain',
|
||||
params: { onion, content_id: item.id, address },
|
||||
timeout: 120000,
|
||||
})
|
||||
onchainPaying.value = false
|
||||
if (dl?.data) {
|
||||
triggerDownload(dl.data, item)
|
||||
closePayModal()
|
||||
} else {
|
||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// transient — keep polling
|
||||
}
|
||||
if (payItem.value && onchainPaying.value) {
|
||||
onchainPollTimer = setTimeout(() => pollOnchain(address), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pay the open item from the local ecash wallet (mint token + download). */
|
||||
async function payWithEcash() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
const price = getItemPrice(item.access)
|
||||
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
try {
|
||||
// Check ecash balance first
|
||||
try {
|
||||
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
|
||||
method: 'wallet.ecash-balance',
|
||||
})
|
||||
const balance = balanceRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.`
|
||||
closePayModal()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (result?.data) {
|
||||
triggerDownload(result.data, item)
|
||||
closePayModal()
|
||||
} else if (result?.error) {
|
||||
purchaseError.value = `Payment failed: ${result.error}`
|
||||
closePayModal()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||
closePayModal()
|
||||
} finally {
|
||||
downloading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the seller for a Lightning invoice, render a QR, and poll for payment. */
|
||||
async function payWithInvoice() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
|
||||
payMode.value = 'invoice'
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!res?.bolt11 || !res?.payment_hash) {
|
||||
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
invoiceWaiting.value = false
|
||||
return
|
||||
}
|
||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
||||
try {
|
||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
||||
} catch {
|
||||
invoiceQr.value = '' // fall back to showing the raw invoice string
|
||||
}
|
||||
scheduleInvoicePoll()
|
||||
} catch (e: unknown) {
|
||||
invoiceError.value = e instanceof Error ? e.message : 'Could not request an invoice'
|
||||
invoiceWaiting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
||||
* release the file. No QR/polling: lnd.payinvoice only returns once the payment
|
||||
* settles, so the payment_hash is immediately valid as the download gate token.
|
||||
*/
|
||||
async function payWithLightning() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || lnPaying.value) return
|
||||
|
||||
lnPaying.value = true
|
||||
lnError.value = ''
|
||||
try {
|
||||
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
return
|
||||
}
|
||||
// 2. Pay it from our own node. Returns only after settlement.
|
||||
const pay = await rpcClient.call<{ payment_hash?: string; payment_error?: string }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: inv.bolt11 },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (pay?.payment_error) {
|
||||
lnError.value = `Payment failed: ${pay.payment_error}`
|
||||
return
|
||||
}
|
||||
// 3. Settled — pull the file using the payment hash as the gate token.
|
||||
const dl = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
triggerDownload(dl.data, item)
|
||||
closePayModal()
|
||||
} else {
|
||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
|
||||
} finally {
|
||||
lnPaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleInvoicePoll() {
|
||||
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
||||
invoicePollTimer = setTimeout(pollInvoice, 3000)
|
||||
}
|
||||
|
||||
async function pollInvoice() {
|
||||
const item = payItem.value
|
||||
const inv = invoiceData.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !inv || !onion) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
method: 'content.invoice-status',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 30000,
|
||||
})
|
||||
if (res?.paid) {
|
||||
// Settled — pull the file using the payment hash as the gate token.
|
||||
invoiceWaiting.value = false
|
||||
const dl = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
triggerDownload(dl.data, item)
|
||||
closePayModal()
|
||||
} else {
|
||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Transient — keep polling.
|
||||
}
|
||||
if (payItem.value && invoiceData.value) scheduleInvoicePoll()
|
||||
}
|
||||
|
||||
async function copyInvoice() {
|
||||
if (!invoiceData.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoiceData.value.bolt11)
|
||||
invoiceCopied.value = true
|
||||
setTimeout(() => { invoiceCopied.value = false }, 2000)
|
||||
} catch { /* clipboard blocked */ }
|
||||
}
|
||||
|
||||
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
|
||||
async function playMedia(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
|
||||
@ -20,7 +21,7 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('common.version') }}</p>
|
||||
<p class="text-xl font-bold text-white">v{{ currentVersion }}</p>
|
||||
<p class="text-xl font-bold text-white">{{ $ver(currentVersion) }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
|
||||
@ -186,6 +187,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update source (origin vs DHT swarm) -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">Update & app source</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Where this node fetches updates and apps. <span class="text-white/80">Stable origin</span> is the
|
||||
known-good default. <span class="text-white/80">DHT swarm</span> pulls content-addressed blobs from
|
||||
peers first and falls back to the origin automatically — use it to live-test the swarm; flip back any time.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="sourceSaving"
|
||||
class="text-left p-4 rounded-lg border transition-colors disabled:opacity-60"
|
||||
:class="updateSource === 'origin' ? 'border-green-400/40 bg-green-500/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
||||
@click="setSource('origin')"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2.5 h-2.5 rounded-full" :class="updateSource === 'origin' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm font-medium text-white">Stable origin</span>
|
||||
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/10 text-white/60">DEFAULT</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">HTTP from the mirrors below. Safe, predictable, always available.</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="sourceSaving"
|
||||
class="text-left p-4 rounded-lg border transition-colors disabled:opacity-60"
|
||||
:class="updateSource === 'swarm' ? 'border-blue-400/40 bg-blue-500/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
||||
@click="setSource('swarm')"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2.5 h-2.5 rounded-full" :class="updateSource === 'swarm' ? 'bg-blue-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm font-medium text-white">DHT swarm</span>
|
||||
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">EXPERIMENTAL</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">Peer-to-peer first, origin fallback. For testing the distributed network.</p>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updateSource === 'swarm' && !swarmAvailable" class="mt-3 text-xs text-orange-300/90 flex items-start gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>This build doesn't include the swarm engine, so DHT mode currently behaves exactly like origin. A swarm-enabled build is required for true peer fetching.</span>
|
||||
</p>
|
||||
|
||||
<!-- Provide to the swarm (seed/serve to peers) -->
|
||||
<div class="mt-5 pt-5 border-t border-white/10 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white mb-0.5">Provide to the swarm</p>
|
||||
<p class="text-xs text-white/50">
|
||||
Let other nodes fetch updates and apps from this one (seeding). On by default so the network has providers.
|
||||
Turn off if this node shouldn't serve content to peers — it won't change how this node fetches.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="provideDht" :disabled="sourceSaving" @update:model-value="setProvide" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mirrors -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
@ -285,12 +342,6 @@
|
||||
>
|
||||
{{ t('systemUpdate.rollback') }}
|
||||
</button>
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium text-center sm:w-auto"
|
||||
>
|
||||
{{ t('systemUpdate.backToSettings') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -451,9 +502,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
interface UpdateDetail {
|
||||
version: string
|
||||
@ -491,6 +543,16 @@ const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
||||
|
||||
// Update/app fetch source: "origin" (HTTP mirrors, default/safe) vs "swarm"
|
||||
// (DHT peer-assist with origin fallback). Lets us live-test the DHT on a node
|
||||
// and flip back instantly. swarmAvailable reflects whether this binary was
|
||||
// built with the iroh swarm engine at all.
|
||||
const updateSource = ref<'origin' | 'swarm'>('origin')
|
||||
const provideDht = ref(true)
|
||||
const swarmAvailable = ref(false)
|
||||
const swarmEnabled = ref(false)
|
||||
const sourceSaving = ref(false)
|
||||
|
||||
// Mirrors — servers this node tries for the manifest, in priority
|
||||
// order. First entry is the primary. Add/remove/set-primary are wired
|
||||
// to update.*-mirror RPCs; downloads automatically go to the mirror
|
||||
@ -564,6 +626,52 @@ async function loadMirrors() {
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceState { source: 'origin' | 'swarm'; provide_dht: boolean; swarm_available: boolean; swarm_enabled: boolean }
|
||||
|
||||
async function loadSource() {
|
||||
try {
|
||||
const res = await rpcClient.call<SourceState>({ method: 'update.get-source' })
|
||||
updateSource.value = res.source
|
||||
swarmAvailable.value = res.swarm_available
|
||||
swarmEnabled.value = res.swarm_enabled
|
||||
if (typeof res.provide_dht === 'boolean') provideDht.value = res.provide_dht
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('update.get-source failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function setSource(source: 'origin' | 'swarm') {
|
||||
if (sourceSaving.value || updateSource.value === source) return
|
||||
sourceSaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<SourceState>({ method: 'update.set-source', params: { source } })
|
||||
updateSource.value = res.source
|
||||
showStatus(source === 'swarm'
|
||||
? 'Update source set to DHT swarm — origin still used as fallback.'
|
||||
: 'Update source set to stable origin.')
|
||||
} catch (e) {
|
||||
showStatus(`Failed to change source: ${e instanceof Error ? e.message : e}`, true)
|
||||
} finally {
|
||||
sourceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setProvide(provide: boolean) {
|
||||
if (sourceSaving.value || provideDht.value === provide) return
|
||||
sourceSaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<SourceState>({ method: 'update.set-source', params: { provide } })
|
||||
if (typeof res.provide_dht === 'boolean') provideDht.value = res.provide_dht
|
||||
showStatus(provide
|
||||
? 'This node will now seed updates/apps to peers.'
|
||||
: 'This node will no longer provide to the swarm.')
|
||||
} catch (e) {
|
||||
showStatus(`Failed to change setting: ${e instanceof Error ? e.message : e}`, true)
|
||||
} finally {
|
||||
sourceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMirror() {
|
||||
const url = mirrorDraft.url.trim()
|
||||
if (!url) return
|
||||
@ -1013,7 +1121,7 @@ async function setSchedule(value: ScheduleValue) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
|
||||
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), loadSource(), checkForUpdates()])
|
||||
// If a download was already running when the user navigated here
|
||||
// (or refreshed), pick up the progress bar where it is and keep
|
||||
// polling until the backend reports done. No RPC call to start the
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||
<span class="text-white/50 text-xs">{{ $ver(pkg.manifest.version) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,6 +76,7 @@ import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -110,7 +111,7 @@ const actionItems = computed(() => {
|
||||
actions.push({
|
||||
key: 'update',
|
||||
emit: 'update',
|
||||
label: props.pendingAction === 'update' ? 'Updating...' : `Update to v${props.pkg['available-update']}`,
|
||||
label: props.pendingAction === 'update' ? 'Updating...' : `Update to ${displayVersion(props.pkg['available-update'])}`,
|
||||
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
|
||||
full: true,
|
||||
})
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<div class="text-right">
|
||||
<span class="text-white font-medium">v{{ pkg.manifest.version }}</span>
|
||||
<span class="text-white font-medium">{{ $ver(pkg.manifest.version) }}</span>
|
||||
<span v-if="pkg['available-update']" class="text-orange-300 text-xs ml-2">
|
||||
v{{ pkg['available-update'] }} available
|
||||
{{ $ver(pkg['available-update']) }} available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
|
||||
>Update</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/50">{{ version ? `v${version}` : '' }}</p>
|
||||
<p class="text-sm text-white/50">{{ version ? $ver(version) : '' }}</p>
|
||||
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,7 +122,7 @@
|
||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||
@click.stop="$emit('update', id)"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
|
||||
:title="`Update to v${pkg['available-update']}`"
|
||||
:title="`Update to ${$ver(pkg['available-update'])}`"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@ -170,6 +170,8 @@ export const TAB_LAUNCH_APPS = new Set([
|
||||
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
||||
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
||||
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
|
||||
'netbird',
|
||||
])
|
||||
|
||||
export function opensInTab(id: string): boolean {
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
<p class="text-xs text-white/60">v{{ version }}</p>
|
||||
<p class="text-xs text-white/60">{{ $ver(version) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -91,6 +91,10 @@ export function useRouteTransitions() {
|
||||
const wasFleet = previousPath === '/dashboard/fleet'
|
||||
const isWeb5 = currentPath === '/dashboard/web5'
|
||||
const wasWeb5 = previousPath === '/dashboard/web5'
|
||||
// Any Web5 sub-detail (networking-profits, credentials, …) animates as a
|
||||
// depth push from/back-to the Web5 tab — same feel as Find Nodes.
|
||||
const isWeb5Detail = currentPath.startsWith('/dashboard/web5/')
|
||||
const wasWeb5Detail = previousPath.startsWith('/dashboard/web5/')
|
||||
|
||||
let transitionName = 'fade'
|
||||
|
||||
@ -146,6 +150,10 @@ export function useRouteTransitions() {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasFleet && isWeb5) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasWeb5 && isWeb5Detail) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasWeb5Detail && isWeb5) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasMarketplaceList && isAppDetails) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasAppDetails && isMarketplaceList) {
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ getAppTier(app.id) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/50">{{ app.version ? `v${app.version}` : 'latest' }}</p>
|
||||
<p class="text-sm text-white/50">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<p v-if="app.author" class="text-xs text-white/40 mt-0.5">{{ app.author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
>{{ getAppTier(app.id) }}</span>
|
||||
<span v-if="isInstalled(app.id)" class="discover-installed-badge">installed</span>
|
||||
</div>
|
||||
<p class="text-white/50 text-sm mb-3">{{ app.author }} · v{{ app.version }}</p>
|
||||
<p class="text-white/50 text-sm mb-3">{{ app.author }} · {{ $ver(app.version) }}</p>
|
||||
<p class="text-white/80 text-sm leading-relaxed">{{ app.featuredDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,25 +1,6 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="router.push('/dashboard/web5')"
|
||||
class="hidden md:flex items-center gap-2 text-white/70 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<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>
|
||||
Web5
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="router.push('/dashboard/web5')"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl 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>Web5</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
|
||||
@ -52,6 +33,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { shortDid } from './utils'
|
||||
import { safeClipboardWrite } from '../web5/utils'
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
|
||||
<p class="text-lg font-bold text-white">v{{ node.version }}</p>
|
||||
<p class="text-lg font-bold text-white">{{ $ver(node.version) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
></span>
|
||||
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
|
||||
</div>
|
||||
<span class="fleet-version-badge">v{{ node.version }}</span>
|
||||
<span class="fleet-version-badge">{{ $ver(node.version) }}</span>
|
||||
</div>
|
||||
<div class="mb-3 truncate text-xs text-white/40">
|
||||
{{ fleetNodeSubtitle(node) }}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ uptimeDisplay }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/monitoring" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/monitoring?from=home" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" 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>
|
||||
|
||||
@ -40,11 +40,17 @@
|
||||
<span>Transactions</span>
|
||||
</template>
|
||||
</button>
|
||||
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
||||
<button
|
||||
@click="$emit('showWalletSettings')"
|
||||
aria-label="Wallet settings"
|
||||
title="Wallet settings"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" 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" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -119,10 +125,19 @@
|
||||
<svg class="w-5 h-5 text-purple-400" 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 text-white/80">{{ t('web5.ecash') }}</span>
|
||||
<span class="text-sm text-white/80">Cashu</span>
|
||||
</div>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg 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="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">Fedimint</span>
|
||||
</div>
|
||||
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
|
||||
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
@ -145,7 +160,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -168,6 +182,7 @@ const props = defineProps<{
|
||||
walletOnchain: number
|
||||
walletLightning: number
|
||||
walletEcash: number
|
||||
walletFedimint: number
|
||||
walletTransactions: WalletTransaction[]
|
||||
isDev: boolean
|
||||
}>()
|
||||
@ -176,6 +191,7 @@ defineEmits<{
|
||||
showSend: []
|
||||
showReceive: []
|
||||
showTransactions: []
|
||||
showWalletSettings: []
|
||||
faucet: []
|
||||
openInMempool: [txHash: string]
|
||||
}>()
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ tierLabel }}</span>
|
||||
</h3>
|
||||
<p class="text-sm text-white/60">{{ app.version ? `v${app.version}` : 'latest' }}</p>
|
||||
<p class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
// Bitcoin-headers-over-mesh send/receive toggles (issue #28). Initialized from
|
||||
// mesh.status (which now carries the persisted prefs) and saved via mesh.configure.
|
||||
const announceHeaders = ref(false)
|
||||
const receiveHeaders = ref(true)
|
||||
const headersSaving = ref(false)
|
||||
watch(
|
||||
() => mesh.status,
|
||||
(s) => {
|
||||
if (!s) return
|
||||
if (typeof s.announce_block_headers === 'boolean') announceHeaders.value = s.announce_block_headers
|
||||
if (typeof s.receive_block_headers === 'boolean') receiveHeaders.value = s.receive_block_headers
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
async function setAnnounceHeaders(v: boolean) {
|
||||
announceHeaders.value = v
|
||||
headersSaving.value = true
|
||||
try { await mesh.configure({ announce_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
|
||||
}
|
||||
async function setReceiveHeaders(v: boolean) {
|
||||
receiveHeaders.value = v
|
||||
headersSaving.value = true
|
||||
try { await mesh.configure({ receive_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
|
||||
}
|
||||
|
||||
const txHexInput = ref('')
|
||||
const bolt11Input = ref('')
|
||||
const bolt11AmountInput = ref('')
|
||||
@ -120,6 +146,22 @@ async function handleRelayLightning() {
|
||||
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header send/receive toggles (issue #28) -->
|
||||
<div class="flex items-center justify-between gap-3 pt-3 mt-2 border-t border-white/10">
|
||||
<div class="min-w-0">
|
||||
<span class="mesh-bitcoin-label">Send headers</span>
|
||||
<small class="mesh-bitcoin-hint">Broadcast new block headers to mesh peers (needs internet)</small>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="announceHeaders" :disabled="headersSaving" @update:model-value="setAnnounceHeaders" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 pt-3">
|
||||
<div class="min-w-0">
|
||||
<span class="mesh-bitcoin-label">Receive headers</span>
|
||||
<small class="mesh-bitcoin-hint">Accept block headers relayed by peers</small>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="receiveHeaders" :disabled="headersSaving" @update:model-value="setReceiveHeaders" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain / Lightning tabs -->
|
||||
|
||||
@ -126,6 +126,61 @@ async function deleteBackup(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery phrase reveal — re-auth gated (password + 2FA when enabled).
|
||||
const showRevealModal = ref(false)
|
||||
const revealPassword = ref('')
|
||||
const revealCode = ref('')
|
||||
const revealPassphrase = ref('')
|
||||
const revealing = ref(false)
|
||||
const revealError = ref('')
|
||||
const revealedWords = ref<string[]>([])
|
||||
const wordsHidden = ref(true)
|
||||
const wordsCopied = ref(false)
|
||||
|
||||
function openReveal() {
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
revealError.value = ''
|
||||
revealedWords.value = []
|
||||
wordsHidden.value = true
|
||||
showRevealModal.value = true
|
||||
}
|
||||
|
||||
async function submitReveal() {
|
||||
if (revealing.value || !revealPassword.value) return
|
||||
revealing.value = true
|
||||
revealError.value = ''
|
||||
try {
|
||||
const params: Record<string, string> = { password: revealPassword.value }
|
||||
if (revealCode.value) params.code = revealCode.value
|
||||
if (revealPassphrase.value) params.passphrase = revealPassphrase.value
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.reveal', params })
|
||||
revealedWords.value = res.words || []
|
||||
wordsHidden.value = true
|
||||
} catch (e: unknown) {
|
||||
revealError.value = e instanceof Error ? e.message : t('settings.revealSeedFailed')
|
||||
} finally {
|
||||
revealing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeReveal() {
|
||||
showRevealModal.value = false
|
||||
revealedWords.value = []
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealedWords.value.join(' '))
|
||||
wordsCopied.value = true
|
||||
setTimeout(() => { wordsCopied.value = false }, 2000)
|
||||
} catch { /* clipboard unavailable */ }
|
||||
}
|
||||
|
||||
// USB Drive Backup
|
||||
interface UsbDriveInfo {
|
||||
device: string
|
||||
@ -198,6 +253,80 @@ defineExpose({ loadBackups })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Recovery phrase Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Recovery phrase</h2>
|
||||
<p class="text-sm text-white/60">
|
||||
View this node's 24-word recovery phrase. You'll need to confirm your password
|
||||
(and 2FA code, if enabled). Only reveal it somewhere private — anyone with these
|
||||
words controls this node.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
@click="openReveal"
|
||||
>Reveal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reveal recovery phrase modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-seed-title">
|
||||
<h3 id="reveal-seed-title" class="text-lg font-semibold text-white mb-1">Reveal recovery phrase</h3>
|
||||
|
||||
<template v-if="revealedWords.length === 0">
|
||||
<p class="text-sm text-white/60 mb-4">Confirm your credentials to display the 24-word phrase.</p>
|
||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Password</label>
|
||||
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
|
||||
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
|
||||
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
|
||||
</div>
|
||||
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
{{ revealing ? 'Verifying…' : 'Reveal' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backup & Restore Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="mb-4">
|
||||
|
||||
@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@ -117,11 +118,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 mb-4 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
@click="router.push('/dashboard/web5')"
|
||||
>← Back to Web5</button>
|
||||
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits — Settings</h1>
|
||||
|
||||
@ -115,6 +115,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: true, // listen on 0.0.0.0 so the dev UI is reachable over the LAN (e.g. http://192.168.1.116:8100)
|
||||
port: 8100,
|
||||
proxy: {
|
||||
'/rpc/v1': {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user