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:
co-authored by
Claude Opus 4.8
parent
bd567cd165
commit
87769cbfbf
@@ -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>
|
||||
Reference in New Issue
Block a user