Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<!-- Hop-route visualization modal: click a message's transport pill to see
|
||||
how it traveled. Teleported to body (full-screen backdrop, project rule).
|
||||
Branded redesign: glowing endpoint medallions ringed with EQ segments
|
||||
(ScreensaverRing motif), accent-colored track with staggered relay
|
||||
markers and an animated packet traveling sender → recipient. Vertical
|
||||
stacked layout below 560px; prefers-reduced-motion disables all loops. -->
|
||||
<Teleport to="body">
|
||||
<div class="mesh-transport-modal-backdrop" @click.self="emit('close')">
|
||||
<div class="glass-card hopviz-panel" :style="panelStyle">
|
||||
<h3 class="hopviz-title">{{ transportLabel || 'Message' }} route</h3>
|
||||
<p class="hopviz-sub">{{ senderName }} → {{ recipientName }}</p>
|
||||
|
||||
<div class="hopviz-chain">
|
||||
<div class="hopviz-endpoint">
|
||||
<div class="hopviz-medallion">
|
||||
<span v-for="i in RING_SEGMENTS" :key="i" class="hopviz-seg" :style="ringSegStyle(i)" />
|
||||
<span class="hopviz-glyph">🏝️</span>
|
||||
</div>
|
||||
<span class="hopviz-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="hopviz-path">
|
||||
<div class="hopviz-track" :class="{ 'hopviz-track-unknown': !msg.transport }">
|
||||
<div class="hopviz-track-line"></div>
|
||||
<span
|
||||
v-for="i in relayCount"
|
||||
:key="i"
|
||||
class="hopviz-relay"
|
||||
:style="relayStyle(i)"
|
||||
title="relay node"
|
||||
>
|
||||
<template v-if="msg.transport === 'tor'">🧅</template>
|
||||
<template v-else>
|
||||
<i v-for="b in 3" :key="b" class="hopviz-relay-bar" :style="{ '--bar-i': b }" />
|
||||
</template>
|
||||
</span>
|
||||
<div class="hopviz-packet"></div>
|
||||
</div>
|
||||
<div class="hopviz-track-label">{{ routeLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="hopviz-endpoint">
|
||||
<div class="hopviz-medallion">
|
||||
<span v-for="i in RING_SEGMENTS" :key="i" class="hopviz-seg" :style="ringSegStyle(i)" />
|
||||
<span class="hopviz-glyph">🏝️</span>
|
||||
</div>
|
||||
<span class="hopviz-name">{{ recipientName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSignal" class="hopviz-meta-row">
|
||||
<span class="hopviz-chip hopviz-chip-strong">{{ signalLabel }}</span>
|
||||
<span v-if="peer?.snr != null" class="hopviz-chip">SNR {{ peer.snr.toFixed(1) }} dB</span>
|
||||
<span v-if="peer?.rssi != null" class="hopviz-chip">RSSI {{ peer.rssi }} dBm</span>
|
||||
<p class="hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
||||
</div>
|
||||
<div class="hopviz-meta-row">
|
||||
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||
<span class="hopviz-chip">{{ deliveryLabel }}</span>
|
||||
<span class="hopviz-chip">{{ timeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<button class="mesh-transport-cancel" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { MeshMessage, MeshPeer } from '@/stores/mesh'
|
||||
|
||||
const props = defineProps<{
|
||||
msg: MeshMessage
|
||||
peer: MeshPeer | null
|
||||
/** Pre-computed by Mesh.vue's transportLabel() — don't duplicate the logic. */
|
||||
transportLabel: string | null
|
||||
/** Pre-computed by Mesh.vue's signalQualityLabel(). */
|
||||
signalLabel: string
|
||||
/** Pre-computed by Mesh.vue's timeAgo(). */
|
||||
timeLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
/** EQ segments around each endpoint medallion (compact ScreensaverRing echo). */
|
||||
const RING_SEGMENTS = 14
|
||||
|
||||
/** Accent per transport — MUST match the chat transport pill colors in
|
||||
* mesh-styles.css (.mesh-chat-transport.transport-*). */
|
||||
const TRANSPORT_ACCENTS: Record<string, string> = {
|
||||
meshtastic: '#3eb489', // mint
|
||||
meshcore: '#fb923c', // orange
|
||||
reticulum: '#60a5fa', // blue
|
||||
lora: '#f59e0b', // amber
|
||||
fips: '#a78bfa', // violet
|
||||
tor: '#818cf8', // indigo
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const accent = computed(() => TRANSPORT_ACCENTS[props.msg.transport ?? ''] ?? '#fb923c')
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
'--hop-accent': accent.value,
|
||||
'--hop-accent-soft': hexToRgba(accent.value, 0.35),
|
||||
'--hop-accent-faint': hexToRgba(accent.value, 0.14),
|
||||
}))
|
||||
|
||||
const peerName = computed(() => props.peer?.advert_name || props.msg.peer_name || 'Peer')
|
||||
const senderName = computed(() => (props.msg.direction === 'sent' ? 'You' : peerName.value))
|
||||
const recipientName = computed(() => (props.msg.direction === 'sent' ? peerName.value : 'You'))
|
||||
|
||||
/** LoRa transports only know a hop COUNT (peer.hops; 0 or 0xff/null = direct). */
|
||||
const hops = computed<number | null>(() => {
|
||||
const p = props.peer
|
||||
if (!p || p.hops == null || p.hops === 0xff) return null
|
||||
return p.hops
|
||||
})
|
||||
|
||||
const showSignal = computed(() => props.msg.transport !== 'tor' && props.msg.transport !== 'fips')
|
||||
|
||||
const relayCount = computed(() => {
|
||||
const t = props.msg.transport
|
||||
if (t === 'tor') return 3 // fixed circuit shape, anonymous
|
||||
if (t === 'fips' || !t) return 0 // direct P2P / not recorded
|
||||
return hops.value ? Math.min(hops.value, 6) : 0
|
||||
})
|
||||
|
||||
const routeLabel = computed(() => {
|
||||
const t = props.msg.transport
|
||||
if (t === 'tor') return '🧅 3 anonymous relays'
|
||||
if (t === 'fips') return '⚡ FIPS overlay · direct peer-to-peer'
|
||||
if (!t) return "🛰 transport wasn't recorded for this message"
|
||||
const h = hops.value
|
||||
return `📡 ${h === null || h === 0 ? 'direct radio link' : `${h} hop${h === 1 ? '' : 's'}`}`
|
||||
})
|
||||
|
||||
const deliveryLabel = computed(() =>
|
||||
props.msg.delivered && props.msg.direction === 'sent'
|
||||
? 'delivered ✓✓'
|
||||
: props.msg.direction === 'sent'
|
||||
? 'sent'
|
||||
: 'received',
|
||||
)
|
||||
|
||||
function ringSegStyle(i: number) {
|
||||
return {
|
||||
'--seg-deg': `${((i - 1) / RING_SEGMENTS) * 360}deg`,
|
||||
'--seg-i': String(i - 1),
|
||||
}
|
||||
}
|
||||
|
||||
function relayStyle(i: number) {
|
||||
return {
|
||||
'--relay-pos': `${Math.round((i / (relayCount.value + 1)) * 100)}%`,
|
||||
'--relay-i': String(i - 1),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hopviz-panel {
|
||||
width: min(560px, 94vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 26px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hopviz-title {
|
||||
margin: 0;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--hop-accent);
|
||||
text-shadow: 0 0 18px var(--hop-accent-faint);
|
||||
}
|
||||
.hopviz-sub {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── Chain: sender medallion → track → recipient medallion ─────────────── */
|
||||
.hopviz-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 4px 10px;
|
||||
}
|
||||
/* Staggered route reveal: sender, then track+relays, then recipient. */
|
||||
.hopviz-chain > * {
|
||||
opacity: 0;
|
||||
animation: hopviz-appear 0.4s ease forwards;
|
||||
}
|
||||
.hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
|
||||
.hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
|
||||
.hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
|
||||
@keyframes hopviz-appear {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.hopviz-endpoint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
/* Endpoint medallion: island glyph inside a compact EQ-segment ring
|
||||
(ScreensaverRing technique: rotate + translateY(-radius) + scaleY pulse). */
|
||||
.hopviz-medallion {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
--ring-radius: 30px;
|
||||
}
|
||||
.hopviz-glyph {
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--hop-accent-faint) 0%, rgba(255, 255, 255, 0.03) 70%);
|
||||
border: 1px solid var(--hop-accent-faint);
|
||||
box-shadow: 0 0 26px var(--hop-accent-soft);
|
||||
}
|
||||
.hopviz-seg {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 11px;
|
||||
margin-left: -1.5px;
|
||||
margin-top: -5.5px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.85), var(--hop-accent-soft));
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius)));
|
||||
animation: hopviz-seg-pulse 2.6s ease-in-out infinite;
|
||||
animation-delay: calc(var(--seg-i) * 0.12s);
|
||||
}
|
||||
@keyframes hopviz-seg-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.35;
|
||||
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius))) scaleY(0.5);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.95;
|
||||
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius))) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hopviz-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Path: accent track + relay markers + traveling packet + label ─────── */
|
||||
.hopviz-path {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.hopviz-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
.hopviz-track-line {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(to right, transparent, var(--hop-accent) 12%, var(--hop-accent) 88%, transparent);
|
||||
opacity: 0.75;
|
||||
box-shadow: 0 0 8px var(--hop-accent-soft);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.hopviz-track-unknown .hopviz-track-line { opacity: 0.3; box-shadow: none; }
|
||||
|
||||
/* Relay markers sit ON the track: tiny EQ clusters (🧅 for Tor's circuit). */
|
||||
.hopviz-relay {
|
||||
position: absolute;
|
||||
left: var(--relay-pos);
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 6px var(--hop-accent-soft));
|
||||
}
|
||||
.hopviz-relay-bar {
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, #fff, var(--hop-accent));
|
||||
transform-origin: center;
|
||||
animation: hopviz-bar-pulse 1.8s ease-in-out infinite;
|
||||
animation-delay: calc((var(--relay-i, 0) * 0.3s) + (var(--bar-i, 0) * 0.15s));
|
||||
}
|
||||
.hopviz-relay-bar:nth-child(2) { height: 16px; }
|
||||
@keyframes hopviz-bar-pulse {
|
||||
0%, 100% { transform: scaleY(0.45); opacity: 0.5; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* The packet: a bright glowing dot traveling sender → recipient. */
|
||||
.hopviz-packet {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 6px 1px #fff,
|
||||
0 0 14px 4px var(--hop-accent),
|
||||
0 0 26px 8px var(--hop-accent-soft);
|
||||
opacity: 0;
|
||||
animation: hopviz-packet-x 2.2s ease-in-out infinite;
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
@keyframes hopviz-packet-x {
|
||||
0% { left: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
100% { left: 100%; opacity: 0; }
|
||||
}
|
||||
@keyframes hopviz-packet-y {
|
||||
0% { top: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
.hopviz-track-label {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── Metadata footer: glass chips ──────────────────────────────────────── */
|
||||
.hopviz-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hopviz-chip {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
.hopviz-chip-strong {
|
||||
color: #fff;
|
||||
border-color: var(--hop-accent-soft);
|
||||
background: var(--hop-accent-faint);
|
||||
}
|
||||
.hopviz-note {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
/* ── Mobile: vertical chain, sender top → recipient bottom ─────────────── */
|
||||
@media (max-width: 560px) {
|
||||
.hopviz-panel { width: 94vw; padding: 22px 18px; }
|
||||
.hopviz-chain { flex-direction: column; gap: 8px; padding: 14px 0 8px; }
|
||||
.hopviz-medallion { width: 56px; height: 56px; --ring-radius: 23px; }
|
||||
.hopviz-glyph { inset: 9px; font-size: 1.3rem; }
|
||||
.hopviz-endpoint { min-width: 0; }
|
||||
.hopviz-name { max-width: 80vw; }
|
||||
.hopviz-path { width: 100%; min-width: 0; }
|
||||
.hopviz-track { height: 110px; }
|
||||
.hopviz-track-line {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
height: auto;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(to bottom, transparent, var(--hop-accent) 12%, var(--hop-accent) 88%, transparent);
|
||||
}
|
||||
.hopviz-relay { left: 50%; top: var(--relay-pos); }
|
||||
.hopviz-packet {
|
||||
left: 50%;
|
||||
top: 0%;
|
||||
animation-name: hopviz-packet-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: static layout, no loops, no entrance ──────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hopviz-chain > * { animation: none; opacity: 1; }
|
||||
.hopviz-seg { animation: none; opacity: 0.7; }
|
||||
.hopviz-relay-bar { animation: none; }
|
||||
.hopviz-packet { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
const saving = ref(false)
|
||||
|
||||
const status = computed(() => mesh.assistantStatus)
|
||||
const enabled = ref(false)
|
||||
const model = ref('') // '' = use the backend's default model
|
||||
const policy = ref<'trusted' | 'anyone'>('trusted')
|
||||
const backend = ref<'claude' | 'ollama'>('claude')
|
||||
const allowedContacts = ref<string[]>([])
|
||||
|
||||
// Preset Claude models offered in the dropdown ('' = backend default = Haiku).
|
||||
const CLAUDE_MODELS: { value: string; label: string }[] = [
|
||||
{ value: '', label: 'Default (Claude Haiku 4.5)' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 — fast & cheap' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8 — most capable' },
|
||||
]
|
||||
// Include any non-preset value the node already has so it isn't silently lost.
|
||||
const claudeModelOptions = computed(() => {
|
||||
const opts = [...CLAUDE_MODELS]
|
||||
if (model.value && !opts.some((o) => o.value === model.value)) {
|
||||
opts.push({ value: model.value, label: `${model.value} (custom)` })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
// Sync local controls from the fetched status.
|
||||
watch(
|
||||
status,
|
||||
(s) => {
|
||||
if (!s) return
|
||||
enabled.value = s.enabled
|
||||
model.value = s.model ?? ''
|
||||
policy.value = s.trusted_only ? 'trusted' : 'anyone'
|
||||
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
|
||||
allowedContacts.value = [...(s.allowed_contacts ?? [])]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Addressable contacts (have an archipelago/radio pubkey) for the allowlist.
|
||||
const contactOptions = computed(() =>
|
||||
mesh.peers
|
||||
.filter((p) => !!p.pubkey_hex)
|
||||
.map((p) => ({ pubkey: p.pubkey_hex as string, name: p.advert_name || (p.pubkey_hex as string).slice(0, 10) })),
|
||||
)
|
||||
|
||||
function isAllowed(pubkey: string) {
|
||||
return allowedContacts.value.some((k) => k.toLowerCase() === pubkey.toLowerCase())
|
||||
}
|
||||
function toggleAllowed(pubkey: string) {
|
||||
if (isAllowed(pubkey)) {
|
||||
allowedContacts.value = allowedContacts.value.filter((k) => k.toLowerCase() !== pubkey.toLowerCase())
|
||||
} else {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
}
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
|
||||
// Manually pasting a raw ed25519 pubkey (hex) — for an allowed asker that
|
||||
// isn't in the contact list yet (e.g. a phone/meshcore device).
|
||||
const newPubkey = ref('')
|
||||
const pubkeyError = ref('')
|
||||
// Allowlisted keys that aren't one of our known contacts (manually added).
|
||||
const extraAllowed = computed(() =>
|
||||
allowedContacts.value.filter(
|
||||
(k) => !contactOptions.value.some((c) => c.pubkey.toLowerCase() === k.toLowerCase()),
|
||||
),
|
||||
)
|
||||
function addPubkey() {
|
||||
const pk = newPubkey.value.trim().toLowerCase()
|
||||
pubkeyError.value = ''
|
||||
if (!/^[0-9a-f]{64}$/.test(pk)) {
|
||||
pubkeyError.value = 'Enter a 64-character hex ed25519 public key.'
|
||||
return
|
||||
}
|
||||
if (allowedContacts.value.some((k) => k.toLowerCase() === pk)) {
|
||||
newPubkey.value = ''
|
||||
return
|
||||
}
|
||||
allowedContacts.value = [...allowedContacts.value, pk]
|
||||
newPubkey.value = ''
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
|
||||
// Radio/peers who recently tried `!ai` and were turned away by the policy.
|
||||
// Surfaced so the operator can one-click allow them instead of digging through
|
||||
// the journal for the firmware key. Hide any we've since allowed.
|
||||
const deniedAskers = computed(() =>
|
||||
(status.value?.denied_askers ?? []).filter(
|
||||
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
|
||||
),
|
||||
)
|
||||
function allowDenied(pubkey: string | null) {
|
||||
if (!pubkey) return
|
||||
if (!isAllowed(pubkey)) {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mesh.fetchAssistantStatus()
|
||||
})
|
||||
|
||||
const claudeReady = computed(() => status.value?.claude_available ?? false)
|
||||
const ollamaReady = computed(() => status.value?.ollama_detected ?? false)
|
||||
const availableModels = computed(() => status.value?.models ?? [])
|
||||
const defaultModel = computed(() =>
|
||||
backend.value === 'claude' ? 'Claude Haiku 4.5' : status.value?.default_model ?? 'qwen2.5-coder',
|
||||
)
|
||||
// The selected backend is usable when its provider is available.
|
||||
const backendReady = computed(() =>
|
||||
backend.value === 'claude' ? claudeReady.value : ollamaReady.value,
|
||||
)
|
||||
|
||||
async function apply(partial: {
|
||||
enabled?: boolean
|
||||
model?: string | null
|
||||
trusted_only?: boolean
|
||||
backend?: string
|
||||
allowed_contacts?: string[]
|
||||
}) {
|
||||
saving.value = true
|
||||
try {
|
||||
await mesh.configureAssistant(partial)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onToggle(val: boolean) {
|
||||
enabled.value = val
|
||||
apply({ enabled: val })
|
||||
}
|
||||
function onBackend() {
|
||||
// Reset the model override when switching backend (models differ).
|
||||
model.value = ''
|
||||
apply({ backend: backend.value, model: null })
|
||||
}
|
||||
function onModel() {
|
||||
apply({ model: model.value === '' ? null : model.value })
|
||||
}
|
||||
function onPolicy() {
|
||||
apply({ trusted_only: policy.value === 'trusted' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card mesh-assistant-panel">
|
||||
<h3 class="mesh-panel-title">AI Assistant</h3>
|
||||
|
||||
<!-- Backend chooser -->
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">AI backend</label>
|
||||
<select v-model="backend" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onBackend">
|
||||
<option value="claude">Claude (shared token — no GPU needed)</option>
|
||||
<option value="ollama">Local model (Ollama on this node)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Provider missing -> guidance / deep-link -->
|
||||
<div v-if="status && backend === 'ollama' && !ollamaReady" class="mesh-assistant-install">
|
||||
<p class="text-sm text-white/70 mb-3">
|
||||
Local mode needs the <strong>Ollama</strong> app installed and running.
|
||||
</p>
|
||||
<RouterLink to="/dashboard/marketplace/ollama" class="glass-button mesh-assistant-install-btn">
|
||||
Install AI (Ollama)
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="status && backend === 'claude' && !claudeReady" class="mesh-assistant-install">
|
||||
<p class="text-sm text-white/70">
|
||||
No Claude API token is configured on this node yet. Add one in Settings to use the shared
|
||||
Claude backend, or switch to a local model.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Enable toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
|
||||
:class="enabled ? 'bg-white/10 border-orange-500/40' : 'bg-black/20 border-white/10 hover:border-white/20'"
|
||||
:style="!backendReady ? 'opacity:0.5;cursor:not-allowed' : ''"
|
||||
@click="backendReady && onToggle(!enabled)"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" :class="enabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" :class="enabled ? 'text-white/95' : 'text-white/70'">
|
||||
{{ enabled ? 'Answering mesh AI queries' : 'Answer mesh AI queries' }}
|
||||
</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Peers can ask this node's AI over the radio</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="enabled" @click.stop @update:model-value="backendReady && onToggle($event)" />
|
||||
</button>
|
||||
|
||||
<template v-if="enabled">
|
||||
<div v-if="backend === 'ollama'" class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Model</label>
|
||||
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
|
||||
<option value="">Default ({{ defaultModel }})</option>
|
||||
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Model</label>
|
||||
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
|
||||
<option v-for="m in claudeModelOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Who can ask</label>
|
||||
<select v-model="policy" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onPolicy">
|
||||
<option value="trusted">Trusted nodes only</option>
|
||||
<option value="anyone">Anyone on the mesh</option>
|
||||
</select>
|
||||
<p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
|
||||
Any peer can spend this node's AI budget + airtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Per-contact allowlist: let specific contacts use !ai even when the
|
||||
policy is "trusted only" and they aren't federation-trusted. -->
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Always allow these contacts</label>
|
||||
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
|
||||
No contacts yet — they appear here once you have mesh/federation contacts.
|
||||
</div>
|
||||
<div v-else class="mesh-assistant-allowlist">
|
||||
<label
|
||||
v-for="c in contactOptions"
|
||||
:key="c.pubkey"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
|
||||
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
|
||||
</label>
|
||||
<!-- Manually-added pubkeys not in the contact list -->
|
||||
<label
|
||||
v-for="pk in extraAllowed"
|
||||
:key="pk"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<input type="checkbox" checked @change="toggleAllowed(pk)" />
|
||||
<span class="mesh-assistant-allow-name" :title="pk">{{ pk.slice(0, 10) }}… (added)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Add an arbitrary pubkey directly -->
|
||||
<div class="mesh-assistant-addkey">
|
||||
<input
|
||||
v-model="newPubkey"
|
||||
class="mesh-bitcoin-input mesh-bitcoin-input-sm"
|
||||
placeholder="Paste an ed25519 pubkey (64 hex) to allow"
|
||||
@keyup.enter="addPubkey"
|
||||
/>
|
||||
<button type="button" class="glass-button mesh-bitcoin-input-sm" @click="addPubkey">Add</button>
|
||||
</div>
|
||||
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Recently denied askers: someone tried !ai but the policy turned them
|
||||
away. Show who, and offer a one-click Allow when we know their key. -->
|
||||
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Recently denied</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
|
||||
</p>
|
||||
<div class="mesh-assistant-allowlist">
|
||||
<div
|
||||
v-for="d in deniedAskers"
|
||||
:key="d.contact_id + (d.pubkey_hex || '')"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
|
||||
{{ d.name || ('#' + d.contact_id) }}
|
||||
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}…</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="d.pubkey_hex"
|
||||
type="button"
|
||||
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
|
||||
@click="allowDenied(d.pubkey_hex)"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
|
||||
no key
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
// Bitcoin-headers-over-mesh send/receive toggles (issue #28). Initialized from
|
||||
// mesh.status (which now carries the persisted prefs) and saved via mesh.configure.
|
||||
const announceHeaders = ref(false)
|
||||
const receiveHeaders = ref(true)
|
||||
const headersSaving = ref(false)
|
||||
watch(
|
||||
() => mesh.status,
|
||||
(s) => {
|
||||
if (!s) return
|
||||
if (typeof s.announce_block_headers === 'boolean') announceHeaders.value = s.announce_block_headers
|
||||
if (typeof s.receive_block_headers === 'boolean') receiveHeaders.value = s.receive_block_headers
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
async function setAnnounceHeaders(v: boolean) {
|
||||
announceHeaders.value = v
|
||||
headersSaving.value = true
|
||||
try { await mesh.configure({ announce_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
|
||||
}
|
||||
async function setReceiveHeaders(v: boolean) {
|
||||
receiveHeaders.value = v
|
||||
headersSaving.value = true
|
||||
try { await mesh.configure({ receive_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
|
||||
}
|
||||
|
||||
const txHexInput = ref('')
|
||||
const bolt11Input = ref('')
|
||||
const bolt11AmountInput = ref('')
|
||||
const relayingTx = ref(false)
|
||||
const relayingLn = ref(false)
|
||||
const relayResult = ref('')
|
||||
const meshSendAddr = ref('')
|
||||
const meshSendAmount = ref('')
|
||||
const relayMode = ref<'archy' | 'broadcast'>('archy')
|
||||
const sendTab = ref<'onchain' | 'lightning'>('onchain')
|
||||
|
||||
function pollRelayStatus(requestId: number) {
|
||||
let attempts = 0
|
||||
const maxAttempts = 30
|
||||
const interval = setInterval(async () => {
|
||||
attempts++
|
||||
try {
|
||||
const res = await mesh.relayStatus(requestId)
|
||||
if (res.status === 'confirmed' && res.txid) {
|
||||
relayResult.value = `TX broadcast! txid: ${res.txid.slice(0, 8)}...${res.txid.slice(-8)}`
|
||||
clearInterval(interval)
|
||||
} else if (res.status === 'failed') {
|
||||
const code = res.error_code ? ` [${res.error_code}]` : ''
|
||||
relayResult.value = `Relay failed${code}: ${res.error || 'unknown error'}`
|
||||
clearInterval(interval)
|
||||
} else if (attempts >= maxAttempts) {
|
||||
relayResult.value += ' (timed out waiting for confirmation)'
|
||||
clearInterval(interval)
|
||||
}
|
||||
} catch {
|
||||
if (attempts >= maxAttempts) clearInterval(interval)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleMeshSendBitcoin() {
|
||||
if (!meshSendAddr.value.trim() || !meshSendAmount.value) return
|
||||
relayingTx.value = true
|
||||
relayResult.value = ''
|
||||
try {
|
||||
relayResult.value = 'Creating signed transaction...'
|
||||
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
|
||||
method: 'lnd.create-raw-tx',
|
||||
params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) },
|
||||
})
|
||||
relayResult.value = relayMode.value === 'broadcast'
|
||||
? 'Broadcasting via mesh network...'
|
||||
: 'Sending to Archy peers (encrypted)...'
|
||||
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex, relayMode.value)
|
||||
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for relay peer to broadcast...`
|
||||
meshSendAddr.value = ''
|
||||
meshSendAmount.value = ''
|
||||
pollRelayStatus(relayRes.request_id)
|
||||
} catch (err: unknown) {
|
||||
relayResult.value = err instanceof Error ? err.message : 'Send failed'
|
||||
} finally {
|
||||
relayingTx.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelayTx() {
|
||||
if (!txHexInput.value.trim()) return
|
||||
relayingTx.value = true
|
||||
relayResult.value = ''
|
||||
try {
|
||||
const res = await mesh.relayTransaction(txHexInput.value.trim())
|
||||
relayResult.value = `TX queued (request #${res.request_id})`
|
||||
txHexInput.value = ''
|
||||
} catch (err: unknown) {
|
||||
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
|
||||
} finally {
|
||||
relayingTx.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelayLightning() {
|
||||
if (!bolt11Input.value.trim() || !bolt11AmountInput.value) return
|
||||
relayingLn.value = true
|
||||
relayResult.value = ''
|
||||
try {
|
||||
const res = await mesh.relayLightning(bolt11Input.value.trim(), parseInt(bolt11AmountInput.value))
|
||||
relayResult.value = `Lightning relay queued (request #${res.request_id})`
|
||||
bolt11Input.value = ''
|
||||
bolt11AmountInput.value = ''
|
||||
} catch (err: unknown) {
|
||||
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
|
||||
} finally {
|
||||
relayingLn.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card mesh-bitcoin-panel">
|
||||
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||
|
||||
<!-- Relay status notification -->
|
||||
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
|
||||
{{ relayResult }}
|
||||
</div>
|
||||
|
||||
<!-- Block Headers -->
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
<span class="mesh-bitcoin-label">Latest Block</span>
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
||||
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
||||
</div>
|
||||
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
|
||||
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
|
||||
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
|
||||
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header send/receive toggles (issue #28) -->
|
||||
<div class="flex items-center justify-between gap-3 pt-3 mt-2 border-t border-white/10">
|
||||
<div class="min-w-0">
|
||||
<span class="mesh-bitcoin-label">Send headers</span>
|
||||
<small class="mesh-bitcoin-hint">Broadcast new block headers to mesh peers (needs internet)</small>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="announceHeaders" :disabled="headersSaving" @update:model-value="setAnnounceHeaders" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 pt-3">
|
||||
<div class="min-w-0">
|
||||
<span class="mesh-bitcoin-label">Receive headers</span>
|
||||
<small class="mesh-bitcoin-hint">Accept block headers relayed by peers</small>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="receiveHeaders" :disabled="headersSaving" @update:model-value="setReceiveHeaders" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain / Lightning tabs -->
|
||||
<div class="mesh-send-tabs">
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain tab -->
|
||||
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
|
||||
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
|
||||
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
|
||||
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
|
||||
</button>
|
||||
<details class="mesh-bitcoin-advanced">
|
||||
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
|
||||
<div style="margin-top: 8px;">
|
||||
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
||||
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
||||
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Lightning tab -->
|
||||
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
|
||||
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
|
||||
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
|
||||
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.mesh-bitcoin-panel { padding: 18px; display: flex; flex-direction: column; gap: 14px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||
.mesh-panel-sub { font-size: 0.78rem; color: rgba(255,255,255,0.45); margin: -6px 0 0; }
|
||||
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.mesh-bitcoin-label { font-size: 0.78rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-bitcoin-height { font-size: 0.85rem; font-weight: 700; color: #fb923c; font-family: monospace; }
|
||||
.mesh-bitcoin-height.mesh-muted { color: rgba(255,255,255,0.3); font-weight: 400; }
|
||||
.mesh-bitcoin-hint { font-size: 0.78rem; color: rgba(255,255,255,0.4); margin: 0; }
|
||||
.mesh-bitcoin-input { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: rgba(255,255,255,0.9); padding: 10px 12px; font-size: 0.85rem; font-family: inherit; outline: none; box-sizing: border-box; }
|
||||
.mesh-bitcoin-input:focus { border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.25); }
|
||||
.mesh-bitcoin-input-sm { padding: 8px 12px; font-size: 0.8rem; }
|
||||
textarea.mesh-bitcoin-input { resize: vertical; min-height: 60px; }
|
||||
select.mesh-bitcoin-input { cursor: pointer; }
|
||||
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-block-row { display: flex; align-items: center; gap: 10px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 6px; font-size: 0.78rem; }
|
||||
.mesh-block-height { color: #fb923c; font-weight: 600; font-family: monospace; }
|
||||
.mesh-block-hash { color: rgba(255,255,255,0.4); font-family: monospace; font-size: 0.72rem; }
|
||||
.mesh-send-tabs { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 3px; }
|
||||
.mesh-send-tab { flex: 1; padding: 7px 10px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.8rem; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
|
||||
.mesh-send-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
.mesh-send-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.mesh-relay-mode { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.mesh-relay-mode-option { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; font-size: 0.8rem; color: rgba(255,255,255,0.7); transition: all 0.2s; flex: 1; }
|
||||
.mesh-relay-mode-option:hover { border-color: rgba(255,255,255,0.2); }
|
||||
.mesh-relay-mode-option.active { border-color: rgba(251,146,60,0.4); background: rgba(251,146,60,0.08); color: rgba(255,255,255,0.9); }
|
||||
.mesh-relay-mode-option small { color: rgba(255,255,255,0.4); }
|
||||
.mesh-relay-mode-option input[type="radio"] { accent-color: #fb923c; }
|
||||
.mesh-relay-result { padding: 10px 14px; border-radius: 8px; font-size: 0.8rem; }
|
||||
.mesh-relay-result.success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; }
|
||||
.mesh-relay-result.error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
|
||||
.mesh-bitcoin-advanced { margin-top: 4px; }
|
||||
.mesh-bitcoin-advanced summary { cursor: pointer; color: rgba(255,255,255,0.5); font-size: 0.8rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
const deadmanConfiguring = ref(false)
|
||||
const deadmanInterval = ref('21600')
|
||||
const deadmanEnabled = ref(false)
|
||||
const deadmanCustomMsg = ref('')
|
||||
|
||||
// Sync from store on creation
|
||||
if (mesh.deadmanStatus) {
|
||||
deadmanEnabled.value = mesh.deadmanStatus.dead_man_enabled
|
||||
deadmanInterval.value = String(mesh.deadmanStatus.dead_man_interval_secs)
|
||||
}
|
||||
|
||||
function formatTimeRemaining(secs: number): string {
|
||||
if (secs >= 86400) return `${Math.floor(secs / 3600)}h`
|
||||
if (secs >= 3600) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`
|
||||
if (secs >= 60) return `${Math.floor(secs / 60)}m ${secs % 60}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
|
||||
async function handleDeadmanToggle() {
|
||||
deadmanConfiguring.value = true
|
||||
try {
|
||||
await mesh.configureDeadman({ enabled: deadmanEnabled.value })
|
||||
await mesh.fetchDeadmanStatus()
|
||||
} finally {
|
||||
deadmanConfiguring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeadmanConfigure() {
|
||||
deadmanConfiguring.value = true
|
||||
try {
|
||||
await mesh.configureDeadman({
|
||||
enabled: deadmanEnabled.value,
|
||||
interval_secs: parseInt(deadmanInterval.value) || 21600,
|
||||
custom_message: deadmanCustomMsg.value || undefined,
|
||||
})
|
||||
await mesh.fetchDeadmanStatus()
|
||||
} finally {
|
||||
deadmanConfiguring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeadmanCheckin() {
|
||||
await mesh.deadmanCheckin()
|
||||
await mesh.fetchDeadmanStatus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card mesh-deadman-panel">
|
||||
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
||||
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
||||
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
||||
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
||||
</div>
|
||||
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
|
||||
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
|
||||
</div>
|
||||
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
|
||||
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
|
||||
</div>
|
||||
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
|
||||
I'm OK — Check In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="mesh-deadman-config">
|
||||
<button
|
||||
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
|
||||
:class="deadmanEnabled
|
||||
? 'bg-white/10 border-orange-500/40'
|
||||
: 'bg-black/20 border-white/10 hover:border-white/20'"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
|
||||
</button>
|
||||
|
||||
<template v-if="deadmanEnabled">
|
||||
<div class="mesh-deadman-field">
|
||||
<label class="mesh-bitcoin-label">Trigger Interval</label>
|
||||
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mesh-deadman-field">
|
||||
<label class="mesh-bitcoin-label">Alert Message</label>
|
||||
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
|
||||
</div>
|
||||
|
||||
<div class="mesh-deadman-info">
|
||||
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
|
||||
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
|
||||
</div>
|
||||
|
||||
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
|
||||
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.mesh-deadman-panel { padding: 18px; display: flex; flex-direction: column; gap: 14px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-deadman-status { display: flex; flex-direction: column; gap: 8px; align-items: center; padding: 16px; background: rgba(0,0,0,0.2); border-radius: 10px; }
|
||||
.mesh-deadman-indicator { font-size: 0.75rem; font-weight: 700; letter-spacing: 1px; padding: 4px 14px; border-radius: 6px; text-transform: uppercase; }
|
||||
.mesh-deadman-indicator.armed { background: rgba(251,146,60,0.15); color: #fb923c; border: 1px solid rgba(251,146,60,0.3); }
|
||||
.mesh-deadman-indicator.disabled { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.mesh-deadman-indicator.triggered { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); animation: pulse-alert 1.5s infinite; }
|
||||
@keyframes pulse-alert { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.mesh-deadman-timer { font-size: 1.6rem; font-weight: 700; color: #fb923c; font-family: monospace; }
|
||||
.mesh-deadman-message { font-size: 0.78rem; color: rgba(255,255,255,0.4); text-align: center; }
|
||||
.mesh-deadman-checkin-btn { margin-top: 4px; }
|
||||
.mesh-deadman-config { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.05); padding: 3px 10px; border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,525 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS, RNODE_REGION_PLANS } from '@/utils/loraRegions'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
const rebooting = ref(false)
|
||||
const rebootError = ref<string | null>(null)
|
||||
const rebootMessage = ref<string | null>(null)
|
||||
|
||||
async function handleReboot() {
|
||||
rebooting.value = true
|
||||
rebootError.value = null
|
||||
rebootMessage.value = null
|
||||
// Same as apply: the radio goes away on purpose for ~15-20s.
|
||||
mesh.suppressDeviceDetect()
|
||||
try {
|
||||
const res = await mesh.rebootRadio()
|
||||
// The backend now waits for the device's acknowledgement and says what
|
||||
// actually happened — show it instead of silently going idle again.
|
||||
rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.'
|
||||
} catch (e) {
|
||||
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
|
||||
} finally {
|
||||
rebooting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── RNode (Reticulum) RF settings — full round-trip with device read-back ──
|
||||
|
||||
const rnodeForm = ref({
|
||||
enabled: true,
|
||||
port: '',
|
||||
frequency: '',
|
||||
bandwidth: '125000',
|
||||
spreading_factor: '8',
|
||||
coding_rate: '5',
|
||||
txpower: '17',
|
||||
airtime_limit_short: '',
|
||||
airtime_limit_long: '',
|
||||
})
|
||||
const rnodeLive = ref<Record<string, unknown> | null>(null)
|
||||
const rnodeLiveError = ref<string | null>(null)
|
||||
const rnodeLoading = ref(false)
|
||||
const rnodeApplying = ref(false)
|
||||
const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null)
|
||||
let rnodeSeeded = false
|
||||
|
||||
const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined))
|
||||
|
||||
function setRnodeRecommendedForRegion() {
|
||||
const plan = rnodeRegionPlan.value
|
||||
if (!plan) return
|
||||
rnodeForm.value.frequency = String(plan.frequency)
|
||||
rnodeForm.value.bandwidth = String(plan.bandwidth)
|
||||
rnodeForm.value.spreading_factor = String(plan.spreading_factor)
|
||||
rnodeForm.value.coding_rate = String(plan.coding_rate)
|
||||
rnodeForm.value.txpower = String(plan.txpower)
|
||||
rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : ''
|
||||
rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : ''
|
||||
}
|
||||
|
||||
async function loadRnodeConfig() {
|
||||
rnodeLoading.value = true
|
||||
try {
|
||||
const res = await mesh.getRnodeConfig()
|
||||
rnodeLive.value = res.live
|
||||
rnodeLiveError.value = res.live_error
|
||||
const s = res.settings as Record<string, unknown>
|
||||
if (!rnodeSeeded && s) {
|
||||
rnodeSeeded = true
|
||||
rnodeForm.value.enabled = s.enabled !== false
|
||||
rnodeForm.value.port = (s.port as string) ?? ''
|
||||
rnodeForm.value.frequency = String(s.frequency ?? '')
|
||||
rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000')
|
||||
rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8')
|
||||
rnodeForm.value.coding_rate = String(s.coding_rate ?? '5')
|
||||
rnodeForm.value.txpower = String(s.txpower ?? '17')
|
||||
rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : ''
|
||||
rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : ''
|
||||
}
|
||||
} catch (e) {
|
||||
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings'
|
||||
} finally {
|
||||
rnodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRnodeSettings() {
|
||||
rnodeApplying.value = true
|
||||
rnodeResult.value = null
|
||||
// Applying deliberately restarts the radio daemon; without this the
|
||||
// "new device detected" modal interrupts the flow mid-apply.
|
||||
mesh.suppressDeviceDetect()
|
||||
try {
|
||||
const res = await mesh.applyRnodeConfig({
|
||||
enabled: rnodeForm.value.enabled,
|
||||
port: rnodeForm.value.port.trim() || null,
|
||||
frequency: Number(rnodeForm.value.frequency),
|
||||
bandwidth: Number(rnodeForm.value.bandwidth),
|
||||
spreading_factor: Number(rnodeForm.value.spreading_factor),
|
||||
coding_rate: Number(rnodeForm.value.coding_rate),
|
||||
txpower: Number(rnodeForm.value.txpower),
|
||||
airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short),
|
||||
airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long),
|
||||
})
|
||||
rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message }
|
||||
if (res.live) rnodeLive.value = res.live
|
||||
} catch (e) {
|
||||
rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' }
|
||||
} finally {
|
||||
rnodeApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRnodeLive() {
|
||||
rnodeLoading.value = true
|
||||
try {
|
||||
const res = await mesh.getRnodeConfig()
|
||||
rnodeLive.value = res.live
|
||||
rnodeLiveError.value = res.live_error
|
||||
} catch (e) {
|
||||
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state'
|
||||
} finally {
|
||||
rnodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMhz(v: unknown): string {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—'
|
||||
}
|
||||
|
||||
// ── Editable settings (persisted via mesh.configure) ──
|
||||
const form = ref({
|
||||
region: '',
|
||||
deviceKind: 'auto',
|
||||
channel: 'archipelago',
|
||||
name: '',
|
||||
broadcastIdentity: true,
|
||||
// MeshCore LoRa PHY params (human units; converted to firmware units on
|
||||
// save). All four empty = leave the radio's flashed settings untouched.
|
||||
rfFreqMhz: '',
|
||||
rfBwKhz: '',
|
||||
rfSf: '',
|
||||
rfCr: '',
|
||||
})
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const saveDone = ref(false)
|
||||
let seeded = false
|
||||
|
||||
// Seed the form once from status (don't clobber in-progress edits on poll)
|
||||
watch(
|
||||
() => mesh.status,
|
||||
(s) => {
|
||||
if (!s || seeded) return
|
||||
seeded = true
|
||||
form.value.region = s.lora_region ?? ''
|
||||
form.value.deviceKind = s.device_kind ?? 'auto'
|
||||
form.value.channel = s.channel_name || 'archipelago'
|
||||
form.value.name = s.self_advert_name ?? ''
|
||||
const rp = (s as Record<string, unknown>).lora_radio_params as
|
||||
| { freq_khz: number; bw_hz: number; sf: number; cr: number }
|
||||
| null
|
||||
| undefined
|
||||
if (rp) {
|
||||
applyingPreset = true
|
||||
form.value.rfFreqMhz = String(rp.freq_khz / 1000)
|
||||
form.value.rfBwKhz = String(rp.bw_hz / 1000)
|
||||
form.value.rfSf = String(rp.sf)
|
||||
form.value.rfCr = String(rp.cr)
|
||||
// Show the matching named preset if the stored values are one; else Custom.
|
||||
const match = MESHCORE_RF_PRESETS.find(
|
||||
(p) =>
|
||||
Math.round(p.freqMhz * 1000) === rp.freq_khz &&
|
||||
Math.round(p.bwKhz * 1000) === rp.bw_hz &&
|
||||
p.sf === rp.sf &&
|
||||
p.cr === rp.cr,
|
||||
)
|
||||
rfPreset.value = match ? match.id : 'custom'
|
||||
applyingPreset = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// RF preset dropdown: picking a named plan fills the four fields; hand-editing
|
||||
// any field flips the dropdown to Custom so it never misrepresents the values.
|
||||
const rfPreset = ref('')
|
||||
let applyingPreset = false
|
||||
function selectRfPreset(id: string) {
|
||||
rfPreset.value = id
|
||||
if (id === 'custom' || id === '') return
|
||||
const p = MESHCORE_RF_PRESETS.find((x) => x.id === id)
|
||||
if (!p) return
|
||||
applyingPreset = true
|
||||
form.value.rfFreqMhz = String(p.freqMhz)
|
||||
form.value.rfBwKhz = String(p.bwKhz)
|
||||
form.value.rfSf = String(p.sf)
|
||||
form.value.rfCr = String(p.cr)
|
||||
applyingPreset = false
|
||||
}
|
||||
watch(
|
||||
() => [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr],
|
||||
() => {
|
||||
if (!applyingPreset && rfPreset.value && rfPreset.value !== 'custom') rfPreset.value = 'custom'
|
||||
},
|
||||
)
|
||||
|
||||
const selectedRegion = computed(() => regionByCode(form.value.region))
|
||||
const deviceType = computed(() => mesh.status?.device_type ?? 'unknown')
|
||||
// Firmware whose options apply: explicit pin wins, else the connected type.
|
||||
const effectiveKind = computed(() => {
|
||||
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
|
||||
const t = deviceType.value.toLowerCase()
|
||||
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
|
||||
})
|
||||
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
saveError.value = null
|
||||
saveDone.value = false
|
||||
try {
|
||||
// MeshCore RF params: a named preset sends its plan; Custom sends the
|
||||
// four fields (all required — partial input is an error); "Keep the
|
||||
// radio's current settings" omits the key so the config is untouched.
|
||||
let rfParams: { freq_khz: number; bw_hz: number; sf: number; cr: number } | undefined
|
||||
if (rfPreset.value && rfPreset.value !== 'custom') {
|
||||
const p = MESHCORE_RF_PRESETS.find((x) => x.id === rfPreset.value)
|
||||
if (p) {
|
||||
rfParams = {
|
||||
freq_khz: Math.round(p.freqMhz * 1000),
|
||||
bw_hz: Math.round(p.bwKhz * 1000),
|
||||
sf: p.sf,
|
||||
cr: p.cr,
|
||||
}
|
||||
}
|
||||
} else if (rfPreset.value === 'custom') {
|
||||
const rf = [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr]
|
||||
if (!rf.every((v) => String(v).trim() !== '')) {
|
||||
throw new Error('Fill in all four RF fields (frequency, bandwidth, SF, CR)')
|
||||
}
|
||||
rfParams = {
|
||||
freq_khz: Math.round(parseFloat(form.value.rfFreqMhz) * 1000),
|
||||
bw_hz: Math.round(parseFloat(form.value.rfBwKhz) * 1000),
|
||||
sf: parseInt(form.value.rfSf, 10),
|
||||
cr: parseInt(form.value.rfCr, 10),
|
||||
}
|
||||
}
|
||||
await mesh.configure({
|
||||
lora_region: form.value.region,
|
||||
device_kind: form.value.deviceKind,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
// Always sent: an empty string CLEARS the custom mesh name (backend
|
||||
// maps "" -> None -> fall back to the server name). The old omit-when-
|
||||
// empty made clearing impossible once a name was ever set.
|
||||
advert_name: form.value.name.trim(),
|
||||
broadcast_identity: form.value.broadcastIdentity,
|
||||
...(rfParams ? { lora_radio_params: rfParams } : {}),
|
||||
})
|
||||
saveDone.value = true
|
||||
setTimeout(() => { saveDone.value = false }, 5000)
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load the RNode settings + live state as soon as the panel knows a
|
||||
// Reticulum radio is (or is pinned as) the device. Declared LAST: with
|
||||
// `immediate: true` the source getter runs at setup, and `effectiveKind`
|
||||
// must already exist (the SendBitcoinModal TDZ-crash lesson).
|
||||
watch(
|
||||
() => effectiveKind.value,
|
||||
(kind) => {
|
||||
if (kind === 'reticulum') void loadRnodeConfig()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card mesh-device-panel">
|
||||
<h3 class="mesh-panel-title">Device</h3>
|
||||
<p class="mesh-panel-sub">Firmware, identity, and radio controls for the connected mesh device</p>
|
||||
|
||||
<div v-if="mesh.status" class="mesh-device-panel-grid">
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Firmware</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.firmware_version ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Node ID</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Type</span>
|
||||
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radio settings -->
|
||||
<div class="mt-5 pt-4 border-t border-white/10">
|
||||
<h4 class="text-sm font-semibold text-white mb-3">Radio Settings</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 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="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
|
||||
{{ selectedRegion.code }}: {{ selectedRegion.dutyCyclePct }}% duty-cycle limit, {{ selectedRegion.band }} MHz, max {{ selectedRegion.maxPowerDbm }} dBm
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'meshtastic' || effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-1">
|
||||
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
|
||||
</p>
|
||||
<p v-else-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 the RF fields below to program the radio.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
Pick your region, then use "Set recommended for region" in the RNode section below.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 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 the flashed firmware so no other protocol's probe bytes touch the port.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="effectiveKind !== 'reticulum'">
|
||||
<label class="block text-xs text-white/60 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name on the mesh</label>
|
||||
<input v-model="form.name" maxlength="24" placeholder="node name" 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>
|
||||
</div>
|
||||
|
||||
<!-- MeshCore LoRa PHY params — every radio on the local mesh must match
|
||||
or it hears RF energy but decodes nothing. Empty = leave the radio's
|
||||
flashed settings untouched. -->
|
||||
<div v-if="effectiveKind === 'meshcore'" class="mt-4">
|
||||
<h5 class="text-xs font-semibold text-white/80 mb-2">MeshCore RF parameters</h5>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency plan</label>
|
||||
<select :value="rfPreset" 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" @change="selectRfPreset(($event.target as HTMLSelectElement).value)">
|
||||
<option value="">Keep the radio's current settings</option>
|
||||
<option v-for="p in MESHCORE_RF_PRESETS" :key="p.id" :value="p.id">{{ p.label }}</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="rfPreset === 'custom'" class="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency (MHz)</label>
|
||||
<input v-model="form.rfFreqMhz" inputmode="decimal" placeholder="869.618" 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>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Bandwidth (kHz)</label>
|
||||
<select v-model="form.rfBwKhz" 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="">—</option>
|
||||
<option v-for="bw in ['62.5', '125', '250', '500']" :key="bw" :value="bw">{{ bw }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
|
||||
<select v-model="form.rfSf" 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="">—</option>
|
||||
<option v-for="sf in [5, 6, 7, 8, 9, 10, 11, 12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
|
||||
<select v-model="form.rfCr" 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="">—</option>
|
||||
<option v-for="cr in [5, 6, 7, 8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
|
||||
</p>
|
||||
</div>
|
||||
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
|
||||
first (radio-confirmed read-back), then every parameter editable,
|
||||
with apply → device confirmation. Actions stack in a column. -->
|
||||
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h5 class="text-xs font-semibold text-white/80">RNode radio — current device settings</h5>
|
||||
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
|
||||
{{ rnodeLoading ? 'Reading…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
|
||||
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
|
||||
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '—' }} Hz</span></div>
|
||||
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '—' }}</span></div>
|
||||
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '—' }} dBm</span></div>
|
||||
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '—' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '—' }}%</span></div>
|
||||
</div>
|
||||
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
|
||||
|
||||
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode RF parameters</h5>
|
||||
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Frequency (Hz)</label>
|
||||
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" 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>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Bandwidth (Hz)</label>
|
||||
<select v-model="rnodeForm.bandwidth" 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 v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :key="bw" :value="bw">{{ bw }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
|
||||
<select v-model="rnodeForm.spreading_factor" 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 v-for="sf in [5,6,7,8,9,10,11,12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
|
||||
<select v-model="rnodeForm.coding_rate" 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 v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
|
||||
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" 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>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Airtime short (%)</label>
|
||||
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" 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>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Airtime long (%)</label>
|
||||
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" 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>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Serial port</label>
|
||||
<input v-model="rnodeForm.port" placeholder="auto-detect" 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>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
RNode interface enabled
|
||||
</label>
|
||||
|
||||
<!-- Actions: stacked in a column on purpose (operator layout request) -->
|
||||
<div class="flex flex-col gap-2 mt-4 max-w-sm">
|
||||
<button
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="!rnodeRegionPlan || rnodeApplying"
|
||||
@click="setRnodeRecommendedForRegion"
|
||||
>
|
||||
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
|
||||
</button>
|
||||
<button
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="rnodeApplying"
|
||||
@click="applyRnodeSettings"
|
||||
>
|
||||
{{ rnodeApplying ? 'Applying — waiting for the radio to confirm…' : 'Apply & Confirm on Device' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
|
||||
<template v-if="rnodeResult.ok && rnodeResult.confirmed">✓ </template>{{ rnodeResult.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
Periodically broadcast this node's identity on the mesh
|
||||
</label>
|
||||
|
||||
<p v-if="effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-3">
|
||||
Options adapt to the detected firmware: region + channel program Meshtastic radios;
|
||||
MeshCore and RNode own their RF parameters in firmware/daemon config; name and identity apply to all.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="saving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applying to the radio now…</span>
|
||||
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mesh-device-panel-actions">
|
||||
<button
|
||||
class="glass-button mesh-device-reboot-btn"
|
||||
:disabled="rebooting || !mesh.status?.device_connected"
|
||||
@click="handleReboot"
|
||||
>
|
||||
<span v-if="rebooting" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<template v-else>Reboot Radio</template>
|
||||
</button>
|
||||
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
|
||||
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
|
||||
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,615 @@
|
||||
/* Mesh view styles — extracted from Mesh.vue
|
||||
* Unscoped — mesh-* classes must reach child components (MeshBitcoinPanel, MeshDeadmanPanel)
|
||||
*/
|
||||
|
||||
.mesh-view {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 921px) {
|
||||
.mesh-dashboard-panel.mobile-scroll-pad {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 921px) and (max-width: 1279px) {
|
||||
.mesh-dashboard-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
.mesh-header { justify-content: space-between; align-items: center; gap: 16px; flex-shrink: 0; }
|
||||
.mesh-header-left { flex: 1; }
|
||||
.mesh-title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.mesh-title { font-size: 1.5rem; font-weight: 700; color: rgba(255, 255, 255, 0.95); margin: 0; }
|
||||
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
|
||||
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
|
||||
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
||||
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
||||
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; overscroll-behavior: contain; }
|
||||
.mesh-tools-wrapper { display: contents; }
|
||||
.mesh-tools-tab-bar { display: none; }
|
||||
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
|
||||
.mesh-columns-wide .mesh-left { grid-column: 1; width: auto; }
|
||||
.mesh-columns-wide .mesh-right { display: contents; }
|
||||
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
|
||||
/* A very wide screen gets a roomier third column — but the SAME tabbed
|
||||
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
|
||||
AI, the map and Device on top of each other in fixed grid rows, which on
|
||||
a real 2560px display clipped the first three headings to a few pixels,
|
||||
letterboxed the map, and pushed Radio Settings into a scroll — more
|
||||
screen producing a worse view (reported with a screenshot 2026-08-05).
|
||||
One tab at a time, filling the column, is what makes the map edge to
|
||||
edge and every control reachable without scrolling. */
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
|
||||
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
|
||||
}
|
||||
/* The map is the one panel with nothing to scroll: let it consume the
|
||||
column edge to edge rather than sitting in a letterbox. */
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
.mesh-columns-wide .mesh-tab-bar { display: none; }
|
||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||
.mesh-status-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; cursor: pointer; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
|
||||
.mesh-status-indicator { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
|
||||
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
|
||||
/* Collapse chevron — the Device panel collapses/expands on every breakpoint. */
|
||||
.mesh-status-chevron { display: block; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
|
||||
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
|
||||
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
|
||||
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-stat-value { font-size: 0.8rem; color: rgba(255, 255, 255, 0.85); font-weight: 500; }
|
||||
.text-green { color: #4ade80; }
|
||||
.text-orange { color: #fb923c; }
|
||||
.text-muted { color: rgba(255, 255, 255, 0.4); }
|
||||
.mesh-loading, .mesh-empty { color: rgba(255, 255, 255, 0.4); font-size: 0.85rem; text-align: center; padding: 16px 0; }
|
||||
.mesh-detected-devices { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.06); }
|
||||
.mesh-device-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255, 255, 255, 0.04); border-radius: 6px; }
|
||||
.mesh-device-indicator { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); flex-shrink: 0; }
|
||||
.mesh-device-path { font-family: monospace; font-size: 0.8rem; color: rgba(255, 255, 255, 0.7); flex: 1; }
|
||||
.mesh-connect-btn { padding: 3px 12px; font-size: 0.75rem; flex-shrink: 0; }
|
||||
.mesh-offgrid-banner { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.3); border-radius: 8px; flex-shrink: 0; }
|
||||
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
|
||||
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
|
||||
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
|
||||
.mesh-refresh-spinner {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 4px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(251, 146, 60, 0.7);
|
||||
border-top-color: transparent;
|
||||
animation: mesh-refresh-spin 0.8s linear infinite;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
.mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
||||
.mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); }
|
||||
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
|
||||
.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
|
||||
.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; }
|
||||
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 36px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
|
||||
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
|
||||
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-peer-search-clear { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; touch-action: manipulation; }
|
||||
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
|
||||
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
|
||||
.mesh-peer-reach.is-reachable { background: #34d399; }
|
||||
.mesh-peer-reach.is-unreachable { background: rgba(255,255,255,0.25); }
|
||||
.mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; }
|
||||
.mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; }
|
||||
.mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; }
|
||||
.mesh-peer-channel-badge { font-size: 0.6rem; font-weight: 700; color: #3b82f6; background: rgba(59, 130, 246, 0.12); padding: 1px 5px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-peer-count { font-size: 0.75rem; font-weight: 600; color: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.08); padding: 2px 8px; border-radius: 10px; margin-left: 6px; vertical-align: middle; }
|
||||
.mesh-peer-row.is-channel { border-bottom: 1px solid rgba(255, 255, 255, 0.04); padding-bottom: 12px; margin-bottom: 4px; }
|
||||
.mesh-peer-info { flex: 1; min-width: 0; }
|
||||
.mesh-peer-name { font-weight: 600; font-size: 0.85rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-peer-archy-badge { font-size: 0.6rem; font-weight: 700; color: #fb923c; background: rgba(251, 146, 60, 0.12); padding: 1px 5px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-peer-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mesh-peer-signal { flex-shrink: 0; }
|
||||
.mesh-signal-bars { display: flex; align-items: flex-end; gap: 2px; height: 14px; }
|
||||
.mesh-signal-bar { width: 3px; border-radius: 1px; background: rgba(255, 255, 255, 0.12); }
|
||||
.mesh-signal-bar:nth-child(1) { height: 3px; }
|
||||
.mesh-signal-bar:nth-child(2) { height: 6px; }
|
||||
.mesh-signal-bar:nth-child(3) { height: 10px; }
|
||||
.mesh-signal-bar:nth-child(4) { height: 14px; }
|
||||
.mesh-signal-bar.active { background: #4ade80; }
|
||||
.mesh-unread-badge { background: #fb923c; color: #000; font-size: 0.65rem; font-weight: 700; min-width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 5px; flex-shrink: 0; }
|
||||
.mesh-chat-card { padding: 0; flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.mesh-chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.14); gap: 8px; padding: 40px; }
|
||||
.mesh-chat-empty-icon { font-size: 3rem; opacity: 0.18; }
|
||||
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
|
||||
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.1); }
|
||||
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
|
||||
/* Floating mobile back button (Teleported to body). Hidden by default; only
|
||||
shown in the single-column mobile mesh layout (see media query below). */
|
||||
.mesh-chat-mobile-back { display: none; }
|
||||
.mesh-chat-header-info { flex: 1; min-width: 0; }
|
||||
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
|
||||
.mesh-chat-header-rename:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
.mesh-chat-header-rename-input { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 6px; color: rgba(255, 255, 255, 0.95); font-size: 0.95rem; font-weight: 600; padding: 4px 8px; outline: none; min-width: 0; max-width: 220px; }
|
||||
.mesh-chat-header-rename-input:focus { border-color: rgba(255, 255, 255, 0.4); }
|
||||
.mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; }
|
||||
.mesh-chat-header-status { flex-shrink: 0; }
|
||||
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-chat-messages { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.mesh-chat-no-messages { flex: 1; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.25); font-size: 0.85rem; }
|
||||
.mesh-chat-bubble-wrapper { display: flex; }
|
||||
.mesh-chat-bubble-wrapper.sent { justify-content: flex-end; }
|
||||
.mesh-chat-bubble-wrapper.received { justify-content: flex-start; }
|
||||
.mesh-chat-bubble { max-width: 75%; padding: 10px 14px; border-radius: 16px; word-break: break-word; }
|
||||
.mesh-chat-bubble.sent { background: rgba(251, 146, 60, 0.15); border: 1px solid rgba(251, 146, 60, 0.2); border-bottom-right-radius: 4px; }
|
||||
.mesh-chat-bubble.received { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); border-bottom-left-radius: 4px; }
|
||||
.mesh-chat-bubble-sender { font-size: 0.7rem; font-weight: 600; color: rgba(251, 146, 60, 0.85); margin-bottom: 3px; }
|
||||
.mesh-chat-bubble-text { color: rgba(255, 255, 255, 0.9); font-size: 0.9rem; line-height: 1.4; }
|
||||
.mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; }
|
||||
.mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-chat-e2e { font-size: 0.55rem; font-weight: 700; color: #4ade80; padding: 0 3px; border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 3px; }
|
||||
/* Per-message transport pill (Meshtastic / Meshcore / Reticulum / FIPS / Tor), styled like the E2E badge. */
|
||||
.mesh-chat-transport { font-size: 0.55rem; font-weight: 700; padding: 0 3px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.85; }
|
||||
.mesh-chat-transport.transport-meshtastic { color: #3eb489; } /* Meshtastic — mint */
|
||||
.mesh-chat-transport.transport-meshcore { color: #fb923c; } /* Meshcore — orange */
|
||||
.mesh-chat-transport.transport-reticulum { color: #60a5fa; } /* Reticulum — blue */
|
||||
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* legacy generic Mesh/LoRa (pre-split) — amber */
|
||||
.mesh-chat-transport.transport-fips { color: #a78bfa; } /* FIPS — violet */
|
||||
.mesh-chat-transport.transport-tor { color: #818cf8; } /* Tor — indigo */
|
||||
.mesh-chat-ack { font-size: 0.7rem; color: #3b82f6; }
|
||||
.mesh-chat-compose { padding: 12px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
|
||||
.mesh-chat-send-error { color: #ef4444; font-size: 0.75rem; margin-bottom: 6px; }
|
||||
.mesh-chat-compose-row { display: flex; gap: 8px; }
|
||||
.mesh-chat-input { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 20px; color: rgba(255, 255, 255, 0.9); padding: 10px 16px; font-size: 0.9rem; font-family: inherit; outline: none; }
|
||||
.mesh-chat-input:focus { border-color: rgba(251, 146, 60, 0.4); }
|
||||
.mesh-chat-input::placeholder { color: rgba(255, 255, 255, 0.25); }
|
||||
.mesh-chat-send-btn { padding: 10px 20px; border-radius: 20px; font-size: 0.85rem; background: rgba(251, 146, 60, 0.15); border-color: rgba(251, 146, 60, 0.25); min-width: 72px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.mesh-chat-send-btn:hover:not(:disabled) { background: rgba(251, 146, 60, 0.25); }
|
||||
.mesh-send-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgba(251, 146, 60, 0.9); border-radius: 50%; animation: mesh-send-spin 0.7s linear infinite; }
|
||||
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-mobile-back-btn { display: none; }
|
||||
|
||||
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
|
||||
≤1279px block flips it to flex and the placement mirrors the mobile back
|
||||
button (pinned above the global tab bar + audio player). */
|
||||
.mesh-mobile-tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.mesh-mtab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
|
||||
.mesh-columns { flex-direction: column; overflow: visible; }
|
||||
.mesh-left { width: 100%; overflow: visible; }
|
||||
.mesh-right { min-height: auto; overflow: visible; }
|
||||
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Single-column mobile mesh: one fixed, internally-scrolling pane that
|
||||
fills the space between the top tab strip and the floating mesh tab bar.
|
||||
The page itself never scrolls; each pane scrolls inside its own bounds.
|
||||
Fixed positioning is relative to the full-height perspective container, so
|
||||
the offsets line up with the body-teleported tab bar / back button. ──── */
|
||||
.mesh-left,
|
||||
.mesh-mobile-tools,
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
/* width:auto so left+right govern the box — .mesh-left otherwise carries a
|
||||
fixed 380px width that ignores `right` and overflows the screen. */
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
|
||||
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 30;
|
||||
}
|
||||
/* Active conversation: the floating tabs are hidden here and the back button
|
||||
takes the standard spot above the tab bar, so the chat window fills down to
|
||||
just above the back pill (back pill ≈44px at +8, plus a 16px gap). When the
|
||||
keyboard is up it covers the tab bar, so anchor to whichever is taller — the
|
||||
bottom controls or the keyboard — so the window sits right above both. */
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
|
||||
overflow: hidden; /* the messages list inside does the scrolling */
|
||||
}
|
||||
.mesh-tools-wrapper { display: none !important; }
|
||||
.mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* The active tool fills the whole fixed pane (no fixed cap that would leave a
|
||||
bottom margin); the panel itself scrolls if its content is taller. */
|
||||
.mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
|
||||
.mesh-mobile-tools .mesh-bitcoin-panel,
|
||||
.mesh-mobile-tools .mesh-assistant-panel,
|
||||
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
|
||||
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
/* In a conversation the tabs are hidden, so the back pill sits just above the
|
||||
tab bar — or above the keyboard when it's up, whichever is taller. */
|
||||
.mesh-chat-mobile-back { display: flex; }
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
|
||||
}
|
||||
/* Floating mesh tab strip — same placement logic as the mobile back button. */
|
||||
.mesh-mobile-tabbar { display: flex; }
|
||||
.mobile-hidden { display: none !important; }
|
||||
:deep(.mesh-bitcoin-panel),
|
||||
:deep(.mesh-assistant-panel),
|
||||
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
|
||||
.mesh-mobile-back-btn:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
}
|
||||
|
||||
@media (min-width: 921px) and (max-width: 1279px) {
|
||||
.mesh-view {
|
||||
padding: 24px;
|
||||
}
|
||||
/* In this range the desktop sidebar (256px) is still shown. The in-pane
|
||||
fixed elements are positioned relative to the main content area (their
|
||||
perspective containing block), so they already clear the sidebar — but the
|
||||
body-teleported floating bars are viewport-relative, so nudge them right. */
|
||||
.mesh-mobile-tabbar,
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
left: 268px;
|
||||
}
|
||||
|
||||
/* The ≤1279px bottom offsets above reserve `--mobile-tab-bar-height` for the
|
||||
OS-style bottom nav bar — but that bar only ever renders below 768px
|
||||
(DashboardMobileNav's `md:hidden`); here the desktop sidebar is the primary
|
||||
nav instead, so the var is always unset and its 72px fallback becomes a
|
||||
phantom reservation with nothing under it, leaving a huge dead gap below
|
||||
the panel. Drop that term so bottom clearance matches the top/left/right
|
||||
margins, keeping only the space actually needed for the in-page floating
|
||||
tab bar / back button. */
|
||||
.mesh-left,
|
||||
.mesh-mobile-tools,
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
bottom: calc(var(--audio-player-height, 0px) + 72px);
|
||||
}
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
bottom: calc(max(var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
|
||||
}
|
||||
.mesh-mobile-tabbar {
|
||||
bottom: calc(var(--audio-player-height, 0px) + 12px);
|
||||
}
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
bottom: calc(max(var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.mesh-view {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mesh-session-badge { font-size: 0.75rem; margin-right: 6px; opacity: 0.7; }
|
||||
.mesh-session-rotate { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); font-size: 0.75rem; line-height: 1; padding: 2px 6px; margin-right: 8px; border-radius: 10px; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
||||
.mesh-session-rotate:hover:not(:disabled) { background: rgba(251,146,60,0.2); color: #fff; border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-session-rotate:disabled { opacity: 0.5; cursor: wait; }
|
||||
.mesh-outbox-badge { font-size: 0.7rem; padding: 2px 7px; margin-right: 8px; border-radius: 10px; background: rgba(251,146,60,0.2); border: 1px solid rgba(251,146,60,0.4); color: #fff; }
|
||||
.session-ratchet { color: #4ade80; opacity: 1; }
|
||||
.session-static { color: #fbbf24; }
|
||||
.session-none { color: rgba(255,255,255,0.3); }
|
||||
.mesh-typed-icon { margin-right: 4px; }
|
||||
.mesh-typed-label { font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.typed-invoice { border-left: 3px solid #fb923c; }
|
||||
.mesh-typed-invoice { padding: 4px 0; }
|
||||
.mesh-typed-invoice-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; color: #fb923c; font-size: 0.75rem; }
|
||||
.mesh-typed-invoice-amount { font-size: 1.1rem; font-weight: 700; color: #fb923c; }
|
||||
.mesh-typed-invoice-memo { font-size: 0.8rem; color: rgba(255,255,255,0.7); margin-top: 2px; }
|
||||
.mesh-typed-invoice-bolt11 { font-size: 0.65rem; color: rgba(255,255,255,0.3); font-family: monospace; margin-top: 4px; word-break: break-all; }
|
||||
.mesh-typed-paid { background: rgba(74,222,128,0.2); color: #4ade80; font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; margin-left: auto; }
|
||||
.typed-alert { border-left: 3px solid #ef4444; }
|
||||
.typed-alert.alert-status { border-left-color: #3b82f6; }
|
||||
.mesh-typed-alert { padding: 4px 0; }
|
||||
.mesh-typed-alert-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; font-size: 0.75rem; }
|
||||
.alert-emergency .mesh-typed-alert-header { color: #ef4444; }
|
||||
.alert-dead_man .mesh-typed-alert-header { color: #ef4444; }
|
||||
.alert-status .mesh-typed-alert-header { color: #3b82f6; }
|
||||
.mesh-typed-alert-message { font-size: 0.85rem; color: rgba(255,255,255,0.9); }
|
||||
.mesh-typed-alert-location { display: block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
|
||||
.mesh-typed-signed { font-size: 0.6rem; color: #4ade80; border: 1px solid rgba(74,222,128,0.3); padding: 0 4px; border-radius: 3px; margin-left: auto; }
|
||||
.typed-coordinate { border-left: 3px solid #3b82f6; }
|
||||
.mesh-typed-coordinate { padding: 4px 0; }
|
||||
.mesh-typed-coordinate-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; color: #3b82f6; font-size: 0.75rem; }
|
||||
.mesh-typed-coordinate-value { font-size: 0.9rem; font-family: monospace; color: rgba(255,255,255,0.8); }
|
||||
.mesh-typed-coordinate-label { font-size: 0.8rem; color: rgba(255,255,255,0.6); margin-top: 2px; }
|
||||
.mesh-typed-coordinate-link { display: inline-block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
|
||||
.typed-block_header { border-left: 3px solid #a855f7; }
|
||||
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
|
||||
.mesh-typed-content-preview { max-width: 220px; max-height: 220px; border-radius: 10px; display: block; cursor: pointer; }
|
||||
.mesh-typed-content-thumb { opacity: 0.85; filter: blur(0.5px); margin-bottom: 6px; }
|
||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
||||
.mesh-typed-content-download-btn {
|
||||
position: absolute; bottom: 8px; right: 8px;
|
||||
width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
|
||||
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
|
||||
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
|
||||
replaced collapsed to its text width inside the narrow mobile bubble and
|
||||
looked squashed — this is a full-width glass pill in the house style. */
|
||||
.mesh-typed-content-fetch-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
|
||||
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
||||
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
|
||||
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
||||
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
.mesh-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.mesh-tab-badge { font-size: 0.65rem; background: rgba(251,146,60,0.2); color: #fb923c; padding: 1px 5px; border-radius: 4px; font-weight: 600; }
|
||||
.mesh-tab-badge-alert { background: rgba(239,68,68,0.3); color: #ef4444; animation: pulse-alert 1.5s infinite; }
|
||||
@keyframes pulse-alert { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.mesh-map-panel { flex: 1; min-height: 400px; padding: 0 !important; overflow: hidden; border-radius: 12px; position: relative; }
|
||||
|
||||
/* Bitcoin & Deadman panels (child components) */
|
||||
.mesh-bitcoin-panel,
|
||||
.mesh-deadman-panel,
|
||||
.mesh-assistant-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-assistant-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-assistant-install { padding: 12px; background: rgba(251,146,60,0.08); border: 1px solid rgba(251,146,60,0.25); border-radius: 10px; }
|
||||
.mesh-assistant-install-btn { display: inline-block; text-align: center; padding: 8px 14px; font-size: 0.8rem; }
|
||||
.mesh-assistant-allowlist { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 6px; background: rgba(0,0,0,0.2); }
|
||||
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
|
||||
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
|
||||
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mesh-assistant-addkey { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
|
||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
||||
.mesh-device-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-device-panel-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.mesh-device-panel-actions { display: flex; flex-direction: column; gap: 6px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.mesh-device-reboot-btn { align-self: flex-start; padding: 8px 16px; font-size: 0.85rem; }
|
||||
.mesh-device-reboot-hint { font-size: 0.75rem; color: rgba(255,255,255,0.4); margin: 0; }
|
||||
.mesh-device-reboot-error { font-size: 0.8rem; color: #ef4444; margin: 0; }
|
||||
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.mesh-bitcoin-label { font-size: 0.75rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-bitcoin-height { font-size: 0.85rem; font-weight: 700; color: #fb923c; font-family: monospace; }
|
||||
.mesh-bitcoin-height.mesh-muted { color: rgba(255,255,255,0.3); font-weight: 400; }
|
||||
.mesh-bitcoin-hint { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: 0; }
|
||||
.mesh-bitcoin-input { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: rgba(255,255,255,0.9); padding: 10px 12px; font-size: 0.85rem; font-family: inherit; outline: none; box-sizing: border-box; }
|
||||
.mesh-bitcoin-input:focus { border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.25); }
|
||||
.mesh-bitcoin-input-sm { padding: 8px 12px; font-size: 0.8rem; }
|
||||
textarea.mesh-bitcoin-input { resize: vertical; min-height: 60px; }
|
||||
select.mesh-bitcoin-input { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='rgba(255,255,255,0.4)' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
|
||||
select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,0.9); }
|
||||
.mesh-bitcoin-advanced { margin-top: 4px; }
|
||||
.mesh-bitcoin-advanced summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-bitcoin-advanced summary::before { content: '\25B6'; font-size: 0.6rem; color: rgba(255,255,255,0.4); transition: transform 0.2s; }
|
||||
.mesh-bitcoin-advanced[open] summary::before { transform: rotate(90deg); }
|
||||
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-block-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 6px; }
|
||||
.mesh-block-height { font-size: 0.8rem; font-weight: 600; color: #a855f7; font-family: monospace; }
|
||||
.mesh-block-hash { font-size: 0.7rem; color: rgba(255,255,255,0.35); font-family: monospace; }
|
||||
.mesh-send-tabs { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 2px; }
|
||||
.mesh-send-tab { flex: 1; padding: 6px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.8rem; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
|
||||
.mesh-send-tab:hover { color: rgba(255,255,255,0.8); }
|
||||
.mesh-send-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.mesh-relay-mode { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.mesh-relay-mode-option { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; color: rgba(255,255,255,0.6); transition: all 0.15s; }
|
||||
.mesh-relay-mode-option.active { color: rgba(255,255,255,0.9); }
|
||||
.mesh-relay-mode-option small { color: rgba(255,255,255,0.35); font-size: 0.7rem; }
|
||||
.mesh-relay-mode-option input[type="radio"] { accent-color: #fb923c; }
|
||||
.mesh-relay-result { padding: 8px 12px; border-radius: 8px; font-size: 0.8rem; }
|
||||
.mesh-relay-result.success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; }
|
||||
.mesh-relay-result.error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
|
||||
|
||||
/* Deadman panel specifics */
|
||||
.mesh-deadman-status { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: rgba(0,0,0,0.2); border-radius: 10px; }
|
||||
.mesh-deadman-indicator { display: inline-flex; align-items: center; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 4px 10px; border-radius: 6px; width: fit-content; }
|
||||
.mesh-deadman-indicator.armed { background: rgba(251,146,60,0.15); color: #fb923c; border: 1px solid rgba(251,146,60,0.3); }
|
||||
.mesh-deadman-indicator.disabled { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.08); }
|
||||
.mesh-deadman-indicator.triggered { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); animation: pulse-alert 1.5s infinite; }
|
||||
.mesh-deadman-timer { font-size: 1.8rem; font-weight: 700; color: #fb923c; font-family: monospace; }
|
||||
.mesh-deadman-message { font-size: 0.8rem; color: rgba(255,255,255,0.5); font-style: italic; }
|
||||
.mesh-deadman-checkin-btn { margin-top: 4px; }
|
||||
.mesh-deadman-config { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* Reaction chips and action menu (Phase 2a) */
|
||||
.mesh-chat-reactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.mesh-chat-reaction-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); font-size: 0.85rem; line-height: 1.1; }
|
||||
.mesh-chat-reaction-chip.by-self { background: rgba(251,146,60,0.15); border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-chat-reaction-count { font-size: 0.7rem; color: rgba(255,255,255,0.55); font-weight: 600; }
|
||||
.mesh-chat-action-menu { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 8px; padding: 8px 10px; border-radius: 10px; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.mesh-chat-action-btn { background: transparent; border: none; color: rgba(255,255,255,0.75); font-size: 0.8rem; padding: 4px 8px; border-radius: 6px; cursor: pointer; }
|
||||
.mesh-chat-action-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
||||
.mesh-chat-reaction-btn { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); color: #fff; font-size: 1.15rem; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; transition: transform 0.1s ease, background 0.15s ease; }
|
||||
.mesh-chat-reaction-btn:hover { background: rgba(251,146,60,0.2); transform: scale(1.1); }
|
||||
|
||||
.mesh-chat-action-danger { color: rgba(248, 113, 113, 0.9) !important; }
|
||||
.mesh-chat-action-danger:hover { background: rgba(239,68,68,0.2) !important; color: #fff !important; }
|
||||
|
||||
.mesh-chat-forward-header { font-size: 0.75rem; color: rgba(251,146,60,0.85); font-style: italic; margin-bottom: 3px; }
|
||||
.mesh-chat-forward-body { }
|
||||
.mesh-chat-deleted { font-style: italic; opacity: 0.55; }
|
||||
.mesh-chat-edited { font-size: 0.7rem; opacity: 0.55; font-style: italic; }
|
||||
|
||||
/* Telegram-style ⋯ action trigger: tiny, ghosted in the meta row, expands on hover or when menu is open */
|
||||
.mesh-chat-action-trigger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.45);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
margin-left: 2px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.mesh-chat-bubble:hover .mesh-chat-action-trigger,
|
||||
.mesh-chat-bubble.menu-open .mesh-chat-action-trigger,
|
||||
.mesh-chat-action-trigger.active,
|
||||
.mesh-chat-action-trigger:focus-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
.mesh-chat-action-trigger:hover,
|
||||
.mesh-chat-action-trigger.active {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.mesh-chat-action-trigger { opacity: 0.7; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Generic inline spinner for busy buttons */
|
||||
.mesh-spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fb923c; border-radius: 50%; animation: mesh-spin 0.7s linear infinite; vertical-align: middle; }
|
||||
@keyframes mesh-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; }
|
||||
.mesh-chat-record-btn.is-recording { background: rgba(239,68,68,0.25); animation: mesh-record-pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes mesh-record-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } }
|
||||
|
||||
/* "+" attach menu — replaces individually-visible attach/record buttons
|
||||
(was overflowing the compose row) with one toggle + an animated stack. */
|
||||
.mesh-attach-menu-anchor { position: relative; flex-shrink: 0; }
|
||||
.mesh-chat-plus-btn { font-size: 1.3rem; line-height: 1; font-weight: 300; transition: transform 0.2s ease; }
|
||||
.mesh-chat-plus-btn.is-open { transform: rotate(45deg); background: rgba(255,255,255,0.14); }
|
||||
.mesh-attach-stack {
|
||||
position: absolute; bottom: calc(100% + 8px); left: 0;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.mesh-attach-stack-enter-active, .mesh-attach-stack-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.mesh-attach-stack-enter-from, .mesh-attach-stack-leave-to {
|
||||
opacity: 0; transform: translateY(8px) scale(0.9);
|
||||
}
|
||||
.mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); }
|
||||
.mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* Reply / attachment pending banner */
|
||||
.mesh-chat-pending-reply,
|
||||
.mesh-chat-pending-attachment { display: flex; align-items: flex-start; gap: 8px; padding: 8px 12px; margin: 6px 0; border-radius: 10px; background: rgba(251,146,60,0.1); border: 1px solid rgba(251,146,60,0.25); font-size: 0.85rem; }
|
||||
.mesh-chat-pending-reply .mesh-typed-icon,
|
||||
.mesh-chat-pending-attachment .mesh-typed-icon { color: #fb923c; font-size: 1rem; line-height: 1.4; flex: 0 0 auto; }
|
||||
.mesh-chat-pending-name { flex: 1 1 auto; min-width: 0; color: rgba(255,255,255,0.85); overflow-wrap: anywhere; word-break: break-word; line-height: 1.35; }
|
||||
.mesh-chat-pending-size { flex: 0 0 auto; color: rgba(255,255,255,0.45); font-size: 0.75rem; margin-left: 4px; }
|
||||
.mesh-chat-pending-clear { flex: 0 0 auto; align-self: center; margin-left: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.85); width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0.95rem; line-height: 1; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
|
||||
.mesh-chat-pending-clear:hover { background: rgba(239,68,68,0.3); color: #fff; border-color: rgba(239,68,68,0.6); transform: scale(1.08); }
|
||||
.mesh-chat-pending-clear:active { transform: scale(0.92); }
|
||||
|
||||
/* Transport chooser modal (attachment size router) */
|
||||
.mesh-transport-modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.mesh-transport-modal { max-width: 420px; width: 92%; padding: 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.mesh-transport-title { margin: 0; font-size: 1.1rem; color: #fff; }
|
||||
.mesh-transport-sub { margin: 0; color: rgba(255,255,255,0.6); font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||
.mesh-transport-options { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
|
||||
.mesh-transport-option { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); color: #fff; cursor: pointer; text-align: left; transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
|
||||
.mesh-transport-option:hover:not(:disabled) { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
|
||||
.mesh-transport-option:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.mesh-transport-icon { font-size: 1.5rem; flex: 0 0 auto; }
|
||||
.mesh-transport-label { flex: 1 1 auto; font-weight: 600; }
|
||||
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
||||
.mesh-transport-cancel:hover { color: #fff; }
|
||||
|
||||
/* Transport pills at the bottom of the image quality modal — pick LoRa vs
|
||||
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
|
||||
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
||||
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
|
||||
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
|
||||
|
||||
/* Clickable transport pill — opens the hop-route modal (HopVizModal.vue,
|
||||
which carries its own scoped hopviz styles). */
|
||||
.mesh-chat-transport-clickable { cursor: pointer; }
|
||||
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
|
||||
|
||||
/* Per-message "more" button — opens the route/hops modal (same target as the
|
||||
transport pill, but always visible and discoverable). */
|
||||
.mesh-chat-more-btn { background: none; border: none; color: rgba(255,255,255,0.45); font-size: 0.9rem; line-height: 1; padding: 0 4px; cursor: pointer; border-radius: 6px; }
|
||||
.mesh-chat-more-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
|
||||
/* Reaction dropdown inside the message action menu */
|
||||
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }
|
||||
Reference in New Issue
Block a user