mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
coloooors
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# general idea
|
||||
- uses the rover's reported battery full, warn, and urgent values
|
||||
- will apply to all rovers
|
||||
- uses the rover locking system
|
||||
- completely server side
|
||||
- always use the battery warn value as 0% battery and the full value as 100%
|
||||
|
||||
## first: the server-side server-wide event bus
|
||||
- global server event bus
|
||||
- used for realtime alerts between modules
|
||||
- includes a way to tell where the event is coming from
|
||||
|
||||
## what will the battery manager do?
|
||||
- watch each rover's battery charge number (reported in sensors)
|
||||
- if the number reaches warn, fire an event on the event bus
|
||||
- the UI will show a warning to users, independently based on sensor data
|
||||
- once the rover is docked and charging, lock it.
|
||||
- when the battery is fully charged, unlock it.
|
||||
|
||||
## while we're at it...
|
||||
- add a lock reason to the rover locking system
|
||||
- add the following to the rover roster in the UI state message:
|
||||
- battery full #
|
||||
- battery warn #
|
||||
- battery urgent #
|
||||
- in the rover roster UI component, add a display for battery percentage and lock reason.
|
||||
- make the rover's background red when locked
|
||||
+4
-4
@@ -4,9 +4,6 @@
|
||||
- video stream currently unloads and reloads based on the tab being active
|
||||
- just remove this feature. It needs to stay running all the time.
|
||||
|
||||
## UI and session info
|
||||
- UI needs a display for the turns mode
|
||||
|
||||
## UI tweaks and fixes
|
||||
- remove redundant rover name tags everywhere
|
||||
- just have one in the hud: "You are driving <name>"
|
||||
@@ -20,4 +17,7 @@
|
||||
- buttons should be even darker, almost black
|
||||
- all buttons should have a hover effect which lightens them a little
|
||||
- text should be either white or the same gray
|
||||
-
|
||||
|
||||
## mobile UI elements
|
||||
- they are currently not styled to match the rest of the program
|
||||
- make them match
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-Be0x3WiZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BTX_m20O.css">
|
||||
<script type="module" crossorigin src="/assets/index-CbBQ197T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DkvQkTlU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+12
-12
@@ -49,12 +49,12 @@ function useLayoutMode() {
|
||||
|
||||
function DesktopLayout({ layout }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-0.5">
|
||||
<DriverVideoPanel />
|
||||
<RightPaneTabs layout={layout} />
|
||||
</section>
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
|
||||
{/* <AdminPanel /> */}
|
||||
<LogPanel />
|
||||
<SessionSnapshot />
|
||||
@@ -65,7 +65,7 @@ function DesktopLayout({ layout }) {
|
||||
|
||||
function MobilePortraitLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<DriverVideoPanel />
|
||||
<MobileControls />
|
||||
<DrivePanel />
|
||||
@@ -80,21 +80,21 @@ function MobilePortraitLayout() {
|
||||
|
||||
function MobileLandscapeLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
||||
<MobileLandscapeAuxColumn />
|
||||
<DriverVideoPanel />
|
||||
<MobileLandscapeControlColumn />
|
||||
</section>
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
||||
<RoomCameraPanel />
|
||||
<DrivePanel />
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<LogPanel />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
@@ -114,12 +114,12 @@ function App() {
|
||||
: <MobilePortraitLayout />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-slate-50">
|
||||
<div className="min-h-screen bg-black text-slate-100">
|
||||
<SettingsProvider>
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className="flex w-full flex-col gap-1 text-base">{renderedLayout}</main>
|
||||
<main className="flex w-full flex-col gap-0.5 text-base">{renderedLayout}</main>
|
||||
<AlertFeed />
|
||||
<ModeGateOverlay />
|
||||
</ControlSystemProvider>
|
||||
|
||||
@@ -57,14 +57,10 @@ export default function AdminPanel() {
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="flex items-center justify-between gap-1 text-sm">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between gap-0.5 text-sm">
|
||||
<span>Admin controls</span>
|
||||
<select
|
||||
value={currentMode}
|
||||
onChange={handleModeChange}
|
||||
className="rounded-sm bg-black/40 px-1 py-1 text-sm text-slate-100"
|
||||
>
|
||||
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
|
||||
{MODES.map((mode) => (
|
||||
<option key={mode.key} value={mode.key}>
|
||||
{mode.label}
|
||||
@@ -73,25 +69,25 @@ export default function AdminPanel() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 space-y-1 text-sm">
|
||||
<div className="space-y-0.5 text-sm">
|
||||
{roster.map((rover) => (
|
||||
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
|
||||
<div key={rover.id} className="surface flex flex-wrap items-center justify-between gap-0.5">
|
||||
<div>
|
||||
<p className="text-slate-100">{rover.name}</p>
|
||||
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-xs">
|
||||
<div className="flex flex-wrap gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="button-dark"
|
||||
>
|
||||
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleForceControl(rover.id)}
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="button-dark"
|
||||
>
|
||||
Force
|
||||
</button>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AlertFeed() {
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
|
||||
<div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5">
|
||||
{visible.map((toast) => (
|
||||
<AlertToast key={toast.key} alert={toast.alert} />
|
||||
))}
|
||||
@@ -46,8 +46,8 @@ function AlertToast({ alert }) {
|
||||
? 'bg-amber-500/30 text-amber-100'
|
||||
: 'bg-emerald-500/30 text-emerald-100';
|
||||
return (
|
||||
<div className={`pointer-events-auto rounded-sm px-1 py-1 text-[0.75rem] ${colorClasses}`}>
|
||||
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
|
||||
<div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}>
|
||||
<p className="text-xs text-slate-200">{alert.title || 'Alert'}</p>
|
||||
<p className="text-sm text-white">{alert.message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,43 +22,31 @@ export default function AuthPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="panel-section space-y-0.5 text-base">
|
||||
<p className="text-sm text-slate-400">Admin login</p>
|
||||
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
|
||||
<form className="flex flex-col gap-0.5" onSubmit={handleLogin}>
|
||||
<input
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="field-input"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="field-input"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-sm bg-slate-200 px-1 py-1 text-sm font-semibold text-black disabled:opacity-50"
|
||||
>
|
||||
<button type="submit" disabled={loading} className="button-dark w-full disabled:opacity-50">
|
||||
{loading ? 'Logging in…' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-1 flex gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('user')}
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||
>
|
||||
<div className="flex gap-0.5 text-sm">
|
||||
<button type="button" onClick={() => setRole('user')} className="flex-1 button-dark">
|
||||
Driver
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('spectator')}
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||
>
|
||||
<button type="button" onClick={() => setRole('spectator')} className="flex-1 button-dark">
|
||||
Spectator
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -81,12 +81,12 @@ export default function CameraServoPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>Camera Tilt</span>
|
||||
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
@@ -99,35 +99,23 @@ export default function CameraServoPanel() {
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-slate-400">
|
||||
<div className="mt-0.5 flex justify-between text-xs text-slate-400">
|
||||
<span>{formatDegrees(min)}</span>
|
||||
<span>{formatDegrees(max)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => handleNudge(-1)}
|
||||
>
|
||||
<div className="flex gap-0.5 text-sm">
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
|
||||
Tilt Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-slate-700 px-1 py-0.5 text-slate-100 hover:bg-slate-600"
|
||||
onClick={() => goServoHome()}
|
||||
>
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => goServoHome()}>
|
||||
Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => handleNudge(1)}
|
||||
>
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(1)}>
|
||||
Tilt Up
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="text-xs text-slate-400">
|
||||
Step: {formatDegrees(step)} · Pin GPIO {config?.pin}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -27,12 +27,12 @@ export default function DrivePanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>Drive control</span>
|
||||
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
||||
<span>{roverId ? 'Ready to drive' : 'No rover assigned'}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<ActionCard
|
||||
title="Start Driving"
|
||||
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
|
||||
@@ -58,21 +58,24 @@ export default function DrivePanel() {
|
||||
}
|
||||
|
||||
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
|
||||
const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600';
|
||||
const colors =
|
||||
tone === 'indigo'
|
||||
? { base: 'bg-indigo-600', hover: 'hover:bg-indigo-500' }
|
||||
: { base: 'bg-emerald-600', hover: 'hover:bg-emerald-500' };
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-lg ${baseColor} px-1 py-1 text-left text-white disabled:opacity-40`}
|
||||
className={`w-full ${colors.base} ${colors.hover} px-0.5 py-0.5 text-left text-white transition-colors disabled:opacity-40`}
|
||||
>
|
||||
<p className="text-base font-semibold">{title}</p>
|
||||
<p className="text-sm text-white/90">{description}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<div className="mt-0.5 flex flex-wrap gap-0.5">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status.label}
|
||||
className={`rounded-full px-1 py-0.5 text-xs font-semibold ${
|
||||
className={`px-0.5 py-0.5 text-xs font-semibold ${
|
||||
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
|
||||
}`}
|
||||
>
|
||||
@@ -80,7 +83,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{footnote && <p className="mt-1 text-xs text-emerald-50/80">{footnote}</p>}
|
||||
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,20 +15,14 @@ export default function DriverVideoPanel() {
|
||||
: null;
|
||||
const batteryConfig = batteryRecord?.battery ?? null;
|
||||
|
||||
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1">
|
||||
<section className="panel">
|
||||
{roverId ? (
|
||||
<div className="w-full">
|
||||
<VideoTile
|
||||
sessionInfo={info}
|
||||
label={roverId}
|
||||
muted={false}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
/>
|
||||
</div>
|
||||
<VideoTile sessionInfo={info} label={roverLabel} telemetryFrame={frame} batteryConfig={batteryConfig} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p>
|
||||
<p className="panel-muted">Assign a rover to view the video feed.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ const ACTIONS = [
|
||||
|
||||
function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
return (
|
||||
<label className="block rounded border border-white/5 p-1">
|
||||
<label className="surface block">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">{label}</span>
|
||||
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
||||
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="mt-1 w-full accent-emerald-400"
|
||||
className="mt-0.5 w-full accent-emerald-400"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -162,31 +162,27 @@ export default function GamepadMappingSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#1d232b] p-1 text-sm text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Gamepad mapping</p>
|
||||
<p className="text-xs text-slate-400">Gamepad mapping</p>
|
||||
<p className="text-[0.65rem] text-slate-500">
|
||||
{gamepadConnected
|
||||
? 'Move sticks/axes with a big sweep or press buttons when capturing.'
|
||||
: 'Connect a controller to configure.'}
|
||||
</p>
|
||||
{capture && (
|
||||
<p className="mt-1 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||
<p className="mt-0.5 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
|
||||
>
|
||||
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Sensitivity & feel</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-0.5 surface">
|
||||
<p className="text-[0.7rem] text-slate-500">Sensitivity & feel</p>
|
||||
<div className="space-y-0.5">
|
||||
<SliderField
|
||||
label="Drive deadzone"
|
||||
description="Ignore small drive stick movement"
|
||||
@@ -226,27 +222,23 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(grouped).map(([section, actions]) => (
|
||||
<div key={section} className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">{section}</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div key={section} className="space-y-0.5 surface">
|
||||
<p className="text-[0.7rem] text-slate-500">{section}</p>
|
||||
<div className="space-y-0.5">
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs">
|
||||
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClear(action)}
|
||||
className="rounded bg-slate-800 px-2 py-1 text-[0.65rem] uppercase tracking-wide"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapture(action)}
|
||||
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${capture?.id === action.id ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`}
|
||||
className={`${capture?.id === action.id ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium`}
|
||||
>
|
||||
{capture?.id === action.id ? 'Waiting…' : 'Capture'}
|
||||
</button>
|
||||
|
||||
@@ -18,11 +18,11 @@ export default function HelpPanel({ layout }) {
|
||||
};
|
||||
const tips = presets[layout] || presets.desktop;
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<p className="text-sm text-slate-400">Help</p>
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{tips.map((tip) => (
|
||||
<li key={tip} className="rounded-sm bg-black/30 px-1 py-1 text-slate-200">
|
||||
<li key={tip} className="surface text-slate-200">
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -71,51 +71,40 @@ export default function KeymapSettings() {
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Keyboard Layout</p>
|
||||
<p className="text-xs text-slate-400">Keyboard layout</p>
|
||||
<p className="text-[0.7rem] text-slate-500">Per-browser · click to change a binding</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetKeyBindings()}
|
||||
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
|
||||
>
|
||||
Reset Defaults
|
||||
<button type="button" onClick={() => resetKeyBindings()} className="button-dark text-xs">
|
||||
Reset defaults
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
{Object.entries(grouped).map(([group, actions]) => (
|
||||
<div key={group} className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-400">{group}</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div key={group} className="space-y-0.5 surface">
|
||||
<p className="text-[0.75rem] text-slate-400">{group}</p>
|
||||
<div className="space-y-0.5">
|
||||
{actions.map((action) => {
|
||||
const value = currentKey(action.id);
|
||||
const isActive = active === action.id;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs"
|
||||
>
|
||||
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{formatKeyLabel(value)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancel()}
|
||||
className="rounded bg-red-600 px-2 py-1 text-[0.65rem] uppercase tracking-wide text-red-100"
|
||||
>
|
||||
<button type="button" onClick={() => cancel()} className="button-danger text-[0.7rem]">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCapture(action.id)}
|
||||
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${isActive ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`}
|
||||
className={`${isActive ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium transition-colors`}
|
||||
>
|
||||
{isActive ? 'Press a key…' : 'Change'}
|
||||
</button>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useSession } from '../context/SessionContext.jsx';
|
||||
export default function LogPanel() {
|
||||
const { logs } = useSession();
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>Server logs</span>
|
||||
<span>{logs.length}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-64 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-xs">
|
||||
<div className="surface h-64 overflow-y-auto space-y-0.5 font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<p>No logs yet.</p>
|
||||
) : (
|
||||
@@ -16,7 +16,7 @@ export default function LogPanel() {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
<div key={entry.id} className="mb-1">
|
||||
<div key={entry.id} className="surface-muted">
|
||||
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||
|
||||
@@ -96,7 +96,7 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-lg border border-slate-800 bg-slate-900/70`}
|
||||
className={`relative w-full ${heightClass} select-none overflow-hidden bg-zinc-950`}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -114,11 +114,11 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
{visual.active && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-cyan-400/60 bg-cyan-400/10"
|
||||
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
|
||||
style={{ left: visual.base.x, top: visual.base.y }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-300/80 shadow-lg"
|
||||
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 bg-cyan-300/80 shadow-lg [clip-path:circle(50%)]"
|
||||
style={{
|
||||
left: visual.base.x + visual.knob.x,
|
||||
top: visual.base.y + visual.knob.y,
|
||||
@@ -195,7 +195,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-slate-100">
|
||||
<div className="flex flex-col gap-0.5 text-slate-100">
|
||||
<DriveModeToggle size="compact" />
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
@@ -204,17 +204,12 @@ function MobileJoystickPanel({ layout }) {
|
||||
onMove={handleMove}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAllMotion}
|
||||
disabled={disabled}
|
||||
className="rounded bg-red-600 px-2 py-2 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
|
||||
>
|
||||
<button type="button" onClick={stopAllMotion} disabled={disabled} className="button-danger w-full disabled:opacity-40">
|
||||
Panic Stop
|
||||
</button>
|
||||
{cameraEnabled && (
|
||||
<div className="rounded-lg bg-slate-900/70 p-2 text-xs">
|
||||
<div className="flex items-center justify-between text-[0.7rem] uppercase tracking-wide text-slate-400">
|
||||
<div className="bg-zinc-950 p-0.5 text-xs">
|
||||
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
|
||||
<span>Camera Tilt</span>
|
||||
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
|
||||
</div>
|
||||
@@ -225,7 +220,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
step={0.5}
|
||||
value={cameraValue}
|
||||
onChange={handleCameraSlider}
|
||||
className="mt-1 w-full accent-cyan-400"
|
||||
className="mt-0.5 w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -269,9 +264,9 @@ function AuxMotorPanel({ orientation }) {
|
||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-slate-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Aux controls</p>
|
||||
<div className={`grid ${gridCols} gap-1`}>
|
||||
<div className="flex flex-col gap-0.5 text-slate-100">
|
||||
<p className="text-xs text-slate-400">Aux controls</p>
|
||||
<div className={`grid ${gridCols} gap-0.5`}>
|
||||
{AUX_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.id}
|
||||
@@ -285,7 +280,7 @@ function AuxMotorPanel({ orientation }) {
|
||||
onPointerLeave={() => handleRelease(button)}
|
||||
onPointerCancel={() => handleRelease(button)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`rounded-lg px-2 py-2 text-left text-sm font-semibold uppercase tracking-wide text-white transition ${button.color} disabled:opacity-30`}
|
||||
className={`px-0.5 py-0.5 text-left text-sm font-semibold text-white transition ${button.color} hover:brightness-110 disabled:opacity-30`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
@@ -297,7 +292,7 @@ function AuxMotorPanel({ orientation }) {
|
||||
|
||||
export function MobileLandscapeAuxColumn() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<AuxMotorPanel orientation="landscape" />
|
||||
</div>
|
||||
);
|
||||
@@ -305,7 +300,7 @@ export function MobileLandscapeAuxColumn() {
|
||||
|
||||
export function MobileLandscapeControlColumn() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<MobileJoystickPanel layout="landscape" />
|
||||
</div>
|
||||
);
|
||||
@@ -313,8 +308,8 @@ export function MobileLandscapeControlColumn() {
|
||||
|
||||
export default function MobilePortraitControls() {
|
||||
return (
|
||||
<section className="w-full">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<section className="panel">
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<AuxMotorPanel orientation="portrait" />
|
||||
<MobileJoystickPanel layout="portrait" />
|
||||
</div>
|
||||
|
||||
@@ -33,13 +33,13 @@ export default function ModeGateOverlay() {
|
||||
const details = getModeDetails(mode);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-2 py-2">
|
||||
<div className="w-full max-w-md space-y-2 rounded-lg bg-[#141821] p-2 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-1">
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-0.5 py-0.5">
|
||||
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-black/40 p-1">
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
|
||||
@@ -8,16 +8,16 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||
|
||||
function RoomControlsPlaceholder() {
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Room controls</p>
|
||||
<p className="text-sm text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Room controls</p>
|
||||
<p className="text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout }) {
|
||||
return (
|
||||
<section className="rounded-sm bg-[#1a1d23] p-1 text-base text-slate-100">
|
||||
<section className="panel text-base">
|
||||
<Tabs defaultTab="telemetry">
|
||||
<TabList>
|
||||
<Tab id="telemetry">Rover Controls</Tab>
|
||||
@@ -27,14 +27,14 @@ export default function RightPaneTabs({ layout }) {
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="telemetry">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<DrivePanel />
|
||||
<CameraServoPanel />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="room">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<RoomCameraPanel />
|
||||
<RoomControlsPlaceholder />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export default function RoomCameraPanel() {
|
||||
return (
|
||||
<div className="min-h-[8rem] rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="panel-section min-h-[8rem] space-y-0.5 text-base">
|
||||
<p className="text-center text-sm text-slate-400">Room camera</p>
|
||||
<p className="mt-1 text-center">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
<p className="text-center text-slate-200">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ export default function SessionSnapshot() {
|
||||
const { session } = useSession();
|
||||
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-xs text-slate-100">
|
||||
<div className="panel-section space-y-0.5 text-xs">
|
||||
<p className="text-sm text-slate-400">Session snapshot</p>
|
||||
<pre className="mt-1 h-64 overflow-y-auto bg-black/40 p-1 text-[0.7rem] text-lime-300">{payload}</pre>
|
||||
<pre className="surface h-64 overflow-y-auto font-mono text-[0.7rem] text-lime-300">{payload}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,49 +42,49 @@ export default function SettingsPanel() {
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="keybindings">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<KeymapSettings />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="controller">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<GamepadMappingSettings />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="admin">
|
||||
<div className="space-y-1">
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Manual OI commands</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<div className="space-y-0.5">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Manual OI commands</p>
|
||||
<div className="surface flex flex-wrap gap-0.5">
|
||||
{manualTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => sendOiCommand(tab.key)}
|
||||
disabled={!canControl}
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-30"
|
||||
className="button-dark text-xs disabled:opacity-30"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Sensor stream</p>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Sensor stream</p>
|
||||
<div className="surface flex gap-0.5">
|
||||
{sensorButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
type="button"
|
||||
onClick={() => handleSensorToggle(btn.enable)}
|
||||
disabled={!canControl}
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
|
||||
className="flex-1 button-dark text-xs disabled:opacity-30"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!canControl && <p className="mt-1 text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
||||
{!canControl && <p className="text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
||||
</section>
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
|
||||
@@ -12,9 +12,9 @@ function useTabsContext() {
|
||||
|
||||
const TAB_VARIANTS = {
|
||||
primary: {
|
||||
base: 'flex-1 rounded-sm px-1 py-1 text-xs font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500',
|
||||
active: 'bg-slate-100 text-slate-900',
|
||||
inactive: 'bg-black/30 text-slate-300 hover:text-slate-100',
|
||||
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500',
|
||||
active: 'bg-sky-600 text-white',
|
||||
inactive: 'bg-zinc-800 text-slate-200 hover:bg-sky-500 hover:text-white',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
|
||||
}
|
||||
|
||||
export function TabList({ children, className = '' }) {
|
||||
return <div className={classNames('flex gap-1', className)}>{children}</div>;
|
||||
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Tab({ id, children, className = '', disabled = false }) {
|
||||
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
|
||||
}
|
||||
|
||||
export function TabPanels({ children, className = '' }) {
|
||||
return <div className={classNames('mt-1 space-y-1', className)}>{children}</div>;
|
||||
return <div className={classNames('mt-0.5 space-y-0.5', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabPanel({ id, children, keepMounted = false }) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function TelemetryPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
||||
<div className="text-sm text-slate-400">
|
||||
<span>{connected ? 'online' : 'offline'}</span>
|
||||
<span> · role {session?.role || 'unknown'}</span>
|
||||
@@ -56,12 +56,12 @@ export default function TelemetryPanel() {
|
||||
<span> · driver {driverLabel}</span>
|
||||
</div>
|
||||
{!roverId ? (
|
||||
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p>
|
||||
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
|
||||
) : !frame ? (
|
||||
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames…</p>
|
||||
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<div className="flex flex-wrap gap-0.5 text-sm">
|
||||
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
|
||||
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||
@@ -70,7 +70,7 @@ export default function TelemetryPanel() {
|
||||
<Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} />
|
||||
<Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} />
|
||||
</div>
|
||||
<div className="mt-1 flex gap-0.5">
|
||||
<div className="flex gap-0.5">
|
||||
<CliffBar value={sensors.cliffLeftSignal} />
|
||||
<CliffBar value={sensors.cliffFrontLeftSignal} />
|
||||
<CliffBar value={sensors.cliffFrontRightSignal} />
|
||||
@@ -80,24 +80,24 @@ export default function TelemetryPanel() {
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
{rawSnippet && (
|
||||
// console.log('rawSnippet:', rawSnippet),
|
||||
<pre className="mt-1 text-wrap break-words p-1 text-xs text-lime-300 bg-gray-700">
|
||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">
|
||||
{rawSnippet}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-400">Rovers</p>
|
||||
{roster.length === 0 ? (
|
||||
// if there ARE NO rovers
|
||||
<p className="text-sm text-slate-500">No roster data.</p>
|
||||
) : (
|
||||
// if there ARE rovers
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{roster.map((rover) => (
|
||||
<li key={rover.id} className="flex items-center justify-between gap-1">
|
||||
<li key={rover.id} className="surface flex items-center justify-between gap-0.5">
|
||||
<div>
|
||||
<p className="text-slate-200">{rover.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
@@ -109,7 +109,7 @@ export default function TelemetryPanel() {
|
||||
type="button"
|
||||
onClick={() => handleRequest(rover.id)}
|
||||
disabled={pending[rover.id]}
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-40"
|
||||
className="button-dark disabled:opacity-40"
|
||||
>
|
||||
{pending[rover.id] ? '...' : 'request'}
|
||||
</button>
|
||||
@@ -130,15 +130,15 @@ function CliffBar({ value }) {
|
||||
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100));
|
||||
const height = `${pct}%`;
|
||||
return (
|
||||
<div className="w-1/4 h-6 bg-gray-700 rounded-sm overflow-hidden">
|
||||
<div className="w-full bg-amber-400" style={{ height }} />
|
||||
<div className="surface-muted h-6 w-1/4">
|
||||
<div className="w-full bg-amber-500" style={{ height }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="bg-gray-700 rounded-sm p-0.5 text-sm">
|
||||
<div className="surface text-sm">
|
||||
{label}: {value}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,17 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const ensurePlayback = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
video.muted = true;
|
||||
await video.play();
|
||||
} catch {
|
||||
// Autoplay might still be blocked; retry logic elsewhere will handle it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const attemptUnmute = useCallback(
|
||||
(delay = 0) => {
|
||||
if (forceMute) return;
|
||||
@@ -52,6 +63,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
await ensurePlayback();
|
||||
video.muted = false;
|
||||
await video.play();
|
||||
setMuted(false);
|
||||
@@ -64,7 +76,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
|
||||
unmuteTimer.current = setTimeout(tryPlay, delay);
|
||||
},
|
||||
[forceMute],
|
||||
[ensurePlayback, forceMute],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -92,6 +104,9 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
if (!active) return;
|
||||
setStatus(nextStatus);
|
||||
setDetail(info || null);
|
||||
if (nextStatus === 'playing') {
|
||||
ensurePlayback();
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
scheduleRestart();
|
||||
}
|
||||
@@ -116,7 +131,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
clearTimeout(resetMuteId);
|
||||
player?.stop();
|
||||
};
|
||||
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]);
|
||||
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'stopped' && sessionInfo?.url) {
|
||||
@@ -133,8 +148,8 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative w-full overflow-hidden rounded-sm bg-black aspect-video">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative w-full overflow-hidden bg-black aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted={forceMute || muted}
|
||||
@@ -157,12 +172,11 @@ function BatteryBar({ charge, config, label, status }) {
|
||||
const urgent = config?.Urgent ?? null;
|
||||
if (charge == null || full == null || warn == null) {
|
||||
return (
|
||||
<div className="rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200">
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
{/* <span>{label}</span> */}
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">Battery telemetry unavailable</p>
|
||||
<p className="text-xs text-slate-500">Battery telemetry unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,13 +191,12 @@ function BatteryBar({ charge, config, label, status }) {
|
||||
const warnTriggered = urgent != null && charge <= urgent;
|
||||
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
|
||||
return (
|
||||
<div className="space-y-2 rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200">
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
{/* <span>{label}</span> */}
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
<div className="relative h-4 w-full rounded-full bg-slate-800">
|
||||
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
|
||||
<div className="relative h-4 w-full bg-zinc-900">
|
||||
<div className={`h-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
|
||||
Battery {percentText}
|
||||
</span>
|
||||
@@ -204,73 +217,25 @@ function HudOverlay({ frame, label }) {
|
||||
|
||||
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
|
||||
|
||||
// const bumperBadges = [
|
||||
// { label: 'B-L', active: bumps.bumpLeft },
|
||||
// { label: 'B-R', active: bumps.bumpRight },
|
||||
// ];
|
||||
// const wheelBadges = [
|
||||
// { label: 'Drop L', active: bumps.wheelDropLeft },
|
||||
// { label: 'Drop R', active: bumps.wheelDropRight },
|
||||
// ];
|
||||
// console.log('session', session);
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
{/* <div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
|
||||
<span className={`h-2 w-2 rounded-full ${pulse ? 'bg-emerald-300 shadow-[0_0_4px_rgba(16,185,129,0.8)]' : 'bg-slate-700'}`} />
|
||||
<span>sensor</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{bumperBadges.map((badge) => (
|
||||
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
{wheelBadges.map((badge) => (
|
||||
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
|
||||
))}
|
||||
</div> */}
|
||||
{/* status that tells you the name of your rover (label) */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 rounded-full bg-gray-900/40 px-2 py-0.5 text-[0.65rem] font-medium text-slate-200">
|
||||
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-xs text-slate-100">
|
||||
<span>You are driving {label || 'Unnamed Rover'}</span>
|
||||
<span>|</span>
|
||||
<span>{pulse ? 'Sensors Active' : 'No Recent Sensor Data'}</span>
|
||||
<span>{pulse ? 'Sensors active' : 'No recent sensors'}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* bump and wheel drops bar */}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex gap-1 rounded-full bg-gray-900/40 text-[0.55rem] font-medium">
|
||||
<div className={`px-0.5 rounded-full ${bumps.bumpLeft ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Left Bump
|
||||
</div>
|
||||
{/* left wheel drop */}
|
||||
<div className={`px-0.5 rounded-full ${bumps.wheelDropLeft ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Left Wheel Drop
|
||||
</div>
|
||||
{/* right wheel drop */}
|
||||
<div className={`px-0.5 rounded-full ${bumps.wheelDropRight ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Right Wheel Drop
|
||||
</div>
|
||||
{/* right bump */}
|
||||
<div className={`px-0.5 rounded-full ${bumps.bumpRight ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Right Bump
|
||||
</div>
|
||||
<div className="absolute top-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/70 px-0.5 py-0.5 text-[0.6rem] font-medium text-slate-200">
|
||||
<div className={`px-0.5 py-0.5 ${bumps.bumpLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left bump</div>
|
||||
<div className={`px-0.5 py-0.5 ${bumps.wheelDropLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left wheel drop</div>
|
||||
<div className={`px-0.5 py-0.5 ${bumps.wheelDropRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right wheel drop</div>
|
||||
<div className={`px-0.5 py-0.5 ${bumps.bumpRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right bump</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function HudBadge({ label, active }) {
|
||||
// return (
|
||||
// <span
|
||||
// className={`rounded-sm px-1 py-0.5 text-[0.55rem] ${active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'}`}
|
||||
// >
|
||||
// {label}
|
||||
// </span>
|
||||
// );
|
||||
// }
|
||||
|
||||
const OVERCURRENT_LABELS = {
|
||||
leftWheel: 'Left wheel',
|
||||
rightWheel: 'Right wheel',
|
||||
@@ -285,7 +250,7 @@ function OvercurrentOverlay({ motors }) {
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
|
||||
<div className="text-center text-2xl font-semibold text-white animate-pulse">
|
||||
<div>Overcurrent</div>
|
||||
<div className="mt-1 text-xl font-medium text-white">{labels.join(', ')}</div>
|
||||
<div className="mt-0.5 text-xl font-medium text-white">{labels.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,25 +37,21 @@ export default function DriveModeToggle({ size = 'default' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const pillClass =
|
||||
size === 'compact'
|
||||
? 'text-xs px-1 py-0.5'
|
||||
: 'text-sm px-1.5 py-0.5';
|
||||
const pillClass = size === 'compact' ? 'text-xs px-0.5 py-0.5' : 'text-sm px-0.5 py-0.5';
|
||||
|
||||
return (
|
||||
<div className="rounded-sm bg-black/30 p-1 text-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`rounded-full ${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}>
|
||||
<div className="bg-black/30 p-0.5 text-slate-100">
|
||||
<div className="flex items-center">
|
||||
<span className={`${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}>
|
||||
{currentLabel}
|
||||
</span>
|
||||
<span className="text-[0.65rem] text-slate-400">Rover {roverId ?? '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 text-xs">
|
||||
<div className="mt-0.5 grid grid-cols-2 gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDrive}
|
||||
disabled={disabled}
|
||||
className={`rounded-sm bg-emerald-600 px-1 py-1 font-semibold uppercase tracking-wide text-emerald-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
className={`bg-emerald-600 px-0.5 py-0.5 font-semibold text-emerald-50 transition-colors hover:bg-emerald-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
>
|
||||
Drive
|
||||
</button>
|
||||
@@ -63,7 +59,7 @@ export default function DriveModeToggle({ size = 'default' }) {
|
||||
type="button"
|
||||
onClick={handleDock}
|
||||
disabled={disabled}
|
||||
className={`rounded-sm bg-indigo-600 px-1 py-1 font-semibold uppercase tracking-wide text-indigo-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
className={`bg-indigo-600 px-0.5 py-0.5 font-semibold text-indigo-50 transition-colors hover:bg-indigo-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
>
|
||||
Dock
|
||||
</button>
|
||||
|
||||
+36
-3
@@ -5,12 +5,45 @@
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
sans-serif;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
@apply bg-black text-slate-100;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #0f172a;
|
||||
@apply bg-black text-slate-100;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply bg-zinc-800 text-slate-100 p-0.5;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply bg-zinc-950 text-slate-100 p-0.5;
|
||||
}
|
||||
|
||||
.panel-muted {
|
||||
@apply text-sm text-slate-400;
|
||||
}
|
||||
|
||||
.surface {
|
||||
@apply bg-zinc-900/80 text-slate-100 border border-zinc-800 px-0.5 py-0.5;
|
||||
}
|
||||
|
||||
.surface-muted {
|
||||
@apply bg-zinc-950/80 text-slate-200 border border-zinc-900 px-0.5 py-0.5;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
@apply bg-zinc-900/80 text-slate-100 border border-zinc-800 px-0.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-slate-500;
|
||||
}
|
||||
|
||||
.button-dark {
|
||||
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-sky-600 hover:bg-sky-500;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export class WhepPlayer {
|
||||
if (!this.video) return;
|
||||
this.video.playsInline = true;
|
||||
this.video.autoplay = true;
|
||||
this.video.muted = true;
|
||||
this.video.disableRemotePlayback = true;
|
||||
if ('latencyHint' in HTMLMediaElement.prototype) {
|
||||
this.video.latencyHint = 'interactive';
|
||||
|
||||
@@ -7,28 +7,26 @@ import VideoTile from '../components/VideoTile.jsx';
|
||||
|
||||
function SpectatorStatus({ connected, ready, rosterCount }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className={`rounded-full px-3 py-1 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 text-sm">
|
||||
<span className={`px-0.5 py-0.5 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span className={`rounded-full px-3 py-1 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
|
||||
<span className={`px-0.5 py-0.5 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
|
||||
Spectator mode {ready ? 'active' : 'pending…'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800 px-3 py-1 text-slate-200">
|
||||
Rovers: {rosterCount}
|
||||
</span>
|
||||
<span className="bg-slate-800 px-0.5 py-0.5 text-slate-200">Rovers: {rosterCount}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogStream({ logs }) {
|
||||
return (
|
||||
<section className="rounded-3xl border border-slate-800 bg-slate-900/70 p-4">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<section className="panel space-y-0.5 text-sm">
|
||||
<header className="mb-0.5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Server logs</h2>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-slate-500">latest {logs.length}</span>
|
||||
<span className="text-xs text-slate-500">latest {logs.length}</span>
|
||||
</header>
|
||||
<div className="h-60 overflow-y-auto rounded-2xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
|
||||
<div className="surface h-60 overflow-y-auto space-y-0.5 text-xs font-mono text-slate-300">
|
||||
{logs.length === 0 ? (
|
||||
<p>No log entries yet.</p>
|
||||
) : (
|
||||
@@ -36,7 +34,7 @@ function LogStream({ logs }) {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
<div key={entry.id} className="mb-2">
|
||||
<div key={entry.id} className="surface-muted">
|
||||
<span className="text-slate-500">{entry.timestamp}</span>{' '}
|
||||
<span className="text-cyan-300">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
|
||||
@@ -59,22 +57,22 @@ function formatSensors(sensors = {}) {
|
||||
function RoverSpectatorCard({ rover, frame, videoInfo }) {
|
||||
const sensorList = formatSensors(frame?.sensors);
|
||||
return (
|
||||
<article className="space-y-4 rounded-3xl border border-slate-800 bg-slate-900/70 p-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<article className="space-y-0.5 bg-zinc-900 p-0.5">
|
||||
<header className="flex flex-col gap-0.5">
|
||||
<h3 className="text-2xl font-semibold text-white">{rover.name}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
|
||||
</p>
|
||||
</header>
|
||||
<VideoTile sessionInfo={videoInfo} label={rover.name} forceMute />
|
||||
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
|
||||
<section className="surface">
|
||||
<p className="text-xs text-slate-500">Telemetry</p>
|
||||
{sensorList.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-400">No sensor data yet.</p>
|
||||
<p className="mt-0.5 text-sm text-slate-400">No sensor data yet.</p>
|
||||
) : (
|
||||
<dl className="mt-3 grid gap-2 text-sm text-slate-200">
|
||||
<dl className="mt-0.5 grid gap-0.5 text-sm text-slate-200">
|
||||
{sensorList.map(({ key, value }) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<div key={key} className="flex justify-between gap-0.5">
|
||||
<dt className="text-slate-500">{key}</dt>
|
||||
<dd className="text-right font-semibold text-white">{value}</dd>
|
||||
</div>
|
||||
@@ -99,14 +97,14 @@ export default function SpectatorApp() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-50">
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-10">
|
||||
<header className="space-y-3">
|
||||
<div className="min-h-screen bg-black text-slate-100">
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-0.5 px-0.5 py-0.5">
|
||||
<header className="space-y-0.5">
|
||||
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
|
||||
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
|
||||
<SpectatorStatus {...status} />
|
||||
</header>
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
<section className="grid gap-0.5 lg:grid-cols-3">
|
||||
{roster.length === 0 ? (
|
||||
<p className="col-span-full text-slate-400">No rovers registered.</p>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user