mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
more organized backend and better mobile control columns
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# general idea:
|
||||
- consolidate each rover control button to it's own component which is used standalone
|
||||
- rework mobile controls (throw away the current left column)
|
||||
- check for duplicates of any control components in redundant places
|
||||
- main culprits
|
||||
- dock drive action
|
||||
- mobile control columns
|
||||
- night vision (headlight) button
|
||||
- horn button
|
||||
- camera tilt slider
|
||||
- also plan to rework mobile controls, but just ensure consolidation first
|
||||
|
||||
## dock / drive action:
|
||||
- ensure that it is one standalone component
|
||||
- revamp the system which allows it to hide / show other componenets that it "covers"
|
||||
|
||||
## camera tilt slider:
|
||||
- the biggest issue currently
|
||||
- it seems to be the root of the problems of things not being consolidated
|
||||
- ensure that it is ONLY the camera slider component
|
||||
- keep styling
|
||||
- add option for a vertical slider with just a degrees number at the top
|
||||
|
||||
## night vision (headlight) button
|
||||
- center label, make it large and match the labels of the horn and drive/dock action labels
|
||||
- center status badge
|
||||
- allow height class to be changed for different modes
|
||||
|
||||
## horn button
|
||||
- the newest one
|
||||
- mostly good
|
||||
- for mobile layouts, make it hide the settings by default, and when shown make them nicely laid out for fitting in a skinny column on mobile
|
||||
|
||||
|
||||
# new mobile control columns:
|
||||
- get rid of the current entire left column with all the aux buttons, we are replacing it
|
||||
- the new left column:
|
||||
- top section
|
||||
- vertical camera slider
|
||||
- night vision button
|
||||
- middle section (two new buttons, side by side)
|
||||
- all aux forward button
|
||||
- all aux backward button
|
||||
- bottom section
|
||||
- horn button
|
||||
- the modified right column:
|
||||
- no more camera slider
|
||||
- no more night vision toggle
|
||||
- still dock/drive action full-column, which minimizes itself
|
||||
- when minimized, make the dock button a little taller
|
||||
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-gGNnvKWF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bi3rvCda.css">
|
||||
<script type="module" crossorigin src="/assets/index-DzclZiZg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-3N52jGFe.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
AttachmentBuilder,
|
||||
PermissionsBitField,
|
||||
WebhookClient,
|
||||
MessageFlags,
|
||||
} = require('discord.js');
|
||||
const logger = require('../globals/logger').child('discordBot');
|
||||
const io = require('../globals/io');
|
||||
@@ -884,7 +885,7 @@ async function sendTypingMessage(entry, payload) {
|
||||
const username = formatWebhookUsername(payload);
|
||||
const content = `-# *${username} is typing...*`;
|
||||
try {
|
||||
const message = await channel.send({ content, allowedMentions: { parse: [] } });
|
||||
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearTypingMessage(entry.guildId, typingId);
|
||||
}, 20000);
|
||||
|
||||
+5
-4
@@ -3,8 +3,8 @@ import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||
import AlertFeed from './components/AlertFeed.jsx';
|
||||
import MobileControls, {
|
||||
MobileLandscapeAuxColumn,
|
||||
MobileLandscapeControlColumn,
|
||||
MobileLeftColumn,
|
||||
MobileRightColumn,
|
||||
} from './components/MobileControls.jsx';
|
||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
||||
import { SettingsProvider } from './settings/index.js';
|
||||
@@ -139,10 +139,11 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
|
||||
}
|
||||
|
||||
function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||
const columnClass = 'self-start h-[min(100svh,32rem)]';
|
||||
return (
|
||||
<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 />
|
||||
<MobileLeftColumn layout="landscape" className={columnClass} />
|
||||
<div>
|
||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
@@ -151,7 +152,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||
</div>
|
||||
{/* <TelemetryPanel /> */}
|
||||
</div>
|
||||
<MobileLandscapeControlColumn />
|
||||
<MobileRightColumn layout="landscape" className={columnClass} />
|
||||
</section>
|
||||
<div className="flex flex-col gap-0.5 pb-0">
|
||||
<MobileFeatureTabs
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
const SLIDER_THROTTLE_MS = 150;
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
export default function CameraServoPanel() {
|
||||
const {
|
||||
state: { roverId, camera, keymap, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision, startHorn, stopHorn },
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
@@ -36,60 +31,6 @@ export default function CameraServoPanel() {
|
||||
|
||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
||||
|
||||
const [pendingAngle, setPendingAngle] = useState(value);
|
||||
const throttleRef = useRef(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPendingAngle(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const step =
|
||||
typeof config?.nudgeDegrees === 'number' && config.nudgeDegrees > 0
|
||||
? config.nudgeDegrees
|
||||
: 1;
|
||||
|
||||
const scheduleSend = (next) => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
throttleRef.current = setTimeout(() => {
|
||||
setServoAngle(next);
|
||||
}, SLIDER_THROTTLE_MS);
|
||||
};
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPendingAngle(next);
|
||||
scheduleSend(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
setServoAngle(pendingAngle);
|
||||
};
|
||||
|
||||
const handleNudge = (direction) => {
|
||||
const delta = step * direction;
|
||||
nudgeServo(delta);
|
||||
};
|
||||
|
||||
const handleNightVisionToggle = (nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
setNightVision(nextOn);
|
||||
@@ -116,30 +57,21 @@ export default function CameraServoPanel() {
|
||||
/>
|
||||
)}
|
||||
{enabled && (
|
||||
<>
|
||||
<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>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
value={pendingAngle}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-0 flex justify-between text-xs text-slate-400">
|
||||
<span>{formatDegrees(min)}</span>
|
||||
<span>{formatDegrees(max)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
onChange={setServoAngle}
|
||||
throttleMs={SLIDER_THROTTLE_MS}
|
||||
className="space-y-0.5"
|
||||
labelRowClass="text-sm text-slate-300"
|
||||
valueClass="font-mono text-sm text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-xs text-slate-400"
|
||||
endpointLabelClass=""
|
||||
/>
|
||||
)}
|
||||
{/* <div className="flex gap-0.5 text-sm">
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
export default function CameraTiltControl({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 0.5,
|
||||
label = 'Camera Tilt',
|
||||
orientation = 'horizontal',
|
||||
onChange,
|
||||
onCommit,
|
||||
throttleMs = 0,
|
||||
disabled = false,
|
||||
keyDownLabel,
|
||||
keyUpLabel,
|
||||
className = '',
|
||||
labelRowClass = '',
|
||||
labelClass = '',
|
||||
valueClass = '',
|
||||
sliderClass = '',
|
||||
endpointClass = '',
|
||||
endpointLabelClass = '',
|
||||
accentClass = '',
|
||||
showValue = true,
|
||||
showEndpoints,
|
||||
}) {
|
||||
const isVertical = orientation === 'vertical';
|
||||
const defaultShowEndpoints = !isVertical;
|
||||
const shouldShowEndpoints = typeof showEndpoints === 'boolean' ? showEndpoints : defaultShowEndpoints;
|
||||
const [pending, setPending] = useState(value);
|
||||
const draggingRef = useRef(false);
|
||||
const throttleRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPending(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleSend = (next) => {
|
||||
if (!onChange) return;
|
||||
if (throttleMs > 0) {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
throttleRef.current = setTimeout(() => {
|
||||
onChange(next);
|
||||
}, throttleMs);
|
||||
return;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPending(next);
|
||||
scheduleSend(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
throttleRef.current = null;
|
||||
}
|
||||
if (onCommit) {
|
||||
onCommit(pending);
|
||||
} else if (throttleMs > 0) {
|
||||
onChange?.(pending);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof min !== 'number' || typeof max !== 'number') return null;
|
||||
|
||||
const mergedSliderClass = [
|
||||
isVertical ? 'h-28 w-5' : 'w-full',
|
||||
accentClass,
|
||||
sliderClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const slider = (
|
||||
<input
|
||||
type="range"
|
||||
orient={isVertical ? 'vertical' : undefined}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={Number.isFinite(pending) ? pending : min}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
disabled={disabled}
|
||||
className={mergedSliderClass}
|
||||
style={
|
||||
isVertical
|
||||
? { writingMode: 'bt-lr', WebkitAppearance: 'slider-vertical' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isVertical) {
|
||||
return (
|
||||
<div className={`flex h-full flex-col items-center gap-0.5 ${className}`.trim()}>
|
||||
{showValue ? (
|
||||
<div className={`flex flex-col items-center ${labelRowClass}`.trim()}>
|
||||
<span className={valueClass}>{formatDegrees(value)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 items-center gap-0.5">
|
||||
<span className={labelClass}>{label}</span>
|
||||
{slider}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-0.5 ${className}`.trim()}>
|
||||
<div className={`flex items-center justify-between ${labelRowClass}`.trim()}>
|
||||
<span className={labelClass}>{label}</span>
|
||||
{showValue ? <span className={valueClass}>{formatDegrees(value)}</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
{slider}
|
||||
{shouldShowEndpoints ? (
|
||||
<div className={`mt-0 flex items-center justify-between ${endpointClass}`.trim()}>
|
||||
<span className={`flex items-center gap-0.5 ${endpointLabelClass}`.trim()}>
|
||||
{keyDownLabel ? <KeyPill label={keyDownLabel} /> : null}
|
||||
{formatDegrees(min)}
|
||||
</span>
|
||||
<span className={`flex items-center gap-0.5 ${endpointLabelClass}`.trim()}>
|
||||
{formatDegrees(max)}
|
||||
{keyUpLabel ? <KeyPill label={keyUpLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
@@ -8,6 +8,7 @@ import RoverRoster from './RoverRoster.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
const { session, requestControl } = useSession();
|
||||
@@ -49,39 +50,17 @@ export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
|
||||
export default function ControlSummary() {
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
state: { roverId, keymap, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
return (
|
||||
<section className="panel-section">
|
||||
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||
<div className="flex h-full w-full items-stretch justify-center">
|
||||
<div className="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineCameraTilt({ keymap }) {
|
||||
const {
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
@@ -94,95 +73,62 @@ export function InlineCameraTilt({ keymap }) {
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
|
||||
const [pendingAngle, setPendingAngle] = useState(value);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPendingAngle(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPendingAngle(next);
|
||||
setServoAngle(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{enabled && (
|
||||
<div className="space-y-0.5 px-1 py-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span>Camera tilt</span>
|
||||
<span className="font-mono text-slate-100">{formatDegrees(value)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
value={pendingAngle}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
{downLabel ? <KeyPill label={downLabel} /> : null}
|
||||
{formatDegrees(min)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{formatDegrees(max)}
|
||||
{upLabel ? <KeyPill label={upLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
<section className="panel-section">
|
||||
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||
<div className="flex h-full w-full items-stretch justify-center">
|
||||
<div className="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={setServoAngle}
|
||||
keyDownLabel={downLabel}
|
||||
keyUpLabel={upLabel}
|
||||
className="space-y-0.5 px-1 py-1"
|
||||
labelRowClass="text-xs text-slate-300"
|
||||
labelClass=""
|
||||
valueClass="font-mono text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-[0.7rem] text-slate-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,13 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriveDockAction({ layout = 'desktop', expand = false, fill = false, driveDockState }) {
|
||||
export default function DriveDockAction({
|
||||
layout = 'desktop',
|
||||
expand = false,
|
||||
fill = false,
|
||||
driveDockState,
|
||||
compactHeightClass = '',
|
||||
}) {
|
||||
const isMobile = layout === 'mobile';
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
@@ -175,9 +181,10 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
};
|
||||
|
||||
const baseCardClasses =
|
||||
'flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60';
|
||||
'flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
|
||||
const ctaText = 'text-center';
|
||||
const ctaLayout = 'items-center justify-between';
|
||||
const compactLayout = 'items-center justify-center';
|
||||
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
||||
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
||||
const emeraldCta =
|
||||
@@ -187,32 +194,41 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
const indigoCta =
|
||||
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
||||
const filledHeight = expand ? 'h-full flex-1' : fill ? 'flex-1' : '';
|
||||
const compactHeight = isMobile && !expand ? compactHeightClass : '';
|
||||
const layoutClass = isMobile && !expand ? compactLayout : ctaLayout;
|
||||
|
||||
if (!driving && !dockingInProgress) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartDrive}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
disabled={driveDisabled}
|
||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
|
||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${emeraldCta}`}
|
||||
>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-emerald-50 md:text-lg">Start Driving</span>
|
||||
{!isMobile ? (
|
||||
{!isMobile && expand ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
{driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
||||
<ActionPill label="Click to start" tone="emerald" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
|
||||
<StepList steps={startDriveInstructions.steps} tone="emerald" />
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
{expand ? (
|
||||
<>
|
||||
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
|
||||
<StepList steps={startDriveInstructions.steps} tone="emerald" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{expand ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -228,24 +244,29 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
type="button"
|
||||
disabled={driveDisabled}
|
||||
onClick={handleReturnToDrive}
|
||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
||||
>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-amber-50 md:text-lg">Docking in Progress</span>
|
||||
{!isMobile ? (
|
||||
{!isMobile && expand ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
<ActionPill label="Click to return to driving mode" tone="amber" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-amber-50/90">{inProgressCopy.summary}</p>
|
||||
{expand ? <p className="text-sm text-amber-50/90">{inProgressCopy.summary}</p> : null}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
<StepList steps={inProgressCopy.steps} tone="amber" />
|
||||
{expand ? (
|
||||
<>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
<StepList steps={inProgressCopy.steps} tone="amber" />
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -256,22 +277,23 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
type="button"
|
||||
disabled={dockDisabled}
|
||||
onClick={handleOpenDock}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={
|
||||
isMobile
|
||||
? `flex w-full items-center justify-center ${baseCardClasses} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
|
||||
: `${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
|
||||
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${indigoCta}`
|
||||
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${indigoCta}`
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-indigo-50 md:text-lg">Dock and Charge</span>
|
||||
{!isMobile ? (
|
||||
{!isMobile && expand ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
||||
<ActionPill label="Click to begin docking" tone="indigo" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
{!isMobile && expand && (
|
||||
<>
|
||||
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||
|
||||
export default function DrivePanel() {
|
||||
const {
|
||||
state: { roverId },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
|
||||
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||
const charging = Boolean(
|
||||
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
|
||||
);
|
||||
|
||||
const handleStartDrive = () => {
|
||||
if (!roverId) return;
|
||||
actions.setMode('drive');
|
||||
actions.runMacro('drive-sequence');
|
||||
};
|
||||
|
||||
const handleDock = () => {
|
||||
if (!roverId) return;
|
||||
actions.setMode('dock');
|
||||
actions.runMacro('seek-dock');
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ? 'Ready to drive' : 'No rover assigned'}</span>
|
||||
</div> */}
|
||||
<div className="space-y-0.5">
|
||||
<ActionCard
|
||||
title="Startfdsafsdfakjfdhlkfshlkjss Driving"
|
||||
description="Press to enable driving mode, then start moving."
|
||||
statuses={[{ label: drivingMode ? 'Ready!' : 'Press to enter driving mode!', active: drivingMode }]}
|
||||
tone="emerald"
|
||||
onClick={handleStartDrive}
|
||||
disabled={!roverId}
|
||||
/>
|
||||
<ActionCard
|
||||
title="Dock and Charge"
|
||||
description="Line the rover up about a foot from the dock, then press to trigger a docking attempt."
|
||||
statuses={[
|
||||
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
|
||||
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
|
||||
]}
|
||||
tone="indigo"
|
||||
onClick={handleDock}
|
||||
disabled={!roverId}
|
||||
/>
|
||||
</div>
|
||||
{/* <DiscordInviteButton /> */}
|
||||
{/* kjfhdljhdsaflkjf */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
|
||||
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 ${colors.base} ${colors.hover} px-0.5 py-0.5 text-left text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-center content-center`}
|
||||
>
|
||||
<p className="text-base font-semibold">{title}</p>
|
||||
<p className="text-sm text-white/90">{description}</p>
|
||||
{/* center statuses in button */}
|
||||
<div className="mt-0 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status.label}
|
||||
className={`p-1 text-xs font-semibold ${
|
||||
status.active ? 'bg-green-600 text-white' : 'bg-red-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{footnote && <p className="mt-0 text-xs text-emerald-50/80">{footnote}</p>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,12 @@ export default function HornControl({
|
||||
active,
|
||||
heat = 0,
|
||||
className = '',
|
||||
defaultShowSettings = true,
|
||||
showSettingsToggle = false,
|
||||
compactSettings = false,
|
||||
}) {
|
||||
const [pressed, setPressed] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(defaultShowSettings);
|
||||
const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace(
|
||||
'horn',
|
||||
HORN_SETTINGS_DEFAULTS,
|
||||
@@ -40,7 +44,7 @@ export default function HornControl({
|
||||
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
|
||||
const buttonClasses = useMemo(() => {
|
||||
const base =
|
||||
'group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold';
|
||||
'group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select';
|
||||
const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50';
|
||||
const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
|
||||
return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
|
||||
@@ -95,18 +99,31 @@ export default function HornControl({
|
||||
const handlePointerDown = (event) => {
|
||||
if (disabled) return;
|
||||
const tag = event.target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label') return;
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return;
|
||||
event.preventDefault();
|
||||
start();
|
||||
};
|
||||
|
||||
const handlePointerUp = (event) => {
|
||||
const tag = event.target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label') return;
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return;
|
||||
event.preventDefault();
|
||||
stop();
|
||||
};
|
||||
|
||||
const settingsToggle = showSettingsToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setShowSettings((prev) => !prev);
|
||||
}}
|
||||
className="rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
|
||||
>
|
||||
{showSettings ? 'Hide' : 'Settings'}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -116,6 +133,7 @@ export default function HornControl({
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={stop}
|
||||
onPointerCancel={stop}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onBlur={stop}
|
||||
className={buttonClasses}
|
||||
>
|
||||
@@ -144,6 +162,7 @@ export default function HornControl({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{settingsToggle}
|
||||
<span
|
||||
className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${
|
||||
isActive ? 'bg-fuchsia-200 text-fuchsia-900' : 'bg-slate-800 text-slate-200'
|
||||
@@ -152,33 +171,67 @@ export default function HornControl({
|
||||
{isActive ? 'Honk' : 'Hold'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
{freqs.map((freq, idx) => (
|
||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={1}
|
||||
value={freq}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{showSettings ? (
|
||||
compactSettings ? (
|
||||
<div className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
{freqs.map((freq, idx) => (
|
||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={1}
|
||||
value={freq}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
{freqs.map((freq, idx) => (
|
||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={1}
|
||||
value={freq}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,20 +4,14 @@ import { clampUnit } from '../controls/controlMath.js';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
const SOURCE = 'mobile-joystick';
|
||||
const JOYSTICK_RADIUS = 80;
|
||||
const JOYSTICK_SMOOTHING = 0.15;
|
||||
const AUX_BUTTONS = [
|
||||
{ id: 'main-forward', label: 'Main Brush +', values: { main: 127 }, hold: true, color: 'bg-emerald-600' },
|
||||
{ id: 'main-reverse', label: 'Main Brush -', values: { main: -127 }, hold: true, color: 'bg-emerald-800' },
|
||||
{ id: 'all-forward', label: 'All Motors +', values: { main: 127, side: 127, vacuum: 127 }, hold: true, color: 'bg-fuchsia-600' },
|
||||
{ id: 'side-forward', label: 'Side Brush +', values: { side: 127 }, hold: true, color: 'bg-cyan-600' },
|
||||
{ id: 'side-reverse', label: 'Side Brush -', values: { side: -70 }, hold: true, color: 'bg-cyan-800' },
|
||||
{ id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true, color: 'bg-amber-500 text-amber-950' },
|
||||
{ id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true, color: 'bg-amber-700' },
|
||||
{ id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false, color: 'bg-slate-900' },
|
||||
];
|
||||
const AUX_ZERO = { main: 0, side: 0, vacuum: 0 };
|
||||
const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 };
|
||||
const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 };
|
||||
|
||||
function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
const containerRef = useRef(null);
|
||||
@@ -134,27 +128,12 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
|
||||
function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
state: { roverId },
|
||||
actions: { setDriveVector, registerInputState },
|
||||
} = useControlSystem();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||
const disabled = !roverId;
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
||||
const cameraValue =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
const joystickRadius = JOYSTICK_RADIUS;
|
||||
const smoothing = JOYSTICK_SMOOTHING;
|
||||
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||
@@ -196,62 +175,20 @@ function MobileJoystickPanel({ layout }) {
|
||||
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
|
||||
}, [disabled, registerInputState, setDriveVector]);
|
||||
|
||||
const handleCameraSlider = (event) => {
|
||||
if (!cameraEnabled) return;
|
||||
const next = Number(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
setServoAngle(next);
|
||||
};
|
||||
|
||||
const handleNightVisionToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
setNightVision(nextOn);
|
||||
},
|
||||
[nightVisionAvailable, setNightVision],
|
||||
);
|
||||
|
||||
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
|
||||
<DriveDockAction
|
||||
layout="mobile"
|
||||
expand={dockedNotDriving}
|
||||
driveDockState={driveDockState}
|
||||
compactHeightClass="min-h-[5rem]"
|
||||
/>
|
||||
{!dockedNotDriving ? (
|
||||
<>
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={disabled}
|
||||
onToggle={handleNightVisionToggle}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={disabled || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<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>
|
||||
<input
|
||||
type="range"
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={0.5}
|
||||
value={cameraValue}
|
||||
onChange={handleCameraSlider}
|
||||
className="mt-0 w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
layout={layout}
|
||||
@@ -267,90 +204,176 @@ function MobileJoystickPanel({ layout }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AuxMotorPanel({ orientation }) {
|
||||
function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
onPress(id, values);
|
||||
}}
|
||||
onPointerUp={() => onRelease(id)}
|
||||
onPointerLeave={() => onRelease(id)}
|
||||
onPointerCancel={() => onRelease(id)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLeftColumnContent({ layout }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
actions: { setAuxMotors },
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const activeRef = useRef(null);
|
||||
const disabled = !roverId;
|
||||
const activeRef = useRef(null);
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
||||
const cameraValue =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
|
||||
const handlePress = useCallback(
|
||||
(button) => {
|
||||
const handleNightVisionToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
setNightVision(nextOn);
|
||||
},
|
||||
[nightVisionAvailable, setNightVision],
|
||||
);
|
||||
|
||||
const handleAuxPress = useCallback(
|
||||
(id, values) => {
|
||||
if (disabled) return;
|
||||
if (button.hold === false) {
|
||||
setAuxMotors(button.values);
|
||||
activeRef.current = null;
|
||||
return;
|
||||
}
|
||||
activeRef.current = button.id;
|
||||
setAuxMotors(button.values);
|
||||
activeRef.current = id;
|
||||
setAuxMotors(values);
|
||||
},
|
||||
[disabled, setAuxMotors],
|
||||
);
|
||||
|
||||
const handleRelease = useCallback(
|
||||
(button) => {
|
||||
if (disabled || button.hold === false) return;
|
||||
if (activeRef.current === button.id) {
|
||||
const handleAuxRelease = useCallback(
|
||||
(id) => {
|
||||
if (disabled) return;
|
||||
if (activeRef.current === id) {
|
||||
activeRef.current = null;
|
||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
||||
setAuxMotors(AUX_ZERO);
|
||||
}
|
||||
},
|
||||
[disabled, setAuxMotors],
|
||||
);
|
||||
|
||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
return (
|
||||
<div className="flex h-full 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}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
handlePress(button);
|
||||
}}
|
||||
onPointerUp={() => handleRelease(button)}
|
||||
onPointerLeave={() => handleRelease(button)}
|
||||
onPointerCancel={() => handleRelease(button)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`px-0.5 py-0.5 text-left text-sm font-semibold text-white transition select-none no-touch-select ${button.color} hover:brightness-110 disabled:opacity-30 h-10`}
|
||||
<div className="grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
|
||||
<div className="flex min-h-0 items-stretch gap-0.5">
|
||||
{cameraEnabled ? (
|
||||
<div
|
||||
className={`flex-1 min-h-0 rounded bg-zinc-950 p-0.25 ${
|
||||
layout === 'landscape' ? 'pt-3' : ''
|
||||
}`.trim()}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
<CameraTiltControl
|
||||
value={cameraValue}
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={0.5}
|
||||
onChange={setServoAngle}
|
||||
orientation="vertical"
|
||||
label="Camera Tilt"
|
||||
labelClass="text-sm font-semibold text-white [writing-mode:vertical-rl] rotate-180"
|
||||
labelRowClass="text-[0.7rem] text-slate-300"
|
||||
valueClass="font-mono text-slate-200"
|
||||
className="h-full gap-0"
|
||||
sliderClass="h-full w-7"
|
||||
accentClass="accent-cyan-400"
|
||||
showEndpoints={false}
|
||||
showValue={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{nightVisionAvailable ? (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={disabled}
|
||||
onToggle={handleNightVisionToggle}
|
||||
heightClass="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-rows-2 gap-0.5">
|
||||
<MobileAuxButton
|
||||
id="aux-vac-forward"
|
||||
label="Vacuum Forward"
|
||||
values={AUX_ALL_FORWARD}
|
||||
color="bg-fuchsia-600"
|
||||
disabled={disabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
<MobileAuxButton
|
||||
id="aux-vac-backward"
|
||||
label="Vacuum Backward"
|
||||
values={AUX_ALL_BACKWARD}
|
||||
color="bg-fuchsia-800"
|
||||
disabled={disabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0">
|
||||
{hornAvailable ? (
|
||||
<HornControl
|
||||
disabled={disabled || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
defaultShowSettings={false}
|
||||
showSettingsToggle
|
||||
compactSettings
|
||||
className="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLandscapeAuxColumn() {
|
||||
export function MobileLeftColumn({ layout, className = '' }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<AuxMotorPanel orientation="landscape" />
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||
<MobileLeftColumnContent layout={layout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLandscapeControlColumn() {
|
||||
export function MobileRightColumn({ layout, className = '' }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<MobileJoystickPanel layout="landscape" />
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||
<MobileJoystickPanel layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MobilePortraitControls() {
|
||||
const columnHeight = 'h-[min(60svh,24rem)]';
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="grid grid-cols-2 gap-0.5 items-stretch">
|
||||
<AuxMotorPanel orientation="portrait" />
|
||||
<MobileJoystickPanel layout="portrait" />
|
||||
<MobileLeftColumn layout="portrait" className={columnHeight} />
|
||||
<MobileRightColumn layout="portrait" className={columnHeight} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function NightVisionControl({
|
||||
onToggle,
|
||||
keyLabel,
|
||||
className = '',
|
||||
heightClass = '',
|
||||
}) {
|
||||
const [optimistic, setOptimistic] = useState(
|
||||
isBoolean(nightVisionOn) ? nightVisionOn : null,
|
||||
@@ -37,31 +38,32 @@ export default function NightVisionControl({
|
||||
|
||||
const buttonClasses = useMemo(() => {
|
||||
const base =
|
||||
'group flex w-full items-center justify-between rounded-xl border-2 px-1 py-0.75 text-xs font-semibold';
|
||||
'group flex w-full flex-col items-center justify-center gap-0.35 rounded-xl border-2 px-1 py-0.75 text-center select-none no-touch-select';
|
||||
const active = 'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700';
|
||||
const inactive = 'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800';
|
||||
return [base, displayOn ? active : inactive, 'disabled:opacity-50', className]
|
||||
return [base, displayOn ? active : inactive, 'disabled:opacity-50', heightClass, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}, [className, displayOn]);
|
||||
}, [className, displayOn, heightClass]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
disabled={disabled}
|
||||
aria-pressed={displayOn}
|
||||
className={buttonClasses}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>Night Vision</span>
|
||||
<span className="text-sm font-semibold">Night Vision</span>
|
||||
{keyLabel ? (
|
||||
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
||||
{keyLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${statusClasses}`}>
|
||||
<span className={`rounded px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InlineCameraTilt } from './ControlSummary.jsx';
|
||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||
import SettingsPanel from './SettingsPanel.jsx';
|
||||
@@ -13,6 +12,10 @@ import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
||||
import RawUserPilePanel from './RawUserPilePanel.jsx';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
@@ -32,15 +35,74 @@ function TopDownMapPanel() {
|
||||
|
||||
function DriveDockPanel() {
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
state: { roverId, keymap, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
const config = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
|
||||
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
|
||||
const value =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
|
||||
return (
|
||||
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||
{!hideInlineControls ? (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={setServoAngle}
|
||||
keyDownLabel={downLabel}
|
||||
keyUpLabel={upLabel}
|
||||
className="space-y-0.5 px-1 py-1"
|
||||
labelRowClass="text-xs text-slate-300"
|
||||
labelClass=""
|
||||
valueClass="font-mono text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-[0.7rem] text-slate-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
|
||||
export default function DriveModeToggle({ size = 'default' }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const [pending, setPending] = useState(null);
|
||||
const disabled = !roverId || pending !== null;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const oiLabel = frame?.sensors?.oiMode?.label || 'Unknown';
|
||||
const oiNormalized = oiLabel.toLowerCase();
|
||||
const driveReady = oiNormalized === 'full';
|
||||
const currentLabel = driveReady ? 'Drive Ready' : `OI Mode: ${oiLabel}`;
|
||||
|
||||
const handleDrive = async () => {
|
||||
if (!roverId) return;
|
||||
setPending('drive');
|
||||
try {
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDock = async () => {
|
||||
if (!roverId) return;
|
||||
setPending('dock');
|
||||
try {
|
||||
actions.setMode('dock');
|
||||
await actions.runMacro('seek-dock');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pillClass = size === 'compact' ? 'text-xs px-0.5 py-0.5' : 'text-sm px-0.5 py-0.5';
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-0 grid grid-cols-2 gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDrive}
|
||||
disabled={disabled}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDock}
|
||||
disabled={disabled}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user