feat: initial KosmoConnect platform v0.1
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:
@@ -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
@@ -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/"
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+2130
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+1753
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user