React + Vite + TailwindCSS dashboard served on port 8080. Connects to ROS2 via rosbridge_server WebSocket (default ws://localhost:9090). Panels: - StatusPanel: pipeline state (idle/listening/thinking/speaking/throttled) with animated pulse indicator, GPU memory bar, per-stage latency stats - FaceGallery: enrolled persons grid with enroll/delete via /social/enrollment/* services; live detection indicator - ConversationLog: real-time transcript with human/bot bubbles, streaming partial support, auto-scroll - PersonalityTuner: sass/humor/verbosity sliders (0–10) writing to personality_node via rcl_interfaces/srv/SetParameters; live PersonalityState display - NavModeSelector: shadow/lead/side/orbit/loose/tight mode buttons publishing to /social/nav/mode; voice command reference table Usage: cd ui/social-bot && npm install && npm run dev # dev server port 8080 npm run build && npm run preview # production preview Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
5.8 KiB
JavaScript
166 lines
5.8 KiB
JavaScript
/**
|
|
* App.jsx — Saltybot Social Dashboard root component.
|
|
*
|
|
* Layout:
|
|
* [TopBar: connection config + pipeline state badge]
|
|
* [TabNav: Status | Faces | Conversation | Personality | Navigation]
|
|
* [TabContent]
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useRosbridge } from './hooks/useRosbridge.js';
|
|
import { StatusPanel } from './components/StatusPanel.jsx';
|
|
import { FaceGallery } from './components/FaceGallery.jsx';
|
|
import { ConversationLog } from './components/ConversationLog.jsx';
|
|
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
|
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
|
|
|
const TABS = [
|
|
{ id: 'status', label: 'Status', icon: '⬤' },
|
|
{ id: 'faces', label: 'Faces', icon: '◉' },
|
|
{ id: 'conversation', label: 'Conversation', icon: '◌' },
|
|
{ id: 'personality', label: 'Personality', icon: '◈' },
|
|
{ id: 'navigation', label: 'Navigation', icon: '◫' },
|
|
];
|
|
|
|
const DEFAULT_WS_URL = 'ws://localhost:9090';
|
|
|
|
function ConnectionBar({ url, setUrl, connected, error }) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(url);
|
|
|
|
const handleApply = () => {
|
|
setUrl(draft);
|
|
setEditing(false);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
{/* Connection dot */}
|
|
<div className={`w-2 h-2 rounded-full shrink-0 ${
|
|
connected ? 'bg-green-400' : error ? 'bg-red-500' : 'bg-gray-600'
|
|
}`} />
|
|
|
|
{editing ? (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleApply(); if (e.key === 'Escape') setEditing(false); }}
|
|
autoFocus
|
|
className="bg-gray-900 border border-cyan-800 rounded px-2 py-0.5 text-cyan-300 w-52 focus:outline-none"
|
|
/>
|
|
<button onClick={handleApply} className="px-2 py-0.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-400 hover:bg-cyan-900">Connect</button>
|
|
<button onClick={() => setEditing(false)} className="text-gray-600 hover:text-gray-400 px-1">✕</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => { setDraft(url); setEditing(true); }}
|
|
className="text-gray-500 hover:text-cyan-400 transition-colors truncate max-w-40"
|
|
title={url}
|
|
>
|
|
{connected ? (
|
|
<span className="text-green-400">rosbridge: {url}</span>
|
|
) : error ? (
|
|
<span className="text-red-400" title={error}>⚠ {url}</span>
|
|
) : (
|
|
<span className="text-gray-500">{url} (connecting…)</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
|
|
const [activeTab, setActiveTab] = useState('status');
|
|
|
|
const { connected, error, subscribe, publish, callService, setParam } = useRosbridge(wsUrl);
|
|
|
|
// Memoized publish for NavModeSelector (avoids recreating on every render)
|
|
const publishFn = useCallback(
|
|
(name, type, data) => publish(name, type, data),
|
|
[publish]
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col bg-[#050510] text-gray-300 font-mono">
|
|
{/* ── Top Bar ── */}
|
|
<header className="flex items-center justify-between px-4 py-2 bg-[#070712] border-b border-cyan-950 shrink-0 gap-2 flex-wrap">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-orange-500 font-bold tracking-widest text-sm">⚡ SALTYBOT</span>
|
|
<span className="text-cyan-800 text-xs hidden sm:inline">SOCIAL DASHBOARD</span>
|
|
</div>
|
|
|
|
<ConnectionBar
|
|
url={wsUrl}
|
|
setUrl={setWsUrl}
|
|
connected={connected}
|
|
error={error}
|
|
/>
|
|
</header>
|
|
|
|
{/* ── Tab Nav ── */}
|
|
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0">
|
|
<div className="flex overflow-x-auto">
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-bold tracking-widest whitespace-nowrap border-b-2 transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-cyan-500 text-cyan-300 bg-cyan-950 bg-opacity-30'
|
|
: 'border-transparent text-gray-500 hover:text-gray-300 hover:border-gray-700'
|
|
}`}
|
|
>
|
|
<span className="hidden sm:inline text-base leading-none">{tab.icon}</span>
|
|
{tab.label.toUpperCase()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* ── Content ── */}
|
|
<main className="flex-1 overflow-y-auto p-4">
|
|
{activeTab === 'status' && (
|
|
<StatusPanel subscribe={subscribe} />
|
|
)}
|
|
|
|
{activeTab === 'faces' && (
|
|
<FaceGallery
|
|
subscribe={subscribe}
|
|
callService={callService}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'conversation' && (
|
|
<ConversationLog subscribe={subscribe} />
|
|
)}
|
|
|
|
{activeTab === 'personality' && (
|
|
<PersonalityTuner
|
|
subscribe={subscribe}
|
|
setParam={setParam}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'navigation' && (
|
|
<NavModeSelector
|
|
subscribe={subscribe}
|
|
publish={publishFn}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
{/* ── Footer ── */}
|
|
<footer className="bg-[#070712] border-t border-cyan-950 px-4 py-1.5 flex items-center justify-between text-xs text-gray-700 shrink-0">
|
|
<span>rosbridge: <code className="text-gray-600">{wsUrl}</code></span>
|
|
<span className={connected ? 'text-green-700' : 'text-red-900'}>
|
|
{connected ? 'CONNECTED' : 'DISCONNECTED'}
|
|
</span>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|