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)
|
// Mesh Networking (LoRa radio via Meshcore)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
case 'mesh.status': {
|
case 'mesh.status': {
|
||||||
|
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -2173,6 +2174,8 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
messages_sent: 23,
|
messages_sent: 23,
|
||||||
messages_received: 47,
|
messages_received: 47,
|
||||||
detected_devices: ['/dev/ttyUSB0'],
|
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': {
|
case 'mesh.configure': {
|
||||||
console.log(`[Mesh] Configure:`, params)
|
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': {
|
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': {
|
case 'network.list-requests': {
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@ -290,6 +290,18 @@
|
|||||||
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
"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",
|
"id": "fedimint-gateway",
|
||||||
"title": "Fedimint Gateway",
|
"title": "Fedimint Gateway",
|
||||||
|
|||||||
@ -139,6 +139,41 @@ function deepElementFromPoint(x: number, y: number): Element | null {
|
|||||||
return el
|
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. */
|
/** The actually-focused element, descending through same-origin iframes. */
|
||||||
function deepActiveElement(): Element | null {
|
function deepActiveElement(): Element | null {
|
||||||
let el: Element | null = document.activeElement
|
let el: Element | null = document.activeElement
|
||||||
@ -267,10 +302,19 @@ function handleMessage(data: string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 's': {
|
case 's': {
|
||||||
const dy = msg.y ?? 0
|
// Scroll the element under the virtual cursor (incl. inside same-origin
|
||||||
document.dispatchEvent(new WheelEvent('wheel', {
|
// app frames like the right-hand panel), not the top document. A synthetic
|
||||||
bubbles: true, deltaY: dy * 100, deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
// 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
|
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. */
|
/** Start the remote relay listener. Connects to /ws/remote-relay. */
|
||||||
export function startRemoteRelay() {
|
export function startRemoteRelay() {
|
||||||
shouldReconnect = true
|
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 App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
|
import { displayVersion } from '@/utils/version'
|
||||||
|
|
||||||
// Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable
|
// Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
@ -31,6 +32,11 @@ app.use(pinia)
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
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) => {
|
app.config.errorHandler = (err, _instance, info) => {
|
||||||
console.error('[Vue Error]', err, info)
|
console.error('[Vue Error]', err, info)
|
||||||
const { error } = useToast()
|
const { error } = useToast()
|
||||||
|
|||||||
@ -3,6 +3,26 @@ import { ref, watch } from 'vue'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { recordAppLaunch } from '@/utils/appUsage'
|
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) */
|
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||||
const NEW_TAB_PORTS = new Set([
|
const NEW_TAB_PORTS = new Set([
|
||||||
@ -29,8 +49,16 @@ const NEW_TAB_APP_IDS = new Set([
|
|||||||
'nginx-proxy-manager',
|
'nginx-proxy-manager',
|
||||||
'uptime-kuma',
|
'uptime-kuma',
|
||||||
'gitea',
|
'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 {
|
function mustOpenInNewTab(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@ -127,12 +155,16 @@ const APP_ID_TO_PORT: Record<string, string> = {
|
|||||||
'nginx-proxy-manager': '8081',
|
'nginx-proxy-manager': '8081',
|
||||||
'uptime-kuma': '3002',
|
'uptime-kuma': '3002',
|
||||||
gitea: '3001',
|
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 {
|
function directAppUrl(appId: string): string | null {
|
||||||
const port = APP_ID_TO_PORT[appId]
|
const port = APP_ID_TO_PORT[appId]
|
||||||
if (!port || typeof window === 'undefined') return null
|
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 mobile = isMobileViewport()
|
||||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||||
if (launchUrl && !mobile) {
|
if (launchUrl && !mobile) {
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
openExternal(launchUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,12 +244,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||||
const titleHintId = inferAppIdFromTitle(payload.title)
|
const titleHintId = inferAppIdFromTitle(payload.title)
|
||||||
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || 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 (!isMobileViewport() && payload.openInNewTab) {
|
||||||
if (resolvedId) recordAppLaunch(resolvedId)
|
if (resolvedId) recordAppLaunch(resolvedId)
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
openExternal(launchUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,7 +271,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// native launchers and keep the user inside Archipelago.
|
// native launchers and keep the user inside Archipelago.
|
||||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||||
recordAppLaunch(resolvedId)
|
recordAppLaunch(resolvedId)
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
openExternal(launchUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,7 +283,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
|
|
||||||
// Unknown apps that block iframes — open directly in new tab
|
// Unknown apps that block iframes — open directly in new tab
|
||||||
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
||||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
openExternal(launchUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,9 @@ export interface MeshStatus {
|
|||||||
messages_sent: number
|
messages_sent: number
|
||||||
messages_received: number
|
messages_received: number
|
||||||
detected_devices?: string[]
|
detected_devices?: string[]
|
||||||
|
/** Bitcoin block-header send/receive prefs (issue #28). */
|
||||||
|
announce_block_headers?: boolean
|
||||||
|
receive_block_headers?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeshPeer {
|
export interface MeshPeer {
|
||||||
|
|||||||
@ -729,6 +729,36 @@ input[type="radio"]:active + * {
|
|||||||
min-height: 36px;
|
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 color variants */
|
||||||
.glass-button-warning {
|
.glass-button-warning {
|
||||||
background: rgba(251, 146, 60, 0.2);
|
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>
|
<template>
|
||||||
<div class="app-details-container pb-16 md:pb-16">
|
<div class="app-details-container pb-16 md:pb-16">
|
||||||
<!-- Desktop Back Button -->
|
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="pkg">
|
<div v-if="pkg">
|
||||||
<AppHeroSection
|
<AppHeroSection
|
||||||
@ -101,6 +82,7 @@ import { useAppLauncherStore } from '../stores/appLauncher'
|
|||||||
import { dummyApps } from '../utils/dummyApps'
|
import { dummyApps } from '../utils/dummyApps'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import type { AppCredentialsResponse } from '@/types/api'
|
import type { AppCredentialsResponse } from '@/types/api'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
|
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
||||||
<p class="text-white/70">
|
<p class="text-white/70">
|
||||||
@ -115,17 +116,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 -->
|
<!-- Add-registry modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
@ -192,8 +182,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
interface Registry {
|
interface Registry {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@ -2,25 +2,7 @@
|
|||||||
<div class="cloud-folder-container flex flex-col h-full">
|
<div class="cloud-folder-container flex flex-col h-full">
|
||||||
<!-- Desktop Back Button + Header -->
|
<!-- Desktop Back Button + Header -->
|
||||||
<div class="shrink-0 mb-4">
|
<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">
|
<BackButton :label="backLabel" @click="goBack" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Folder Header -->
|
<!-- Folder Header -->
|
||||||
<div class="flex items-center justify-between">
|
<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 { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useCloudStore } from '../stores/cloud'
|
import { useCloudStore } from '../stores/cloud'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
||||||
import FileGrid from '../components/cloud/FileGrid.vue'
|
import FileGrid from '../components/cloud/FileGrid.vue'
|
||||||
import ShareModal from '../components/cloud/ShareModal.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 appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||||
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
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(() => {
|
const backLabel = computed(() => {
|
||||||
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
|
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
|
// Initialize native file browser when entering a native-UI section
|
||||||
@ -388,8 +379,8 @@ async function navigateCloudPath(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
|
if (useNativeUI.value && !atSectionRoot.value) {
|
||||||
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
|
navigateCloudPath(parentCloudPath(routeFolderPath.value))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push('/dashboard/cloud')
|
router.push('/dashboard/cloud')
|
||||||
|
|||||||
@ -1,15 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button
|
<BackButton :label="t('containerDetails.back')" @click="$router.back()" />
|
||||||
@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>
|
|
||||||
|
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -135,6 +127,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useContainerStore } from '@/stores/container'
|
import { useContainerStore } from '@/stores/container'
|
||||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
type ContainerStateValue =
|
type ContainerStateValue =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
|
<BackButton label="Back to Web5" @click="$router.push('/dashboard/web5')" />
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<h1 class="text-3xl font-bold text-white">Credentials</h1>
|
||||||
<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>
|
|
||||||
<p class="text-white/70">Issue, view, and verify W3C Verifiable Credentials</p>
|
<p class="text-white/70">Issue, view, and verify W3C Verifiable Credentials</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,6 +201,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
interface Identity {
|
interface Identity {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@ -174,8 +174,12 @@ const isGlitching = ref(false)
|
|||||||
const backgroundImage = computed(() => {
|
const backgroundImage = computed(() => {
|
||||||
const mapped = ROUTE_BACKGROUNDS[route.path]
|
const mapped = ROUTE_BACKGROUNDS[route.path]
|
||||||
if (mapped) return mapped
|
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/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'
|
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
||||||
return 'bg-home.jpg'
|
return 'bg-home.jpg'
|
||||||
})
|
})
|
||||||
|
|||||||
@ -143,7 +143,7 @@
|
|||||||
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
||||||
<span v-else>Install</span>
|
<span v-else>Install</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,24 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-16 md:pb-6 mobile-scroll-pad">
|
<div class="pb-16 md:pb-6 mobile-scroll-pad">
|
||||||
<!-- Desktop Back Button -->
|
<BackButton label="Web5" desktop-margin="mb-6" @click="router.push('/dashboard/web5')" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="hidden md:block mb-8">
|
<div class="hidden md:block mb-8">
|
||||||
@ -158,6 +140,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
|
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
|
||||||
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
|
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
|
||||||
import FleetAlerts from './fleet/FleetAlerts.vue'
|
import FleetAlerts from './fleet/FleetAlerts.vue'
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<!-- Back button -->
|
<BackButton :label="t('goalDetail.backToGoals')" desktop-margin="mb-6" @click="goBack" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Goal not found -->
|
<!-- Goal not found -->
|
||||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
<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 { getGoalById } from '@/data/goals'
|
||||||
import type { GoalStep } from '@/types/goals'
|
import type { GoalStep } from '@/types/goals'
|
||||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||||
const APP_ICON_MAP: Record<string, string> = {
|
const APP_ICON_MAP: Record<string, string> = {
|
||||||
|
|||||||
@ -132,11 +132,13 @@
|
|||||||
:wallet-onchain="walletOnchain"
|
:wallet-onchain="walletOnchain"
|
||||||
:wallet-lightning="walletLightning"
|
:wallet-lightning="walletLightning"
|
||||||
:wallet-ecash="walletEcash"
|
:wallet-ecash="walletEcash"
|
||||||
|
:wallet-fedimint="walletFedimint"
|
||||||
:wallet-transactions="walletTransactions"
|
:wallet-transactions="walletTransactions"
|
||||||
:is-dev="isDev"
|
:is-dev="isDev"
|
||||||
@show-send="showSendModal = true"
|
@show-send="showSendModal = true"
|
||||||
@show-receive="showReceiveModal = true"
|
@show-receive="showReceiveModal = true"
|
||||||
@show-transactions="showTransactionsModal = true"
|
@show-transactions="showTransactionsModal = true"
|
||||||
|
@show-wallet-settings="showWalletSettingsModal = true"
|
||||||
@faucet="devFaucet"
|
@faucet="devFaucet"
|
||||||
@open-in-mempool="openInMempool"
|
@open-in-mempool="openInMempool"
|
||||||
/>
|
/>
|
||||||
@ -274,6 +276,7 @@
|
|||||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||||
|
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -284,6 +287,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||||
import TransactionsModal from '@/components/TransactionsModal.vue'
|
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||||
|
import WalletSettingsModal from '@/components/WalletSettingsModal.vue'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
@ -486,11 +490,11 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Wallet modals
|
// 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 */ } }
|
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[]>([])
|
const walletTransactions = ref<WalletTransaction[]>([])
|
||||||
|
|
||||||
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
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() {
|
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; 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.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 = [] }
|
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>
|
<template>
|
||||||
<div class="app-details-container pb-16 md:pb-16">
|
<div class="app-details-container pb-16 md:pb-16">
|
||||||
<!-- Desktop Back Button -->
|
<BackButton :label="backButtonLabel" desktop-margin="mb-6" @click="goBack" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Transition name="content-fade" mode="out-in">
|
<Transition name="content-fade" mode="out-in">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@ -65,7 +44,7 @@
|
|||||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||||
{{ t('marketplaceDetails.installed') }}
|
{{ t('marketplaceDetails.installed') }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
@ -130,7 +109,7 @@
|
|||||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||||
{{ t('marketplaceDetails.installed') }}
|
{{ t('marketplaceDetails.installed') }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -377,13 +356,12 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import { handleImageError } from './apps/appsConfig'
|
import { handleImageError } from './apps/appsConfig'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { bottomPosition } = useMobileBackButton()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import MeshMap from '@/components/MeshMap.vue'
|
|||||||
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
|
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
|
||||||
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { wsClient } from '@/api/websocket'
|
||||||
import '@/views/mesh/mesh-styles.css'
|
import '@/views/mesh/mesh-styles.css'
|
||||||
|
|
||||||
const mesh = useMeshStore()
|
const mesh = useMeshStore()
|
||||||
@ -37,6 +38,7 @@ const connectingDevice = ref<string | null>(null)
|
|||||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||||
const mobileShowChat = ref(false)
|
const mobileShowChat = ref(false)
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let wsUnsub: (() => void) | null = null
|
||||||
|
|
||||||
// The Public channel (always available on Meshcore)
|
// The Public channel (always available on Meshcore)
|
||||||
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
|
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
|
||||||
@ -332,6 +334,14 @@ onMounted(async () => {
|
|||||||
mesh.fetchDeadmanStatus()
|
mesh.fetchDeadmanStatus()
|
||||||
mesh.fetchBlockHeaders()
|
mesh.fetchBlockHeaders()
|
||||||
}, 5000)
|
}, 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(() => {
|
onUnmounted(() => {
|
||||||
@ -340,6 +350,7 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||||
if (pollInterval) clearInterval(pollInterval)
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||||
|
if (wsUnsub) { wsUnsub(); wsUnsub = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Active chat name for the header
|
// Active chat name for the header
|
||||||
|
|||||||
@ -1,24 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-16 md:pb-6">
|
<div class="pb-16 md:pb-6">
|
||||||
<!-- Desktop Back Button -->
|
<BackButton :label="backLabel" desktop-margin="mb-6" @click="router.push(backTarget)" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="hidden md:block mb-8">
|
<div class="hidden md:block mb-8">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@ -233,10 +215,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import LineChart from '@/components/LineChart.vue'
|
import LineChart from '@/components/LineChart.vue'
|
||||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||||
@ -297,7 +280,15 @@ interface FiredAlert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
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 homeStatus = useHomeStatusStore()
|
||||||
|
|
||||||
const current = ref<MetricSnapshot | null>(null)
|
const current = ref<MetricSnapshot | null>(null)
|
||||||
|
|||||||
@ -42,6 +42,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Set Password Button -->
|
||||||
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
|
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
|
||||||
<button
|
<button
|
||||||
@ -57,17 +66,56 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { playNavSound } from '@/composables/useNavSounds'
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const setPasswordButton = ref<HTMLButtonElement | null>(null)
|
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(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setPasswordButton.value?.focus({ preventScroll: true })
|
setPasswordButton.value?.focus({ preventScroll: true })
|
||||||
}, 500)
|
}, 500)
|
||||||
|
pollFips()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (fipsTimer) clearTimeout(fipsTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
function goToLogin() {
|
function goToLogin() {
|
||||||
|
|||||||
@ -31,8 +31,16 @@
|
|||||||
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error (genuine failure — server-starting hiccups retry silently) -->
|
||||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
<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 -->
|
<!-- Word Grid -->
|
||||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||||
@ -115,23 +123,42 @@ function stopTimers() {
|
|||||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
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() {
|
async function generateSeed() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
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()
|
stopTimers()
|
||||||
words.value = res.words
|
words.value = res.words
|
||||||
loading.value = false
|
loading.value = false
|
||||||
waitingForServer.value = false
|
waitingForServer.value = false
|
||||||
} catch {
|
} catch (err) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (!waitingForServer.value) {
|
if (isServerStartingError(err)) {
|
||||||
waitingForServer.value = true
|
// Backend not ready yet — keep waiting, retry silently.
|
||||||
startElapsedTimer()
|
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.'
|
errorMessage.value = 'Verification failed. Please try again.'
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
isVerifying.value = false
|
isVerifying.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<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">
|
<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">
|
<BackButton label="Back to Cloud" @click="goBack" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Peer Header -->
|
<!-- Peer Header -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@ -276,14 +259,144 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
peerId?: string
|
peerId?: string
|
||||||
@ -335,6 +448,23 @@ const transportPill = computed(() => {
|
|||||||
const previewUrls = reactive<Record<string, string>>({})
|
const previewUrls = reactive<Record<string, string>>({})
|
||||||
const audioPlayer = useAudioPlayer()
|
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
|
// Video player modal state
|
||||||
const videoPlayerItem = ref<CatalogItem | null>(null)
|
const videoPlayerItem = ref<CatalogItem | null>(null)
|
||||||
const videoPlayerUrl = ref<string | null>(null)
|
const videoPlayerUrl = ref<string | null>(null)
|
||||||
@ -496,54 +626,30 @@ function getItemPrice(access: CatalogItem['access']): number {
|
|||||||
async function downloadFile(item: CatalogItem) {
|
async function downloadFile(item: CatalogItem) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
downloading.value = item.id
|
|
||||||
purchaseError.value = null
|
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 {
|
try {
|
||||||
const price = getItemPrice(item.access)
|
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||||
|
const probe = await probePeerContent(streamUrl)
|
||||||
if (price > 0) {
|
if (probe !== true) {
|
||||||
// Check ecash balance first
|
purchaseError.value = probe
|
||||||
try {
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
streamDownload(streamUrl, item)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||||
} finally {
|
} 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. */
|
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
|
||||||
async function playMedia(item: CatalogItem) {
|
async function playMedia(item: CatalogItem) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
|
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
||||||
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
|
<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="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div class="p-4 bg-white/5 rounded-lg">
|
<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-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>
|
||||||
<div class="p-4 bg-white/5 rounded-lg">
|
<div class="p-4 bg-white/5 rounded-lg">
|
||||||
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
|
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
|
||||||
@ -186,6 +187,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Mirrors -->
|
||||||
<div class="glass-card p-6 mb-6">
|
<div class="glass-card p-6 mb-6">
|
||||||
<div class="flex items-start justify-between gap-4 mb-2">
|
<div class="flex items-start justify-between gap-4 mb-2">
|
||||||
@ -285,12 +342,6 @@
|
|||||||
>
|
>
|
||||||
{{ t('systemUpdate.rollback') }}
|
{{ t('systemUpdate.rollback') }}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -451,9 +502,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
|
|
||||||
interface UpdateDetail {
|
interface UpdateDetail {
|
||||||
version: string
|
version: string
|
||||||
@ -491,6 +543,16 @@ const statusIsError = ref(false)
|
|||||||
const downloadPercent = ref(0)
|
const downloadPercent = ref(0)
|
||||||
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
|
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
|
// Mirrors — servers this node tries for the manifest, in priority
|
||||||
// order. First entry is the primary. Add/remove/set-primary are wired
|
// order. First entry is the primary. Add/remove/set-primary are wired
|
||||||
// to update.*-mirror RPCs; downloads automatically go to the mirror
|
// 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() {
|
async function submitMirror() {
|
||||||
const url = mirrorDraft.url.trim()
|
const url = mirrorDraft.url.trim()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@ -1013,7 +1121,7 @@ async function setSchedule(value: ScheduleValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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
|
// If a download was already running when the user navigated here
|
||||||
// (or refreshed), pick up the progress bar where it is and keep
|
// (or refreshed), pick up the progress bar where it is and keep
|
||||||
// polling until the backend reports done. No RPC call to start the
|
// 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>
|
<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']) }}
|
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -76,6 +76,7 @@ import type { PackageDataEntry } from '@/types/api'
|
|||||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||||
|
import { displayVersion } from '@/utils/version'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@ -110,7 +111,7 @@ const actionItems = computed(() => {
|
|||||||
actions.push({
|
actions.push({
|
||||||
key: 'update',
|
key: 'update',
|
||||||
emit: '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',
|
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
|
||||||
full: true,
|
full: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
<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>
|
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||||
<div class="text-right">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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>
|
>Update</span>
|
||||||
</div>
|
</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>
|
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,7 +122,7 @@
|
|||||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||||
@click.stop="$emit('update', id)"
|
@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"
|
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">
|
<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" />
|
<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',
|
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
||||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
||||||
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
'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 {
|
export function opensInTab(id: string): boolean {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<AnimatedLogo />
|
<AnimatedLogo />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -91,6 +91,10 @@ export function useRouteTransitions() {
|
|||||||
const wasFleet = previousPath === '/dashboard/fleet'
|
const wasFleet = previousPath === '/dashboard/fleet'
|
||||||
const isWeb5 = currentPath === '/dashboard/web5'
|
const isWeb5 = currentPath === '/dashboard/web5'
|
||||||
const wasWeb5 = previousPath === '/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'
|
let transitionName = 'fade'
|
||||||
|
|
||||||
@ -146,6 +150,10 @@ export function useRouteTransitions() {
|
|||||||
transitionName = 'depth-forward'
|
transitionName = 'depth-forward'
|
||||||
} else if (wasFleet && isWeb5) {
|
} else if (wasFleet && isWeb5) {
|
||||||
transitionName = 'depth-back'
|
transitionName = 'depth-back'
|
||||||
|
} else if (wasWeb5 && isWeb5Detail) {
|
||||||
|
transitionName = 'depth-forward'
|
||||||
|
} else if (wasWeb5Detail && isWeb5) {
|
||||||
|
transitionName = 'depth-back'
|
||||||
} else if (wasMarketplaceList && isAppDetails) {
|
} else if (wasMarketplaceList && isAppDetails) {
|
||||||
transitionName = 'depth-forward'
|
transitionName = 'depth-forward'
|
||||||
} else if (wasAppDetails && isMarketplaceList) {
|
} else if (wasAppDetails && isMarketplaceList) {
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||||
>{{ getAppTier(app.id) }}</span>
|
>{{ getAppTier(app.id) }}</span>
|
||||||
</div>
|
</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>
|
<p v-if="app.author" class="text-xs text-white/40 mt-0.5">{{ app.author }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
>{{ getAppTier(app.id) }}</span>
|
>{{ getAppTier(app.id) }}</span>
|
||||||
<span v-if="isInstalled(app.id)" class="discover-installed-badge">installed</span>
|
<span v-if="isInstalled(app.id)" class="discover-installed-badge">installed</span>
|
||||||
</div>
|
</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>
|
<p class="text-white/80 text-sm leading-relaxed">{{ app.featuredDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,25 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button
|
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
|
||||||
@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>
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
|
||||||
@ -52,6 +33,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
import { shortDid } from './utils'
|
import { shortDid } from './utils'
|
||||||
import { safeClipboardWrite } from '../web5/utils'
|
import { safeClipboardWrite } from '../web5/utils'
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="monitoring-stat-card">
|
<div class="monitoring-stat-card">
|
||||||
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
|
<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>
|
||||||
<div class="monitoring-stat-card">
|
<div class="monitoring-stat-card">
|
||||||
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
|
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
|
||||||
|
|||||||
@ -36,7 +36,7 @@
|
|||||||
></span>
|
></span>
|
||||||
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
|
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="fleet-version-badge">v{{ node.version }}</span>
|
<span class="fleet-version-badge">{{ $ver(node.version) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3 truncate text-xs text-white/40">
|
<div class="mb-3 truncate text-xs text-white/40">
|
||||||
{{ fleetNodeSubtitle(node) }}
|
{{ fleetNodeSubtitle(node) }}
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
||||||
<p class="text-sm text-white/70">{{ uptimeDisplay }}</p>
|
<p class="text-sm text-white/70">{{ uptimeDisplay }}</p>
|
||||||
</div>
|
</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">
|
<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="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -40,11 +40,17 @@
|
|||||||
<span>Transactions</span>
|
<span>Transactions</span>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</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">
|
<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>
|
</svg>
|
||||||
</RouterLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -119,10 +125,19 @@
|
|||||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span class="text-sm text-white/80">{{ t('web5.ecash') }}</span>
|
<span class="text-sm text-white/80">Cashu</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
|
<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">
|
<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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -168,6 +182,7 @@ const props = defineProps<{
|
|||||||
walletOnchain: number
|
walletOnchain: number
|
||||||
walletLightning: number
|
walletLightning: number
|
||||||
walletEcash: number
|
walletEcash: number
|
||||||
|
walletFedimint: number
|
||||||
walletTransactions: WalletTransaction[]
|
walletTransactions: WalletTransaction[]
|
||||||
isDev: boolean
|
isDev: boolean
|
||||||
}>()
|
}>()
|
||||||
@ -176,6 +191,7 @@ defineEmits<{
|
|||||||
showSend: []
|
showSend: []
|
||||||
showReceive: []
|
showReceive: []
|
||||||
showTransactions: []
|
showTransactions: []
|
||||||
|
showWalletSettings: []
|
||||||
faucet: []
|
faucet: []
|
||||||
openInMempool: [txHash: string]
|
openInMempool: [txHash: string]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||||
>{{ tierLabel }}</span>
|
>{{ tierLabel }}</span>
|
||||||
</h3>
|
</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>
|
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useMeshStore } from '@/stores/mesh'
|
import { useMeshStore } from '@/stores/mesh'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
|
|
||||||
const mesh = useMeshStore()
|
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 txHexInput = ref('')
|
||||||
const bolt11Input = ref('')
|
const bolt11Input = ref('')
|
||||||
const bolt11AmountInput = 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>
|
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- On-Chain / Lightning tabs -->
|
<!-- 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
|
// USB Drive Backup
|
||||||
interface UsbDriveInfo {
|
interface UsbDriveInfo {
|
||||||
device: string
|
device: string
|
||||||
@ -198,6 +253,80 @@ defineExpose({ loadBackups })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 -->
|
<!-- Backup & Restore Section -->
|
||||||
<div class="glass-card px-6 py-6 mb-6">
|
<div class="glass-card px-6 py-6 mb-6">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -117,11 +118,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<button
|
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits — Settings</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits — Settings</h1>
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
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,
|
port: 8100,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/rpc/v1': {
|
'/rpc/v1': {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user