feat: initial KosmoConnect platform v0.1
CI / lint-docs (push) Has been cancelled
CI / build-firmware (push) Has been cancelled
CI / test-backend (push) Has been cancelled
CI / test-web (push) Has been cancelled

Includes:
- Backend services: ingestion (:8001), weather API (:8002),
  gateway (:8003), billing (:8004) with BTCPay integration
- Shared asyncpg pool, TimescaleDB hypertable, Redis, Mosquitto MQTT
- React frontend: Dashboard (MapLibre) and Messaging (chat UI)
- Bridge daemon for Pi + Meshtastic (Serial/TCP T-Deck support)
- Production Docker Compose, Nginx reverse proxy, ops scripts
- DEPLOY.md with step-by-step deployment guide
This commit is contained in:
2026-04-12 17:30:15 +02:00
commit 0a4fb7b55e
95 changed files with 9903 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# Web
This directory contains all web-based frontends for KosmoConnect.
## Structure
```
web/
├── dashboard/ # Public weather dashboard
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── Dockerfile
├── messaging/ # Subscriber web-to-mesh messaging client
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── Dockerfile
├── admin/ # Administrative panel
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── Dockerfile
└── shared/ # Shared UI components, hooks, styles
├── components/
├── hooks/
└── styles/
```
## Dashboard
A public-facing weather visualization app.
**Tech Stack**: React + Vite + MapLibre GL
**Features**:
- Interactive map showing all active enviro-nodes
- Live sensor readings inside node popups
- Auto-refreshing node locations and health
- Mobile-responsive dark theme
- No login required for basic viewing
See [`dashboard/README.md`](./dashboard/README.md) for run instructions.
## Messaging Client
A subscriber-only app for sending and receiving mesh messages.
**Tech Stack**: React + Vite
**Features**:
- Inbox with threaded conversations
- Compose message to any node (network plan) or linked nodes (node plan)
- Auto-refreshing replies and delivery status indicators
- Dev-mode user switcher for testing subscription tiers
See [`messaging/README.md`](./messaging/README.md) for run instructions.
## Admin Panel
An internal tool for network operators.
**Tech Stack**: React + Vite + TanStack Table
**Features**:
- Node onboarding wizard
- Subscriber search and management
- Network-wide message broadcast
- System metrics and logs
- Invoice and payout overview
## Design System
- **Colors**: Dark theme primary (slate/zinc), accent color TBD by Church of Kosmo branding
- **Typography**: Inter or system-ui stack
- **Icons**: Lucide React
- **Component Library**: Headless UI + Tailwind CSS
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Build all web frontends for production deployment
# Output goes to each app's dist/ directory
cd "$(dirname "$0")"
echo "Building Dashboard..."
cd dashboard
npm install
npm run build
cd ..
echo "Building Messaging..."
cd messaging
npm install
npm run build
cd ..
echo "Build complete."
echo "Dashboard static files: dashboard/dist/"
echo "Messaging static files: messaging/dist/"
+40
View File
@@ -0,0 +1,40 @@
# KosmoConnect Dashboard
Public weather and node health visualization for the KosmoConnect mesh network.
## Tech Stack
- **React 18** + **Vite**
- **MapLibre GL** (open-source Mapbox alternative)
- CartoDB Voyager basemap (free, no API key required)
## Running Locally
Make sure the backend API is running on `http://localhost:8002` (see `backend/README.md`).
```bash
cd web/dashboard
npm install
npm run dev
```
Open http://localhost:3000 in your browser.
## Features (v0.1)
- Interactive map showing all registered enviro-nodes
- Live sensor readings inside node popups (temperature, humidity, pressure, wind, PM2.5, PM10, battery, solar)
- Auto-refresh every 15 seconds
- Automatic map bounds fitting so all nodes are visible
- Dark UI theme aligned with Church of Kosmo aesthetics
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `VITE_API_BASE` | `http://localhost:8002` | Base URL for the KosmoConnect API |
## Architecture Notes
- The Vite dev server proxies `/api` requests to `localhost:8002` to avoid CORS issues during development.
- In production, the dashboard is served as static files and talks directly to the API host.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KosmoConnect Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2130
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "kosmoconnect-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"maplibre-gl": "^4.7.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.1"
}
}
+28
View File
@@ -0,0 +1,28 @@
import MapView from './components/MapView'
function App() {
return (
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
<header style={{
padding: '0.75rem 1rem',
background: '#0f172a',
borderBottom: '1px solid #1e293b',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, color: '#38bdf8' }}>
KosmoConnect
</h1>
<span style={{ fontSize: '0.875rem', color: '#94a3b8' }}>
Environmental Intelligence & Emergency Mesh Network
</span>
</header>
<main style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
<MapView />
</main>
</div>
)
}
export default App
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useNodes } from '../hooks/useApi'
import NodePopup from './NodePopup'
import { createRoot } from 'react-dom/client'
// Free CartoDB Voyager style for MapLibre
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
export default function MapView() {
const mapContainerRef = useRef(null)
const mapRef = useRef(null)
const markersRef = useRef([])
const { nodes, error } = useNodes(15000)
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: MAP_STYLE,
center: [10, 50], // default center (Europe-ish)
zoom: 4,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map) return
// clear existing markers
markersRef.current.forEach(m => m.remove())
markersRef.current = []
if (!nodes.length) return
// compute bounds to fit all nodes with coordinates
const coords = nodes.filter(n => n.lat != null && n.lon != null).map(n => [n.lon, n.lat])
nodes.forEach(node => {
const el = document.createElement('div')
el.style.width = '18px'
el.style.height = '18px'
el.style.borderRadius = '50%'
el.style.background = node.last_seen ? '#22c55e' : '#64748b'
el.style.border = '2px solid #0f172a'
el.style.boxShadow = '0 0 0 2px #38bdf8'
el.style.cursor = 'pointer'
const marker = new maplibregl.Marker({ element: el })
.setLngLat([node.lon ?? 0, node.lat ?? 0])
.addTo(map)
const popupContainer = document.createElement('div')
const root = createRoot(popupContainer)
root.render(<NodePopup node={node} />)
const popup = new maplibregl.Popup({
offset: 20,
className: 'kosmo-popup',
maxWidth: '320px'
}).setDOMContent(popupContainer)
marker.setPopup(popup)
markersRef.current.push(marker)
})
if (coords.length) {
const bounds = coords.reduce(
(b, c) => b.extend(c),
new maplibregl.LngLatBounds(coords[0], coords[0])
)
map.fitBounds(bounds, { padding: 60, maxZoom: 12, duration: 1000 })
}
}, [nodes])
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
{error && (
<div style={{
position: 'absolute',
top: '1rem',
left: '1rem',
background: '#7f1d1d',
color: '#fecaca',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
fontSize: '0.875rem',
zIndex: 10
}}>
API Error: {error}
</div>
)}
<div style={{
position: 'absolute',
bottom: '1rem',
left: '1rem',
background: 'rgba(15, 23, 42, 0.8)',
color: '#94a3b8',
padding: '0.35rem 0.6rem',
borderRadius: '0.375rem',
fontSize: '0.75rem',
zIndex: 10,
backdropFilter: 'blur(4px)'
}}>
Nodes online: {nodes.filter(n => n.is_active).length} · Total: {nodes.length}
</div>
</div>
)
}
@@ -0,0 +1,47 @@
import { useLatest } from '../hooks/useApi'
function fmt(v, unit) {
if (v == null) return '—'
return `${Number(v).toFixed(1)} ${unit}`
}
function timeAgo(iso) {
if (!iso) return '—'
const diff = Date.now() - new Date(iso).getTime()
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
return `${hr}h ago`
}
export default function NodePopup({ node }) {
const { reading } = useLatest(node.mesh_node_id)
return (
<div style={{ minWidth: '220px' }}>
<div style={{ fontWeight: 600, fontSize: '1rem', marginBottom: '0.25rem', color: '#38bdf8' }}>
{node.name || node.mesh_node_id}
</div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '0.5rem' }}>
{node.mesh_node_id} · {timeAgo(node.last_seen)}
</div>
{reading ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.35rem 0.75rem', fontSize: '0.875rem' }}>
<div><span style={{ color: '#94a3b8' }}>Temp</span><br/><strong>{fmt(reading.temperature_c, '°C')}</strong></div>
<div><span style={{ color: '#94a3b8' }}>Humidity</span><br/><strong>{fmt(reading.humidity_percent, '%')}</strong></div>
<div><span style={{ color: '#94a3b8' }}>Pressure</span><br/><strong>{fmt(reading.pressure_pa / 100, 'hPa')}</strong></div>
<div><span style={{ color: '#94a3b8' }}>Wind</span><br/><strong>{fmt(reading.wind_speed_ms, 'm/s')} {reading.wind_direction != null ? `@ ${reading.wind_direction}°` : ''}</strong></div>
<div><span style={{ color: '#94a3b8' }}>PM2.5</span><br/><strong>{fmt(reading.pm25_ugm3, 'µg/m³')}</strong></div>
<div><span style={{ color: '#94a3b8' }}>PM10</span><br/><strong>{fmt(reading.pm10_ugm3, 'µg/m³')}</strong></div>
<div><span style={{ color: '#94a3b8' }}>Battery</span><br/><strong>{fmt(reading.battery_voltage, 'V')}</strong></div>
<div><span style={{ color: '#94a3b8' }}>Solar</span><br/><strong>{fmt(reading.solar_voltage, 'V')}</strong></div>
</div>
) : (
<div style={{ fontSize: '0.875rem', color: '#94a3b8' }}>No recent readings available.</div>
)}
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react'
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8002'
export function useNodes(refreshMs = 15000) {
const [nodes, setNodes] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchNodes() {
try {
const res = await fetch(`${API_BASE}/api/v1/nodes`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
if (!cancelled) setNodes(json.data || [])
} catch (e) {
if (!cancelled) setError(e.message)
}
}
fetchNodes()
const id = setInterval(fetchNodes, refreshMs)
return () => {
cancelled = true
clearInterval(id)
}
}, [refreshMs])
return { nodes, error }
}
export function useLatest(nodeId) {
const [reading, setReading] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
if (!nodeId) return
let cancelled = false
async function fetchLatest() {
try {
const res = await fetch(`${API_BASE}/api/v1/weather/latest?node_id=${encodeURIComponent(nodeId)}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
if (!cancelled) setReading((json.data || [])[0] || null)
} catch (e) {
if (!cancelled) setError(e.message)
}
}
fetchLatest()
const id = setInterval(fetchLatest, 10000)
return () => {
cancelled = true
clearInterval(id)
}
}, [nodeId])
return { reading, error }
}
+53
View File
@@ -0,0 +1,53 @@
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #0f172a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
height: 100vh;
}
.kosmo-popup .maplibregl-popup-content {
background: #1e293b;
color: #f8fafc;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid #334155;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3);
}
.kosmo-popup .maplibregl-popup-tip {
border-top-color: #1e293b;
}
.maplibregl-popup-close-button {
color: #94a3b8;
font-size: 1rem;
padding: 0.25rem 0.5rem;
}
.maplibregl-popup-close-button:hover {
color: #f8fafc;
background: transparent;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8002',
changeOrigin: true,
}
}
}
})
+35
View File
@@ -0,0 +1,35 @@
# KosmoConnect Messaging Client
A subscriber-only web application for sending and receiving messages with the Meshtastic mesh network.
## Tech Stack
- **React 18** + **Vite**
- Plain CSS (no heavy UI framework)
## Running Locally
Make sure the **Gateway Service** is running on `http://localhost:8003` (see `backend/gateway/README.md`).
```bash
cd web/messaging
npm install
npm run dev
```
Open http://localhost:3001 in your browser.
## Features (v0.1)
- **User switcher** (dev mode): select between test subscription tiers (Wanderer / Guardian)
- **Conversation list**: auto-refreshing sidebar with latest message preview and unread badges
- **Message thread**: chat-style bubbles with timestamps and delivery status indicators
- `⏳` pending / `✓` queued / `✓✓` transmitted or delivered
- **Auto-refresh**: polls for new replies every 5 seconds
- **Subscription enforcement**: errors surfaced as browser alerts (e.g., quota exceeded, node not allowed)
## Architecture Notes
- The Vite dev server proxies `/api` requests to `localhost:8003` to avoid CORS issues during development.
- In production, the messaging client is served as static files and talks directly to the API gateway host.
- Authentication is currently mocked with a simple `X-User-ID` header selector. Production will use JWT.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KosmoConnect Messaging</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1753
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "kosmoconnect-messaging",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.1"
}
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import ConversationList from './components/ConversationList'
import MessageThread from './components/MessageThread'
import UserSwitcher from './components/UserSwitcher'
const styles = {
container: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100vh',
background: '#0f172a',
},
header: {
padding: '0.75rem 1rem',
background: '#0f172a',
borderBottom: '1px solid #1e293b',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
},
title: {
margin: 0,
fontSize: '1.25rem',
fontWeight: 600,
color: '#38bdf8',
},
main: {
display: 'flex',
flex: 1,
overflow: 'hidden',
},
sidebar: {
width: '320px',
minWidth: '260px',
borderRight: '1px solid #1e293b',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
thread: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
empty: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: '0.95rem',
},
}
export default function App() {
const [userId, setUserId] = useState('11111111-1111-1111-1111-111111111111')
const [selectedNodeId, setSelectedNodeId] = useState(null)
return (
<div style={styles.container}>
<header style={styles.header}>
<h1 style={styles.title}>KosmoConnect Messaging</h1>
<UserSwitcher userId={userId} onChange={setUserId} />
</header>
<main style={styles.main}>
<aside style={styles.sidebar}>
<ConversationList
userId={userId}
selectedNodeId={selectedNodeId}
onSelect={setSelectedNodeId}
/>
</aside>
<section style={styles.thread}>
{selectedNodeId ? (
<MessageThread
userId={userId}
nodeId={selectedNodeId}
key={selectedNodeId + userId}
/>
) : (
<div style={styles.empty}>Select a conversation to start messaging</div>
)}
</section>
</main>
</div>
)
}
@@ -0,0 +1,96 @@
import { useConversations } from '../hooks/useApi'
function timeAgo(iso) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m`
const hr = Math.floor(min / 60)
return `${hr}h`
}
export default function ConversationList({ userId, selectedNodeId, onSelect }) {
const { conversations, error } = useConversations(userId)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '0.6rem 0.9rem',
fontSize: '0.75rem',
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderBottom: '1px solid #1e293b',
}}
>
Conversations
</div>
{error && (
<div style={{ padding: '0.5rem 0.75rem', color: '#fecaca', fontSize: '0.85rem' }}>
Error: {error}
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{conversations.length === 0 && (
<div style={{ padding: '1rem', color: '#64748b', fontSize: '0.875rem' }}>
No conversations yet.
</div>
)}
{conversations.map((c) => {
const isSelected = c.node_id === selectedNodeId
return (
<button
key={c.node_id}
onClick={() => onSelect(c.node_id)}
style={{
width: '100%',
textAlign: 'left',
padding: '0.75rem 0.9rem',
background: isSelected ? '#1e293b' : 'transparent',
border: 'none',
borderBottom: '1px solid #1e293b',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: '#f8fafc', fontSize: '0.95rem' }}>
{c.nickname || c.node_id}
</span>
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>{timeAgo(c.latest_at)}</span>
</div>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{c.latest_text || 'No messages'}
</div>
{c.unread_count > 0 && (
<span
style={{
alignSelf: 'flex-start',
marginTop: '0.2rem',
background: '#38bdf8',
color: '#0f172a',
fontSize: '0.7rem',
fontWeight: 700,
padding: '0.15rem 0.4rem',
borderRadius: '999px',
}}
>
{c.unread_count} new
</span>
)}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,133 @@
import { useState, useRef, useEffect } from 'react'
import { useMessages } from '../hooks/useApi'
function statusIcon(status) {
if (status === 'pending') return '⏳'
if (status === 'queued') return '✓'
if (status === 'transmitted') return '✓✓'
if (status === 'delivered') return '✓✓'
return ''
}
export default function MessageThread({ userId, nodeId }) {
const { messages, error, sendMessage } = useMessages(userId, nodeId)
const [text, setText] = useState('')
const [sending, setSending] = useState(false)
const bottomRef = useRef(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = async (e) => {
e.preventDefault()
if (!text.trim() || sending) return
setSending(true)
try {
await sendMessage(text.trim())
setText('')
} catch (err) {
alert(err.message)
} finally {
setSending(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid #1e293b',
fontWeight: 600,
color: '#f8fafc',
background: '#0f172a',
}}
>
{nodeId}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{error && (
<div style={{ color: '#fecaca', fontSize: '0.85rem' }}>Error loading messages: {error}</div>
)}
{messages.map((msg) => {
const isOutbound = msg.direction === 'outbound'
return (
<div
key={msg.id}
style={{
alignSelf: isOutbound ? 'flex-end' : 'flex-start',
maxWidth: '70%',
background: isOutbound ? '#075985' : '#1e293b',
color: '#f8fafc',
padding: '0.6rem 0.9rem',
borderRadius: '0.75rem',
borderBottomRightRadius: isOutbound ? '0.25rem' : '0.75rem',
borderBottomLeftRadius: isOutbound ? '0.75rem' : '0.25rem',
fontSize: '0.9rem',
lineHeight: 1.4,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.text}
<div style={{ marginTop: '0.35rem', fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: isOutbound ? 'flex-end' : 'flex-start' }}>
<span>{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
{isOutbound && <span title={msg.status}>{statusIcon(msg.status)}</span>}
{msg.hop_count != null && (
<span title={`${msg.hop_count} hops`}>· {msg.hop_count} hops</span>
)}
</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
<form
onSubmit={handleSend}
style={{
display: 'flex',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderTop: '1px solid #1e293b',
background: '#0f172a',
}}
>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type a message..."
maxLength={200}
style={{
flex: 1,
background: '#1e293b',
border: '1px solid #334155',
borderRadius: '0.5rem',
padding: '0.5rem 0.75rem',
color: '#f8fafc',
fontSize: '0.9rem',
}}
/>
<button
type="submit"
disabled={sending || !text.trim()}
style={{
background: sending ? '#334155' : '#0284c7',
color: '#fff',
border: 'none',
borderRadius: '0.5rem',
padding: '0.5rem 1rem',
fontWeight: 600,
fontSize: '0.9rem',
}}
>
{sending ? 'Sending…' : 'Send'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,30 @@
const USERS = [
{ id: '11111111-1111-1111-1111-111111111111', label: 'Wanderer (any node)' },
{ id: '22222222-2222-2222-2222-222222222222', label: 'Guardian (allowed nodes)' },
]
export default function UserSwitcher({ userId, onChange }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>User:</span>
<select
value={userId}
onChange={(e) => onChange(e.target.value)}
style={{
background: '#1e293b',
color: '#f8fafc',
border: '1px solid #334155',
borderRadius: '0.375rem',
padding: '0.35rem 0.5rem',
fontSize: '0.85rem',
}}
>
{USERS.map((u) => (
<option key={u.id} value={u.id}>
{u.label}
</option>
))}
</select>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState, useCallback } from 'react'
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8003'
async function apiFetch(path, userId, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-User-ID': userId,
...options.headers,
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${res.status}`)
}
return res.json()
}
export function useConversations(userId, refreshMs = 5000) {
const [conversations, setConversations] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchConversations() {
try {
const json = await apiFetch('/api/v1/messages/conversations', userId)
if (!cancelled) setConversations(json.data || [])
} catch (e) {
if (!cancelled) setError(e.message)
}
}
fetchConversations()
const id = setInterval(fetchConversations, refreshMs)
return () => {
cancelled = true
clearInterval(id)
}
}, [userId, refreshMs])
return { conversations, error }
}
export function useMessages(userId, nodeId, refreshMs = 5000) {
const [messages, setMessages] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchMessages() {
try {
const json = await apiFetch(`/api/v1/messages/conversations/${encodeURIComponent(nodeId)}`, userId)
if (!cancelled) setMessages(json.data || [])
} catch (e) {
if (!cancelled) setError(e.message)
}
}
fetchMessages()
const id = setInterval(fetchMessages, refreshMs)
return () => {
cancelled = true
clearInterval(id)
}
}, [userId, nodeId, refreshMs])
const sendMessage = useCallback(async (text) => {
const json = await apiFetch('/api/v1/messages', userId, {
method: 'POST',
body: JSON.stringify({ target_node_id: nodeId, text }),
})
return json
}, [userId, nodeId])
return { messages, error, sendMessage }
}
+31
View File
@@ -0,0 +1,31 @@
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.9);
background-color: #0f172a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
height: 100vh;
}
button {
cursor: pointer;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,
}
}
}
})