fix: sync latest frontend source with linter fixes
This commit is contained in:
@@ -1520,18 +1520,18 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
const limit = params?.limit || 100
|
const limit = params?.limit || 100
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const allMessages = [
|
const allMessages = [
|
||||||
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true },
|
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true },
|
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'ARCHY:2:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: false },
|
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'Block #890,413 relayed. Fees avg 12 sat/vB.', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: true, message_type: 'block_header', typed_payload: { alert_type: 'block_header', message: 'Block #890,413 — 2,847 txs, 12 sat/vB avg fee', signed: true } },
|
||||||
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Federation state sync complete. 3 containers matched.', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true },
|
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice: 50,000 sats — Channel opening fee', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: false } },
|
||||||
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true },
|
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers. 6 confirmations on last tx.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true },
|
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 7, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'New block relayed: 890,413. Fees averaging 12 sat/vB.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: true },
|
{ id: 7, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'EMERGENCY: Solar array failure. Running on battery reserve.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: false, message_type: 'alert', typed_payload: { alert_type: 'emergency', message: 'Solar array failure. Running on battery reserve. ETA 4h before shutdown.', coordinate: { lat: 39507400, lng: -106042800, label: 'Mountain relay site' }, signed: true } },
|
||||||
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true },
|
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true },
|
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
|
||||||
{ id: 10, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Anyone copy? Solar panel restored, back online.', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false },
|
{ id: 10, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Location shared', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false, message_type: 'coordinate', typed_payload: { lat: 30267200, lng: -97743100, label: 'Supply drop point' } },
|
||||||
{ id: 11, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Copy mountain-node. Welcome back. Relaying your backlog.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: false },
|
{ id: 11, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: true, message_type: 'alert', typed_payload: { alert_type: 'status', message: 'All systems nominal. Battery 78%. Mesh uptime 14d.', signed: true } },
|
||||||
{ id: 12, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true },
|
{ id: 12, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice paid: 50,000 sats', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: true, payment_hash: 'a1b2c3d4e5f6...' } },
|
||||||
]
|
]
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
@@ -1570,6 +1570,95 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
return res.json({ result: { configured: true } })
|
return res.json({ result: { configured: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'mesh.send-invoice': {
|
||||||
|
console.log(`[Mesh] Send invoice: ${params?.amount_sats} sats to contact ${params?.contact_id}`)
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
sent: true,
|
||||||
|
message_id: Math.floor(Math.random() * 10000) + 200,
|
||||||
|
amount_sats: params?.amount_sats,
|
||||||
|
bolt11: `lnbc${params?.amount_sats}n1pjmesh...`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mesh.send-coordinate': {
|
||||||
|
console.log(`[Mesh] Send coordinate: ${params?.lat}, ${params?.lng} to contact ${params?.contact_id}`)
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
sent: true,
|
||||||
|
message_id: Math.floor(Math.random() * 10000) + 300,
|
||||||
|
lat: Math.round((params?.lat || 0) * 1000000),
|
||||||
|
lng: Math.round((params?.lng || 0) * 1000000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mesh.send-alert': {
|
||||||
|
console.log(`[Mesh] Send alert: ${params?.alert_type} — ${params?.message}`)
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
sent: true,
|
||||||
|
alert_type: params?.alert_type || 'status',
|
||||||
|
signed: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mesh.outbox': {
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dest_did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
|
||||||
|
from_did: 'did:key:z6MkSelf',
|
||||||
|
created_at: new Date(Date.now() - 1800000).toISOString(),
|
||||||
|
ttl_secs: 86400,
|
||||||
|
retry_count: 3,
|
||||||
|
relay_hops: 0,
|
||||||
|
expired: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
dest_did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp7NQD5EjEREWh',
|
||||||
|
from_did: 'did:key:z6MkSelf',
|
||||||
|
created_at: new Date(Date.now() - 7200000).toISOString(),
|
||||||
|
ttl_secs: 86400,
|
||||||
|
retry_count: 8,
|
||||||
|
relay_hops: 1,
|
||||||
|
expired: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mesh.session-status': {
|
||||||
|
const hasSess = (params?.contact_id === 1 || params?.contact_id === 4)
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
has_session: hasSess,
|
||||||
|
forward_secrecy: hasSess,
|
||||||
|
message_count: hasSess ? 23 : 0,
|
||||||
|
ratchet_generation: hasSess ? 7 : 0,
|
||||||
|
peer_did: hasSess ? 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9' : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mesh.rotate-prekeys': {
|
||||||
|
console.log('[Mesh] Rotating prekeys...')
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
rotated: true,
|
||||||
|
signed_prekey_id: Math.floor(Math.random() * 1000000),
|
||||||
|
one_time_prekeys: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Transport Layer (unified routing: mesh > lan > tor)
|
// Transport Layer (unified routing: mesh > lan > tor)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export interface MeshChannel {
|
|||||||
has_secret: boolean
|
has_secret: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MeshMessageTypeLabel =
|
||||||
|
| 'text'
|
||||||
|
| 'alert'
|
||||||
|
| 'invoice'
|
||||||
|
| 'psbt_hash'
|
||||||
|
| 'coordinate'
|
||||||
|
| 'block_header'
|
||||||
|
|
||||||
export interface MeshMessage {
|
export interface MeshMessage {
|
||||||
id: number
|
id: number
|
||||||
direction: 'sent' | 'received'
|
direction: 'sent' | 'received'
|
||||||
@@ -44,6 +52,46 @@ export interface MeshMessage {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
delivered: boolean
|
delivered: boolean
|
||||||
encrypted: boolean
|
encrypted: boolean
|
||||||
|
message_type?: MeshMessageTypeLabel
|
||||||
|
typed_payload?: InvoiceData | AlertData | CoordinateData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceData {
|
||||||
|
bolt11: string
|
||||||
|
amount_sats: number
|
||||||
|
memo: string | null
|
||||||
|
payment_hash?: string
|
||||||
|
paid?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertData {
|
||||||
|
alert_type: 'emergency' | 'status' | 'dead_man' | 'block_header'
|
||||||
|
message: string
|
||||||
|
coordinate?: { lat: number; lng: number; label?: string }
|
||||||
|
signed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinateData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStatus {
|
||||||
|
has_session: boolean
|
||||||
|
forward_secrecy: boolean
|
||||||
|
message_count: number
|
||||||
|
ratchet_generation: number
|
||||||
|
peer_did: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertStatus {
|
||||||
|
dead_man_enabled: boolean
|
||||||
|
dead_man_interval_secs: number
|
||||||
|
triggered: boolean
|
||||||
|
time_remaining_secs: number
|
||||||
|
has_gps: boolean
|
||||||
|
emergency_contacts: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMeshStore = defineStore('mesh', () => {
|
export const useMeshStore = defineStore('mesh', () => {
|
||||||
@@ -163,6 +211,64 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendInvoice(contactId: number, amountSats: number, memo?: string) {
|
||||||
|
try {
|
||||||
|
sending.value = true
|
||||||
|
error.value = null
|
||||||
|
return await rpcClient.call<{ sent: boolean; amount_sats: number; bolt11: string }>({
|
||||||
|
method: 'mesh.send-invoice',
|
||||||
|
params: { contact_id: contactId, amount_sats: amountSats, memo },
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to send invoice'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCoordinate(contactId: number, lat: number, lng: number, label?: string) {
|
||||||
|
try {
|
||||||
|
sending.value = true
|
||||||
|
error.value = null
|
||||||
|
return await rpcClient.call<{ sent: boolean }>({
|
||||||
|
method: 'mesh.send-coordinate',
|
||||||
|
params: { contact_id: contactId, lat, lng, label },
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to send coordinate'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAlert(message: string, alertType: string, broadcast = false, lat?: number, lng?: number) {
|
||||||
|
try {
|
||||||
|
error.value = null
|
||||||
|
return await rpcClient.call<{ sent: boolean; signed: boolean }>({
|
||||||
|
method: 'mesh.send-alert',
|
||||||
|
params: { message, alert_type: alertType, broadcast, lat, lng },
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to send alert'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionStatus(contactId: number): Promise<SessionStatus> {
|
||||||
|
return rpcClient.call<SessionStatus>({
|
||||||
|
method: 'mesh.session-status',
|
||||||
|
params: { contact_id: contactId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotatePrekeys() {
|
||||||
|
return rpcClient.call<{ rotated: boolean; one_time_prekeys: number }>({
|
||||||
|
method: 'mesh.rotate-prekeys',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
|
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
|
||||||
}
|
}
|
||||||
@@ -185,5 +291,10 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
refreshAll,
|
refreshAll,
|
||||||
markChatRead,
|
markChatRead,
|
||||||
clearViewingChat,
|
clearViewingChat,
|
||||||
|
sendInvoice,
|
||||||
|
sendCoordinate,
|
||||||
|
sendAlert,
|
||||||
|
getSessionStatus,
|
||||||
|
rotatePrekeys,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -333,7 +333,6 @@ const HTTPS_PROXY_PATHS: Record<string, string> = {
|
|||||||
'immich_server': '/app/immich/',
|
'immich_server': '/app/immich/',
|
||||||
'tailscale': '/app/tailscale/',
|
'tailscale': '/app/tailscale/',
|
||||||
'endurain': '/app/endurain/',
|
'endurain': '/app/endurain/',
|
||||||
'indeedhub': '/app/indeedhub/',
|
|
||||||
'dwn': '/app/dwn/',
|
'dwn': '/app/dwn/',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +384,17 @@ const appUrl = computed(() => {
|
|||||||
const proxyPath = PROXY_APPS[id]
|
const proxyPath = PROXY_APPS[id]
|
||||||
if (proxyPath) return `${window.location.origin}${proxyPath}`
|
if (proxyPath) return `${window.location.origin}${proxyPath}`
|
||||||
|
|
||||||
|
// IndeedHub: always direct port (X-Frame-Options removed by deploy script)
|
||||||
|
if (id === 'indeedhub') {
|
||||||
|
const port = APP_PORTS[id]
|
||||||
|
if (port) {
|
||||||
|
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
|
||||||
|
const subpath = route.query.path as string | undefined
|
||||||
|
if (subpath) base += subpath
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
|
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
|
||||||
if (window.location.protocol === 'https:') {
|
if (window.location.protocol === 'https:') {
|
||||||
const httpsProxy = HTTPS_PROXY_PATHS[id]
|
const httpsProxy = HTTPS_PROXY_PATHS[id]
|
||||||
|
|||||||
@@ -1,18 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<div class="hidden md:block mb-8">
|
<!-- Desktop: tabs + search in one row -->
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
|
<div class="hidden md:flex items-center gap-4 mb-4">
|
||||||
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
|
<div class="mode-switcher flex-shrink-0">
|
||||||
|
<button
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
|
||||||
|
@click="activeTab = 'apps'"
|
||||||
|
>My Apps</button>
|
||||||
|
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
|
||||||
|
<button
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
|
||||||
|
@click="activeTab = 'services'"
|
||||||
|
>Services</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('apps.searchPlaceholder')"
|
:placeholder="t('apps.searchPlaceholder')"
|
||||||
:aria-label="t('apps.searchLabel')"
|
:aria-label="t('apps.searchLabel')"
|
||||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: tabs + search -->
|
||||||
|
<div class="md:hidden mb-4">
|
||||||
|
<div class="mode-switcher mode-switcher-full mb-3">
|
||||||
|
<button
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
|
||||||
|
@click="activeTab = 'apps'"
|
||||||
|
>My Apps</button>
|
||||||
|
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
|
||||||
|
<button
|
||||||
|
class="mode-switcher-btn"
|
||||||
|
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
|
||||||
|
@click="activeTab = 'services'"
|
||||||
|
>Services</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('apps.searchPlaceholder')"
|
||||||
|
:aria-label="t('apps.searchLabel')"
|
||||||
|
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,14 +201,14 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
@click="closeUninstallModal()"
|
@click="closeUninstallModal()"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
<div
|
<div
|
||||||
ref="uninstallModalRef"
|
ref="uninstallModalRef"
|
||||||
@click.stop
|
@click.stop
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="uninstall-dialog-title"
|
aria-labelledby="uninstall-dialog-title"
|
||||||
class="glass-card p-6 max-w-md w-full relative z-10"
|
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4">
|
||||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||||
@@ -201,9 +233,20 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="confirmUninstall"
|
@click="confirmUninstall"
|
||||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
:disabled="uninstalling"
|
||||||
|
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{{ t('common.uninstall') }}
|
<svg
|
||||||
|
v-if="uninstalling"
|
||||||
|
class="animate-spin h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,6 +279,28 @@ import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
const activeTab = ref<'apps' | 'services'>('apps')
|
||||||
|
|
||||||
|
// Service container name patterns (backend/infra, not user-facing)
|
||||||
|
// Exact container names or prefixes that are backend services (not user-facing)
|
||||||
|
const SERVICE_NAMES = new Set([
|
||||||
|
'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||||
|
'immich_postgres', 'immich_redis',
|
||||||
|
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
|
||||||
|
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||||
|
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||||
|
'mysql-mempool',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isServiceContainer(id: string): boolean {
|
||||||
|
if (SERVICE_NAMES.has(id)) return true
|
||||||
|
const lower = id.toLowerCase()
|
||||||
|
return lower.includes('_db') || lower.includes('-db') && !lower.includes('indeedhub')
|
||||||
|
? SERVICE_NAMES.has(id)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
@@ -254,12 +319,11 @@ function showActionError(msg: string) {
|
|||||||
|
|
||||||
// Web-only app IDs and their URLs
|
// Web-only app IDs and their URLs
|
||||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||||
'indeedhub': `${window.location.protocol}//${window.location.hostname}:7777`,
|
|
||||||
'botfights': 'https://botfights.net',
|
'botfights': 'https://botfights.net',
|
||||||
'nwnn': 'https://nwnn.l484.com',
|
'nwnn': 'https://nwnn.l484.com',
|
||||||
'484-kitchen': 'https://484.kitchen',
|
'484-kitchen': 'https://484.kitchen',
|
||||||
'call-the-operator': 'https://cta.tx1138.com',
|
'call-the-operator': 'https://cta.tx1138.com',
|
||||||
'arch-presentation': 'https://present.l484.com',
|
// 'arch-presentation': hidden until X-Frame-Options fixed
|
||||||
'syntropy-institute': 'https://syntropy.institute',
|
'syntropy-institute': 'https://syntropy.institute',
|
||||||
't-zero': 'https://teeminuszero.net',
|
't-zero': 'https://teeminuszero.net',
|
||||||
}
|
}
|
||||||
@@ -270,11 +334,6 @@ function isWebOnlyApp(id: string): boolean {
|
|||||||
|
|
||||||
// Web-only apps (no container) — always show as installed bookmarks
|
// Web-only apps (no container) — always show as installed bookmarks
|
||||||
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||||
'indeedhub': {
|
|
||||||
state: 'running' as PackageState,
|
|
||||||
manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
||||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeehub.ico' },
|
|
||||||
},
|
|
||||||
'botfights': {
|
'botfights': {
|
||||||
state: 'running' as PackageState,
|
state: 'running' as PackageState,
|
||||||
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||||
@@ -295,11 +354,12 @@ const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
|||||||
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
||||||
},
|
},
|
||||||
|
/* arch-presentation hidden until X-Frame-Options fixed
|
||||||
'arch-presentation': {
|
'arch-presentation': {
|
||||||
state: 'running' as PackageState,
|
state: 'running' as PackageState,
|
||||||
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
||||||
},
|
}, */
|
||||||
'syntropy-institute': {
|
'syntropy-institute': {
|
||||||
state: 'running' as PackageState,
|
state: 'running' as PackageState,
|
||||||
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||||
@@ -321,7 +381,12 @@ const packages = computed(() => {
|
|||||||
// Web-only apps first (alphabetically), then all other apps (alphabetically)
|
// Web-only apps first (alphabetically), then all other apps (alphabetically)
|
||||||
const sortedPackageEntries = computed(() => {
|
const sortedPackageEntries = computed(() => {
|
||||||
const entries = Object.entries(packages.value)
|
const entries = Object.entries(packages.value)
|
||||||
return entries.sort(([idA, a], [idB, b]) => {
|
// Filter by active tab
|
||||||
|
const filtered = entries.filter(([id]) => {
|
||||||
|
const isSvc = isServiceContainer(id)
|
||||||
|
return activeTab.value === 'services' ? isSvc : !isSvc
|
||||||
|
})
|
||||||
|
return filtered.sort(([idA, a], [idB, b]) => {
|
||||||
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
||||||
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
||||||
if (aWeb !== bWeb) return aWeb - bWeb
|
if (aWeb !== bWeb) return aWeb - bWeb
|
||||||
@@ -367,59 +432,7 @@ function canLaunch(pkg: PackageDataEntry): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function launchApp(id: string) {
|
function launchApp(id: string) {
|
||||||
const isDev = import.meta.env.DEV
|
useAppLauncherStore().openSession(id)
|
||||||
const pkg = packages.value[id]
|
|
||||||
|
|
||||||
// Web-only apps — use their external URL directly
|
|
||||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
|
||||||
if (webOnlyUrl) {
|
|
||||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg?.manifest?.title || id })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit URLs for apps that need them (checked first to avoid package data issues)
|
|
||||||
const appUrls: Record<string, { dev: string, prod: string }> = {
|
|
||||||
'lorabell': {
|
|
||||||
dev: 'http://192.168.1.166',
|
|
||||||
prod: 'http://192.168.1.166'
|
|
||||||
},
|
|
||||||
'atob': {
|
|
||||||
dev: 'http://localhost:8102',
|
|
||||||
prod: 'https://app.atobitcoin.io'
|
|
||||||
},
|
|
||||||
'k484': {
|
|
||||||
dev: 'http://localhost:8103',
|
|
||||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appUrls[id]) {
|
|
||||||
let url = isDev ? appUrls[id].dev : appUrls[id].prod
|
|
||||||
// Replace localhost with current hostname for remote access (not for external IPs like LoraBell)
|
|
||||||
if (url.includes('localhost')) {
|
|
||||||
const currentHost = window.location.hostname
|
|
||||||
url = url.replace('localhost', currentHost)
|
|
||||||
}
|
|
||||||
useAppLauncherStore().open({ url, title: pkg?.manifest?.title || id })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the LAN address from the package
|
|
||||||
let lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
|
|
||||||
|
|
||||||
// Replace localhost with the current hostname (for remote access)
|
|
||||||
if (lanAddress && lanAddress.includes('localhost')) {
|
|
||||||
const currentHost = window.location.hostname
|
|
||||||
lanAddress = lanAddress.replace('localhost', currentHost)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lanAddress) {
|
|
||||||
useAppLauncherStore().open({ url: lanAddress, title: pkg?.manifest?.title || id })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other apps, navigate to app details which has launch functionality
|
|
||||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusClass(state: PackageState): string {
|
function getStatusClass(state: PackageState): string {
|
||||||
@@ -491,15 +504,21 @@ function showUninstallModal(id: string, pkg: PackageDataEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uninstalling = ref(false)
|
||||||
|
|
||||||
async function confirmUninstall() {
|
async function confirmUninstall() {
|
||||||
const { appId } = uninstallModal.value
|
const { appId } = uninstallModal.value
|
||||||
uninstallModal.value.show = false
|
uninstalling.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.uninstallPackage(appId)
|
await store.uninstallPackage(appId)
|
||||||
|
uninstallModal.value.show = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||||
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
uninstallModal.value.show = false
|
||||||
|
} finally {
|
||||||
|
uninstalling.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useMeshStore } from '@/stores/mesh'
|
import { useMeshStore } from '@/stores/mesh'
|
||||||
import { useTransportStore } from '@/stores/transport'
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import type { MeshPeer } from '@/stores/mesh'
|
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
|
||||||
const mesh = useMeshStore()
|
const mesh = useMeshStore()
|
||||||
@@ -22,6 +22,20 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
|||||||
const publicChannel = { index: 0, name: 'Public' }
|
const publicChannel = { index: 0, name: 'Public' }
|
||||||
|
|
||||||
const togglingOffGrid = ref(false)
|
const togglingOffGrid = ref(false)
|
||||||
|
const peerSessionInfo = ref<SessionStatus | null>(null)
|
||||||
|
|
||||||
|
// Fetch session status when active peer changes
|
||||||
|
watch(() => activeChatPeer.value, async (peer) => {
|
||||||
|
if (peer) {
|
||||||
|
try {
|
||||||
|
peerSessionInfo.value = await mesh.getSessionStatus(peer.contact_id)
|
||||||
|
} catch {
|
||||||
|
peerSessionInfo.value = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
peerSessionInfo.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function handleToggleOffGrid() {
|
async function handleToggleOffGrid() {
|
||||||
togglingOffGrid.value = true
|
togglingOffGrid.value = true
|
||||||
@@ -343,6 +357,7 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
<div class="mesh-chat-header-sub">{{ activeChatSub }}</div>
|
<div class="mesh-chat-header-sub">{{ activeChatSub }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mesh-chat-header-status">
|
<div class="mesh-chat-header-status">
|
||||||
|
<span v-if="activeChatPeer && peerSessionInfo" class="mesh-session-badge" :class="peerSessionInfo.forward_secrecy ? 'session-ratchet' : peerSessionInfo.has_session ? 'session-static' : 'session-none'" :title="peerSessionInfo.forward_secrecy ? 'Double Ratchet (forward secrecy)' : peerSessionInfo.has_session ? 'Static encryption' : 'No encryption'">🛡</span>
|
||||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,8 +372,47 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
class="mesh-chat-bubble-wrapper"
|
class="mesh-chat-bubble-wrapper"
|
||||||
:class="msg.direction"
|
:class="msg.direction"
|
||||||
>
|
>
|
||||||
<div class="mesh-chat-bubble" :class="msg.direction">
|
<div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '']">
|
||||||
<div class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
<!-- Invoice card -->
|
||||||
|
<div v-if="msg.message_type === 'invoice' && msg.typed_payload" class="mesh-typed-invoice">
|
||||||
|
<div class="mesh-typed-invoice-header">
|
||||||
|
<span class="mesh-typed-icon">⚡</span>
|
||||||
|
<span class="mesh-typed-label">Lightning Invoice</span>
|
||||||
|
<span v-if="msg.typed_payload.paid" class="mesh-typed-paid">Paid</span>
|
||||||
|
</div>
|
||||||
|
<div class="mesh-typed-invoice-amount">{{ (msg.typed_payload.amount_sats || 0).toLocaleString() }} sats</div>
|
||||||
|
<div v-if="msg.typed_payload.memo" class="mesh-typed-invoice-memo">{{ msg.typed_payload.memo }}</div>
|
||||||
|
<div class="mesh-typed-invoice-bolt11">{{ (msg.typed_payload.bolt11 || '').substring(0, 40) }}...</div>
|
||||||
|
</div>
|
||||||
|
<!-- Alert card -->
|
||||||
|
<div v-else-if="msg.message_type === 'alert' && msg.typed_payload" class="mesh-typed-alert" :class="'alert-' + (msg.typed_payload.alert_type || 'status')">
|
||||||
|
<div class="mesh-typed-alert-header">
|
||||||
|
<span class="mesh-typed-icon">{{ msg.typed_payload.alert_type === 'emergency' ? '🚨' : msg.typed_payload.alert_type === 'dead_man' ? '☠' : 'ℹ' }}</span>
|
||||||
|
<span class="mesh-typed-label">{{ msg.typed_payload.alert_type === 'emergency' ? 'EMERGENCY' : msg.typed_payload.alert_type === 'dead_man' ? 'DEAD MAN' : 'Status' }}</span>
|
||||||
|
<span v-if="msg.typed_payload.signed" class="mesh-typed-signed">Signed</span>
|
||||||
|
</div>
|
||||||
|
<div class="mesh-typed-alert-message">{{ msg.typed_payload.message }}</div>
|
||||||
|
<a v-if="msg.typed_payload.coordinate" class="mesh-typed-alert-location" :href="'https://www.openstreetmap.org/?mlat=' + (msg.typed_payload.coordinate.lat / 1000000) + '&mlon=' + (msg.typed_payload.coordinate.lng / 1000000) + '&zoom=14'" target="_blank" rel="noopener">
|
||||||
|
📍 {{ msg.typed_payload.coordinate.label || 'View location' }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Coordinate card -->
|
||||||
|
<div v-else-if="msg.message_type === 'coordinate' && msg.typed_payload" class="mesh-typed-coordinate">
|
||||||
|
<div class="mesh-typed-coordinate-header">
|
||||||
|
<span class="mesh-typed-icon">📍</span>
|
||||||
|
<span class="mesh-typed-label">Location</span>
|
||||||
|
</div>
|
||||||
|
<div class="mesh-typed-coordinate-value">{{ (msg.typed_payload.lat / 1000000).toFixed(4) }}, {{ (msg.typed_payload.lng / 1000000).toFixed(4) }}</div>
|
||||||
|
<div v-if="msg.typed_payload.label" class="mesh-typed-coordinate-label">{{ msg.typed_payload.label }}</div>
|
||||||
|
<a class="mesh-typed-coordinate-link" :href="'https://www.openstreetmap.org/?mlat=' + (msg.typed_payload.lat / 1000000) + '&mlon=' + (msg.typed_payload.lng / 1000000) + '&zoom=14'" target="_blank" rel="noopener">Open Map</a>
|
||||||
|
</div>
|
||||||
|
<!-- Block header -->
|
||||||
|
<div v-else-if="msg.message_type === 'block_header' && msg.typed_payload" class="mesh-typed-block">
|
||||||
|
<span class="mesh-typed-icon">⛓</span>
|
||||||
|
<span class="mesh-typed-label">{{ msg.typed_payload.message || msg.plaintext }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Default: plain text -->
|
||||||
|
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
||||||
<div class="mesh-chat-bubble-meta">
|
<div class="mesh-chat-bubble-meta">
|
||||||
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||||
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
||||||
@@ -1057,4 +1111,51 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Session badge ─── */
|
||||||
|
.mesh-session-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-right: 6px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.session-ratchet { color: #4ade80; opacity: 1; }
|
||||||
|
.session-static { color: #fbbf24; }
|
||||||
|
.session-none { color: rgba(255,255,255,0.3); }
|
||||||
|
|
||||||
|
/* ─── Typed message cards ─── */
|
||||||
|
.mesh-typed-icon { margin-right: 4px; }
|
||||||
|
.mesh-typed-label { font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|
||||||
|
/* Invoice */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Alert */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Coordinate */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Block header */
|
||||||
|
.typed-block_header { border-left: 3px solid #a855f7; }
|
||||||
|
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user