mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 08:54:36 +02:00
- Add GET /api/channels endpoint for live radio channel data - Create ChannelPicker component (single/multi-select from live channels) - Create NodePicker component (searchable multi-select from mesh nodes) - Replace manual inputs in Config with data-driven pickers - Update Notifications to use pickers for mesh broadcast/DM - Resolve node names in Alerts subscriptions display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { Search, X, Check } from 'lucide-react'
|
|
|
|
interface Node {
|
|
node_num: number
|
|
node_id_hex: string
|
|
short_name: string
|
|
long_name: string
|
|
role: string
|
|
is_infrastructure?: boolean
|
|
}
|
|
|
|
interface NodePickerProps {
|
|
label: string
|
|
value: string[]
|
|
onChange: (value: string[]) => void
|
|
helper?: string
|
|
info?: string
|
|
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
|
|
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
|
|
}
|
|
|
|
export default function NodePicker({
|
|
label,
|
|
value,
|
|
onChange,
|
|
helper,
|
|
info: _info,
|
|
roleFilter,
|
|
valueType = 'short_name',
|
|
}: NodePickerProps) {
|
|
const [nodes, setNodes] = useState<Node[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [search, setSearch] = useState('')
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/nodes')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setNodes(data)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setNodes([])
|
|
setLoading(false)
|
|
})
|
|
}, [])
|
|
|
|
const filteredNodes = useMemo(() => {
|
|
let result = nodes
|
|
|
|
// Filter by role if specified
|
|
if (roleFilter) {
|
|
result = result.filter(n => {
|
|
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
|
|
return n.is_infrastructure ||
|
|
n.role === 'ROUTER' ||
|
|
n.role === 'ROUTER_CLIENT' ||
|
|
n.role === 'REPEATER'
|
|
}
|
|
return n.role === roleFilter
|
|
})
|
|
}
|
|
|
|
// Filter by search
|
|
if (search.trim()) {
|
|
const s = search.toLowerCase()
|
|
result = result.filter(n =>
|
|
n.short_name?.toLowerCase().includes(s) ||
|
|
n.long_name?.toLowerCase().includes(s) ||
|
|
n.role?.toLowerCase().includes(s) ||
|
|
n.node_id_hex?.toLowerCase().includes(s)
|
|
)
|
|
}
|
|
|
|
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
|
|
}, [nodes, search, roleFilter])
|
|
|
|
const getNodeValue = (node: Node): string => {
|
|
switch (valueType) {
|
|
case 'node_num':
|
|
return String(node.node_num)
|
|
case 'node_id_hex':
|
|
return node.node_id_hex
|
|
default:
|
|
return node.short_name || String(node.node_num)
|
|
}
|
|
}
|
|
|
|
const isSelected = (node: Node): boolean => {
|
|
const nodeVal = getNodeValue(node)
|
|
return value.includes(nodeVal)
|
|
}
|
|
|
|
const toggleNode = (node: Node) => {
|
|
const nodeVal = getNodeValue(node)
|
|
if (value.includes(nodeVal)) {
|
|
onChange(value.filter(v => v !== nodeVal))
|
|
} else {
|
|
onChange([...value, nodeVal])
|
|
}
|
|
}
|
|
|
|
const formatNodeDisplay = (node: Node): string => {
|
|
const parts = [node.short_name]
|
|
if (node.long_name && node.long_name !== node.short_name) {
|
|
parts.push(`— ${node.long_name}`)
|
|
}
|
|
if (node.role) {
|
|
parts.push(`(${node.role})`)
|
|
}
|
|
return parts.join(' ')
|
|
}
|
|
|
|
// Fallback to text input if no nodes loaded
|
|
if (!loading && nodes.length === 0) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
|
<input
|
|
type="text"
|
|
value={value.join(', ')}
|
|
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
|
placeholder="Enter node IDs separated by commas"
|
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
|
/>
|
|
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
|
|
|
{/* Selected nodes display */}
|
|
{value.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{value.map((v) => {
|
|
const node = nodes.find(n => getNodeValue(n) === v)
|
|
return (
|
|
<span
|
|
key={v}
|
|
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
|
|
>
|
|
{node ? node.short_name : v}
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(value.filter(val => val !== v))}
|
|
className="hover:text-white"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search and dropdown */}
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onFocus={() => setIsOpen(true)}
|
|
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
|
|
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
|
|
{isOpen && !loading && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl">
|
|
{filteredNodes.length === 0 ? (
|
|
<div className="p-3 text-sm text-slate-500 text-center">
|
|
No nodes found
|
|
</div>
|
|
) : (
|
|
filteredNodes.map((node) => (
|
|
<button
|
|
key={node.node_num}
|
|
type="button"
|
|
onClick={() => toggleNode(node)}
|
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[#1e2a3a] ${
|
|
isSelected(node) ? 'bg-accent/10' : ''
|
|
}`}
|
|
>
|
|
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
|
isSelected(node) ? 'bg-accent border-accent' : 'border-slate-600'
|
|
}`}>
|
|
{isSelected(node) && <Check size={12} className="text-white" />}
|
|
</div>
|
|
<span className="text-slate-200">{formatNodeDisplay(node)}</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
|
</div>
|
|
)
|
|
}
|