Merge remote-tracking branch 'origin/main' into ark-merge
This commit is contained in:
@@ -77,12 +77,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
// host to resolve). Points at the companion APK hosted on the 146 release server
|
||||
// (publicly reachable) rather than the local node's /packages copy.
|
||||
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
// The demo serves the APK from its own public origin instead, so the QR never
|
||||
// exposes the release-server address.
|
||||
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
|
||||
const visible = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white text-sm font-semibold py-2.5 transition-colors"
|
||||
class="flex-1 glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-semibold py-2.5"
|
||||
:disabled="loading || !selected"
|
||||
@click="confirm"
|
||||
>
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
|
||||
<!-- Open Channel Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showOpenModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
|
||||
<div v-if="showOpenModal" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
|
||||
<!-- Close Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
|
||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
// Global "back up your Lightning seed" nudge. The full backup flow lives on
|
||||
// the LND app-details page (views/appDetails/LndSeedBackup.vue); this banner
|
||||
// just surfaces it proactively once a wallet seed exists and hasn't been
|
||||
// acknowledged as backed up yet.
|
||||
//
|
||||
// lnd.seed-backup-status → { available, acknowledged }:
|
||||
// available=false → no captured seed (LND not installed / legacy wallet) → stay quiet
|
||||
// acknowledged=true → user already confirmed the backup → stay quiet
|
||||
// available && !acknowledged → prompt
|
||||
|
||||
const SNOOZE_KEY = 'lnd-seed-backup-prompt-snooze-until'
|
||||
const SNOOZE_MS = 24 * 60 * 60 * 1000
|
||||
const POLL_MS = 5 * 60 * 1000
|
||||
const LND_DETAILS_PATH = '/dashboard/apps/lnd'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const needsBackup = ref(false)
|
||||
const snoozedUntil = ref(readSnooze())
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
// Once the backend says acked (or there is no seed to back up), stop polling
|
||||
// for the rest of the session — nothing left for this banner to do.
|
||||
let settled = false
|
||||
|
||||
function readSnooze(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(SNOOZE_KEY)
|
||||
const ts = raw ? Number(raw) : 0
|
||||
return Number.isFinite(ts) ? ts : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthed(): boolean {
|
||||
// Same guard as useMessageToast — never poll unauthenticated (background
|
||||
// 401s on the login page cause refresh loops).
|
||||
try { return localStorage.getItem('neode-auth') === 'true' } catch { return false }
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (settled || !isAuthed()) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
|
||||
method: 'lnd.seed-backup-status',
|
||||
timeout: 5000,
|
||||
})
|
||||
if (!res.available || res.acknowledged) {
|
||||
needsBackup.value = false
|
||||
settled = true
|
||||
stopPolling()
|
||||
} else {
|
||||
needsBackup.value = true
|
||||
}
|
||||
} catch {
|
||||
// Transient RPC failure — keep the current state, retry on next tick.
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer || settled) return
|
||||
void pollStatus()
|
||||
pollTimer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void pollStatus()
|
||||
}, POLL_MS)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => appStore.isAuthenticated, (authed) => {
|
||||
if (authed) startPolling()
|
||||
else {
|
||||
stopPolling()
|
||||
needsBackup.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// The LND details page renders its own prominent backup card — hide the
|
||||
// banner there, and re-check status when the user navigates away so an ack
|
||||
// made on that page hides the banner immediately (not after the next tick).
|
||||
const onLndPage = computed(() => route.path === LND_DETAILS_PATH)
|
||||
watch(onLndPage, (now, was) => {
|
||||
if (was && !now && !settled && isAuthed()) void pollStatus()
|
||||
})
|
||||
|
||||
const show = computed(() =>
|
||||
needsBackup.value && !onLndPage.value && Date.now() >= snoozedUntil.value,
|
||||
)
|
||||
|
||||
function backUpNow() {
|
||||
router.push(LND_DETAILS_PATH).catch(() => {})
|
||||
}
|
||||
|
||||
function snooze() {
|
||||
const until = Date.now() + SNOOZE_MS
|
||||
snoozedUntil.value = until
|
||||
try { localStorage.setItem(SNOOZE_KEY, String(until)) } catch { /* noop */ }
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="seed-banner">
|
||||
<div
|
||||
v-if="show"
|
||||
role="alert"
|
||||
class="fixed bottom-4 left-4 right-4 z-[95] mx-auto w-auto max-w-lg rounded-xl border border-orange-400/30 p-4 seed-banner-glass md:left-auto md:right-6 md:bottom-6"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
|
||||
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">Back up your Lightning wallet seed</p>
|
||||
<p class="mt-0.5 text-sm text-white/70">
|
||||
Your Lightning wallet has a recovery seed that hasn't been backed up yet.
|
||||
Write it down now so your funds survive a disk failure.
|
||||
</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="glass-button glass-button-warning rounded-lg px-4 py-2 text-sm font-medium"
|
||||
@click="backUpNow"
|
||||
>Back up now</button>
|
||||
<button
|
||||
type="button"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
@click="snooze"
|
||||
>Later</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss for now"
|
||||
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
@click="snooze"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.seed-banner-glass {
|
||||
background: rgba(15, 15, 20, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.seed-banner-enter-active,
|
||||
.seed-banner-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.seed-banner-enter-from,
|
||||
.seed-banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
</style>
|
||||
@@ -3,10 +3,12 @@
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
tabindex="-1"
|
||||
data-controller-ignore
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
|
||||
:class="[modelValue ? 'bg-orange-500' : 'bg-white/15', disabled ? 'opacity-40 cursor-not-allowed' : '']"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<div
|
||||
@@ -19,6 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
<template>
|
||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
|
||||
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
||||
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
||||
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
class="px-2.5 py-1 rounded-full text-xs transition-colors"
|
||||
:class="activeFilter === f.key
|
||||
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
|
||||
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
|
||||
@click="activeFilter = f.key"
|
||||
>
|
||||
{{ f.label }}<span v-if="countFor(f.key)" class="text-white/35"> · {{ countFor(f.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
|
||||
<div
|
||||
v-for="tx in transactions"
|
||||
v-for="tx in filteredTransactions"
|
||||
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
|
||||
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
|
||||
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
|
||||
@@ -75,6 +95,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
@@ -93,7 +114,7 @@ interface WalletTransaction {
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
transactions: WalletTransaction[]
|
||||
}>()
|
||||
@@ -101,6 +122,28 @@ defineProps<{
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
|
||||
const filters: Array<{ key: FilterKey; label: string }> = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'onchain', label: 'On-chain' },
|
||||
{ key: 'lightning', label: '⚡ Lightning' },
|
||||
{ key: 'ecash', label: 'Ecash' },
|
||||
]
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
|
||||
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
|
||||
if (f === 'all') return true
|
||||
if (f === 'onchain') return isOnchain(tx)
|
||||
if (f === 'lightning') return tx.kind === 'lightning'
|
||||
return tx.kind === 'cashu' || tx.kind === 'fedimint'
|
||||
}
|
||||
|
||||
const filteredTransactions = computed(() => props.transactions.filter(tx => matchesFilter(tx, activeFilter.value)))
|
||||
function countFor(f: FilterKey): number {
|
||||
if (f === 'all') return 0
|
||||
return props.transactions.filter(tx => matchesFilter(tx, f)).length
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
title="Request to Peer"
|
||||
max-width="max-w-md"
|
||||
:z-index="'z-[3200]'"
|
||||
@close="$emit('cancel')"
|
||||
>
|
||||
<p class="text-white/70 text-sm mb-1">
|
||||
Send a connection request to
|
||||
<span class="text-white font-medium">{{ targetLabel }}</span>
|
||||
</p>
|
||||
<p class="text-white/45 text-xs mb-4">
|
||||
They'll see your request and approve or decline it. Approved peers connect
|
||||
at the Peer level — never trusted automatically.
|
||||
</p>
|
||||
|
||||
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
|
||||
<textarea
|
||||
v-model="message"
|
||||
rows="3"
|
||||
maxlength="280"
|
||||
placeholder="Hey — mind if we peer?"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-white/30 text-right mt-1">{{ message.length }}/280</p>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="sending"
|
||||
@click="$emit('send', message.trim() || undefined)"
|
||||
>
|
||||
{{ sending ? 'Sending…' : 'Send Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
targetLabel: string
|
||||
sending?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
send: [message: string | undefined]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const message = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) message.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
:title="step === 1 ? 'Mesh Device Detected' : 'Set Up Your Mesh Radio'"
|
||||
max-width="max-w-lg"
|
||||
content-class="max-h-[90vh] overflow-y-auto"
|
||||
@close="dismiss"
|
||||
>
|
||||
<!-- Step 1: detection + hardware graphic -->
|
||||
<div v-if="step === 1" class="text-center">
|
||||
<div class="mx-auto my-2 w-44 h-44 relative">
|
||||
<!-- pulsing signal waves behind the board image -->
|
||||
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
|
||||
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M130 44 a30 30 0 0 1 0 28" />
|
||||
<path d="M138 36 a42 42 0 0 1 0 44" />
|
||||
<path d="M146 28 a54 54 0 0 1 0 60" />
|
||||
</g>
|
||||
</svg>
|
||||
<!-- actual board image (vendored from the Meshtastic web flasher) -->
|
||||
<img
|
||||
:src="deviceImage.image"
|
||||
:alt="deviceImage.label"
|
||||
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
|
||||
@error="imageFailed = true"
|
||||
v-if="!imageFailed"
|
||||
/>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
|
||||
</div>
|
||||
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
|
||||
<p class="text-white/60 text-xs mt-1">
|
||||
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
|
||||
<span class="font-mono text-orange-300">{{ devicePath }}</span>
|
||||
</p>
|
||||
<p class="text-white/50 text-xs mt-2">
|
||||
Connect it to join the off-grid mesh — chat, block headers, and payments
|
||||
keep flowing even without internet.
|
||||
</p>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="dismiss">
|
||||
Not Now
|
||||
</button>
|
||||
<button class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" @click="step = 2">
|
||||
Set Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: configuration with presets -->
|
||||
<div v-else>
|
||||
<p class="text-white/60 text-xs mb-4">
|
||||
Archipelago presets are pre-selected — confirm the region and you're on the mesh.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Region — the meaning adapts to the firmware: Meshtastic takes a
|
||||
region enum directly; MeshCore/RNode take raw RF params owned by
|
||||
the firmware/daemon, so the region only drives displayed guidance. -->
|
||||
<div>
|
||||
<label class="block text-sm text-white/80 mb-1">LoRa region / frequency plan</label>
|
||||
<select v-model="form.region" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">Keep the radio's current region</option>
|
||||
<option v-for="r in LORA_REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
|
||||
</select>
|
||||
<p v-if="suggestedRegion && form.region === suggestedRegion.code" class="text-[11px] text-green-400/80 mt-1">
|
||||
Suggested from your node's location
|
||||
</p>
|
||||
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
|
||||
{{ selectedRegion.code }} limits airtime to {{ selectedRegion.dutyCyclePct }}% duty cycle ({{ selectedRegion.band }} MHz) — the radio enforces this automatically.
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} — set on the radio via its MeshCore app if it differs.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'meshcore' && selectedRegion" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore radios keep their flashed RF settings — make sure the radio is on the {{ selectedRegion.band }} MHz band for {{ selectedRegion.code }}.
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Node name -->
|
||||
<div>
|
||||
<label class="block text-sm text-white/80 mb-1">Name on the mesh</label>
|
||||
<input v-model="form.name" maxlength="24" placeholder="e.g. basement-node"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
</div>
|
||||
|
||||
<!-- Channel (Meshtastic secondary channel / MeshCore group channel;
|
||||
Reticulum has no channel concept — IFAC lives in the daemon config) -->
|
||||
<div v-if="effectiveKind !== 'reticulum'">
|
||||
<label class="block text-sm text-white/80 mb-1">Channel</label>
|
||||
<input v-model="form.channel" maxlength="11"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Archipelago nodes find each other on the "archipelago" channel; the public default channel stays active for interop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Firmware (advanced) -->
|
||||
<div>
|
||||
<button class="text-xs text-orange-300 hover:text-orange-200" @click="showAdvanced = !showAdvanced">
|
||||
{{ showAdvanced ? '▾' : '▸' }} Advanced
|
||||
</button>
|
||||
<div v-if="showAdvanced" class="mt-2">
|
||||
<label class="block text-sm text-white/80 mb-1">Radio firmware</label>
|
||||
<select v-model="form.deviceKind" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="auto">Auto-detect (Meshcore → Meshtastic → RNode)</option>
|
||||
<option value="meshcore">MeshCore</option>
|
||||
<option value="meshtastic">Meshtastic</option>
|
||||
<option value="reticulum">Reticulum / RNode</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Pin this if you know what's flashed on the board — auto-detect probes each firmware in order.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="connecting"
|
||||
@click="connect"
|
||||
>
|
||||
{{ connecting ? 'Connecting…' : 'Connect to Mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const showAdvanced = ref(false)
|
||||
const connecting = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
||||
const show = computed(() => !!devicePath.value)
|
||||
const imageFailed = ref(false)
|
||||
const deviceImage = computed(() =>
|
||||
resolveMeshDeviceImage(
|
||||
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
|
||||
)
|
||||
)
|
||||
|
||||
const suggestedRegion = computed(() => {
|
||||
const info = appStore.serverInfo as { lat?: number | null; lon?: number | null } | undefined
|
||||
if (info?.lat != null && info?.lon != null) {
|
||||
return suggestRegionFromLatLon(info.lat, info.lon)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
region: '',
|
||||
name: '',
|
||||
channel: 'archipelago',
|
||||
deviceKind: 'auto',
|
||||
})
|
||||
|
||||
const selectedRegion = computed(() => regionByCode(form.value.region))
|
||||
// The firmware whose options we surface: the explicit pin wins, else the
|
||||
// last detected/connected type, else meshtastic-style (where presets apply).
|
||||
const effectiveKind = computed(() => {
|
||||
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
|
||||
const t = (mesh.status?.device_type ?? '').toLowerCase()
|
||||
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
|
||||
})
|
||||
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
|
||||
|
||||
// (Re)apply presets each time a new device surfaces the modal
|
||||
watch(show, (visible) => {
|
||||
if (visible) {
|
||||
step.value = 1
|
||||
error.value = ''
|
||||
imageFailed.value = false
|
||||
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
|
||||
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
|
||||
form.value.channel = mesh.status?.channel_name || 'archipelago'
|
||||
form.value.deviceKind = mesh.status?.device_kind ?? 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
connecting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const path = devicePath.value
|
||||
await mesh.configure({
|
||||
enabled: true,
|
||||
device_path: path,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||
...(form.value.region ? { lora_region: form.value.region } : {}),
|
||||
device_kind: form.value.deviceKind,
|
||||
})
|
||||
mesh.dismissDetectedDevice(path)
|
||||
void router.push('/mesh')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mesh-detect-waves path {
|
||||
animation: mesh-wave-pulse 2.2s ease-out infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
|
||||
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
|
||||
@keyframes mesh-wave-pulse {
|
||||
0% { opacity: 0; }
|
||||
25% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user