mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
moving a LOT of stuff around in web ui
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Battery Bar module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
import './styles.css';
|
||||
import { WARN_DISPLAY_PERCENT } from '../../lib/battery.js';
|
||||
|
||||
const WARN_FLASH_MS = 1600;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@keyframes batteryTickWarn { 0%, 49% { background-color: rgba(255,255,255,.95); } 50%, 100% { background-color: rgba(239,68,68,.95); } }
|
||||
.battery-tick-warn { animation: batteryTickWarn .4s steps(2,end) infinite; }
|
||||
@keyframes batteryUrgentFlash { 0%, 49% { opacity: 1; } 50%, 100% { opacity: .25; } }
|
||||
.battery-urgent-flash { animation: batteryUrgentFlash .4s steps(2,end) infinite; }
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps timeline updates isolated from controlled form inputs so incoming chat activity does not
|
||||
// force the composer DOM to re-commit while a user is simply watching or driving.
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import './styles.css';
|
||||
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.chat-composer { container-type: inline-size; display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.125rem; min-width: 0; overflow: hidden; }
|
||||
.chat-composer-nickname { flex: 0 0 5rem; min-width: 0; order: 1; }
|
||||
.chat-composer-input { flex: 1 1 0%; min-width: 0; order: 2; }
|
||||
.chat-composer-send { flex: 0 0 auto; align-self: stretch; order: 3; }
|
||||
.chat-composer-tts { display: flex; flex: 1 1 100%; align-items: center; gap: 0.125rem; min-width: 0; overflow: hidden; order: 4; }
|
||||
@container (min-width: 44rem) {
|
||||
.chat-composer { flex-wrap: nowrap; }
|
||||
.chat-composer-nickname { flex-basis: 7rem; order: 1; }
|
||||
.chat-composer-input { order: 2; }
|
||||
.chat-composer-tts { flex: 0 1 auto; order: 3; }
|
||||
.chat-composer-send { order: 4; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Drive Dock Action module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import '../MobileControls/mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser.
|
||||
// Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import '../MobileControls/mobileControls.css';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function isBoolean(value) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Horn Control module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import '../MobileControls/mobileControls.css';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { HORN_MAX_FREQUENCY } from '../../controls/constants.js';
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import './styles.css';
|
||||
import '../../MobileControls/mobileControls.css';
|
||||
import { useChatActions } from '../../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.mobile-text-entry { font-size: 16px; -webkit-text-size-adjust: 100%; touch-action: manipulation; }
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
||||
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
||||
import { useMemo } from 'react';
|
||||
import './styles.css';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.inter-instance-overlay-frame { max-width: calc(100vw - .5rem); }
|
||||
.inter-instance-overlay-body { max-height: 82vh; }
|
||||
@media (min-width: 1024px) {
|
||||
.inter-instance-overlay-scale { zoom: 1.5; }
|
||||
.inter-instance-overlay-frame { max-width: calc((100vw - .5rem) / 1.5); }
|
||||
.inter-instance-overlay-body { max-height: 54.6667vh; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
|
||||
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
|
||||
import { useCallback, useRef } from 'react';
|
||||
import './mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Provides the mobile movement control pad and speed mode selector.
|
||||
// Scope: Converts touch pad cells into keyboard-style drive vectors; movement column owns drive/dock placement.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import './mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { normalizeKeymapEntries } from '../../controls/keymapUtils.js';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.no-touch-select { user-select: none; -webkit-user-select: none; -ms-user-select: none; -webkit-touch-callout: none; }
|
||||
.mobile-touch-control {
|
||||
/* Suppress browser gestures that compete with deliberate control presses. */
|
||||
user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;
|
||||
-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation; overscroll-behavior: contain;
|
||||
}
|
||||
.mobile-drag-control { touch-action: none; }
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Mode Gate Overlay module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
import '../InterInstancePanel/styles.css';
|
||||
import AuthPanel from '../AuthPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
// Right Pane Tabs
|
||||
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomCameraPanel from '../RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from '../KinectPanel/index.jsx';
|
||||
import BalanceBoardPanel from '../BalanceBoardPanel/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import SettingsPanel from '../SettingsPanel/index.jsx';
|
||||
import HelpPanel from '../HelpPanel/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import PtzQueueCard from '../PtzCamera/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
||||
import TopDownMap from '../TopDownMap/index.jsx';
|
||||
import DriveDockAction from '../DriveDockAction/index.jsx';
|
||||
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import HornControl from '../HornControl/index.jsx';
|
||||
import CameraTiltControl from '../CameraTiltControl/index.jsx';
|
||||
import VipPanel from '../VipPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||
import OdometerPanel from '../OdometerPanel/index.jsx';
|
||||
import FleetReportsCard from '../FleetReportsCard/index.jsx';
|
||||
import LiftCard from '../LiftCard/index.jsx';
|
||||
import NeatoCard from '../NeatoCard/index.jsx';
|
||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { themeGapClass, themeStackClass } from '../../themes/index.js';
|
||||
|
||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||
const CHAT_DOCK_MIN_HEIGHT = 144;
|
||||
const CHAT_DOCK_MAX_HEIGHT = 300;
|
||||
const CHAT_DOCK_BOTTOM_INSET = 8;
|
||||
const CAMERA_TILT_STEP_DEGREES = 0.5;
|
||||
const CAMERA_TILT_PRECISION_STEP_DEGREES = 0.1;
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title = "Roomba sensor view"
|
||||
>
|
||||
<div className="aspect-square w-full">
|
||||
<TopDownMap roverId={roverId} />
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function DriveDockPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const horn = useControlSelector((control) => control.state.horn);
|
||||
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
||||
const headlightState = useControlSelector((control) => control.pipeline?.headlightState);
|
||||
const laser = useControlSelector((control) => control.pipeline?.laser);
|
||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
const config = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||
const headlightAvailable = Boolean(roverId && headlight);
|
||||
const laserAvailable = Boolean(roverId && laser);
|
||||
const hornAvailable = Boolean(roverId && pipelineHorn);
|
||||
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 headlightLabel = formatKeyLabel(keymap?.headlightToggle?.[0]);
|
||||
const laserLabel = formatKeyLabel(keymap?.laserToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||
/*
|
||||
Precision movement mode also tightens the servo slider step. The command
|
||||
path still sends ordinary angle targets; only the UI increment changes while
|
||||
precision mode is active.
|
||||
*/
|
||||
const cameraTiltStep = camera?.precisionMode
|
||||
? CAMERA_TILT_PRECISION_STEP_DEGREES
|
||||
: CAMERA_TILT_STEP_DEGREES;
|
||||
const auxControls = useMemo(
|
||||
() => ({
|
||||
setHeadlight: (nextOn) => {
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
setLaser: (nextOn) => {
|
||||
setLaser(nextOn);
|
||||
},
|
||||
startHorn: () => {
|
||||
return startHorn();
|
||||
},
|
||||
stopHorn,
|
||||
}),
|
||||
[setHeadlight, setLaser, startHorn, stopHorn],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
hideHeader
|
||||
|
||||
className="h-full"
|
||||
bodyClassName={`grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] ${themeGapClass}`}
|
||||
>
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? (
|
||||
<div className={`${themeStackClass} p-0 text-sm text-slate-200`}>
|
||||
{(headlightAvailable || laserAvailable) && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{headlightAvailable && (
|
||||
<GPIOToggleControl
|
||||
label="Headlight"
|
||||
on={headlightState?.headlightOn}
|
||||
disabled={!roverId}
|
||||
onToggle={auxControls.setHeadlight}
|
||||
keyLabel={headlightLabel}
|
||||
/>
|
||||
)}
|
||||
{laserAvailable && (
|
||||
<GPIOToggleControl
|
||||
label="Laser"
|
||||
on={laserState?.laserOn}
|
||||
disabled={!roverId || roomLightsLockedOn}
|
||||
onToggle={auxControls.setLaser}
|
||||
keyLabel={laserLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={auxControls.startHorn}
|
||||
onStop={auxControls.stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={cameraTiltStep}
|
||||
label="Camera tilt"
|
||||
disabled={cameraDisabled}
|
||||
onChange={setServoAngle}
|
||||
keyDownLabel={downLabel}
|
||||
keyUpLabel={upLabel}
|
||||
className={`${themeStackClass} rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50`}
|
||||
labelRowClass="text-xs text-emerald-100"
|
||||
labelClass="text-sm font-semibold"
|
||||
valueClass="font-mono text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-[0.7rem] text-emerald-100/80"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueReplayLinksRow() {
|
||||
/*
|
||||
Flex ratios match the old grid proportions when the Links panel exists. If
|
||||
LinkButtonsPanel returns null because socials are disabled, flex naturally
|
||||
removes that item instead of preserving an empty grid column.
|
||||
*/
|
||||
return (
|
||||
<div className={`flex items-stretch ${themeGapClass}`}>
|
||||
<div className="relative min-w-0 basis-0 grow-[1]">
|
||||
{/*
|
||||
The absolutely positioned queue card is removed from flex cross-size
|
||||
calculation. Replay and the links/PTZ stack therefore define the row
|
||||
height entirely through normal CSS layout; this relative column then
|
||||
stretches to that established height and gives the queue card an exact
|
||||
containing block to fill without any JavaScript measurement.
|
||||
*/}
|
||||
<div className="absolute inset-0 min-h-0">
|
||||
<RoverQueuesPanel fillHeight />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
</div>
|
||||
<div className={`min-w-0 basis-0 grow-[0.75] ${themeStackClass}`}>
|
||||
<LinkButtonsPanel fillHeight={false} />
|
||||
<PtzQueueCard layout="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
const [activeTab, setActiveTab] = useState('telemetry');
|
||||
const chatDockRef = useRef(null);
|
||||
const [chatDockHeight, setChatDockHeight] = useState(CHAT_DOCK_INITIAL_HEIGHT);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
|
||||
const ownAudioForward = useSessionSelector((state) => {
|
||||
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||
return roverId ? state.session?.audioForward?.[roverId] || null : null;
|
||||
});
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
||||
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-red-600';
|
||||
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
|
||||
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
|
||||
const vipMicActive = Boolean(
|
||||
ownRoverId &&
|
||||
isVerified &&
|
||||
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
|
||||
);
|
||||
const vipClipPlaying = Boolean(
|
||||
ownRoverId &&
|
||||
isVerified &&
|
||||
pttMode === 'clip' &&
|
||||
ownAudioForward?.source === 'upload' &&
|
||||
ownAudioForward?.state === 'playing',
|
||||
);
|
||||
const showOverseerPreferencePanel = useSessionSelector((state) => {
|
||||
const vote = state.session?.overseerVote;
|
||||
|
||||
// Match the panel's server-owned voting gate so the measured desktop chat
|
||||
// dock can give the side-column height to the user pile when voting is
|
||||
// unavailable, including disabled service and direct-address mode.
|
||||
return Boolean(vote?.votingEnabled);
|
||||
});
|
||||
const handleTabChange = useCallback(
|
||||
(tab) => {
|
||||
/*
|
||||
Keep the selected desktop panel controlled here so the tab strip and
|
||||
panel content always move together.
|
||||
*/
|
||||
setActiveTab(tab);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const chatDock = chatDockRef.current;
|
||||
if (!chatDock) return undefined;
|
||||
let animationFrame = 0;
|
||||
let settledFirstFrame = 0;
|
||||
let settledSecondFrame = 0;
|
||||
|
||||
const measureChatDock = () => {
|
||||
animationFrame = 0;
|
||||
const { top } = chatDock.getBoundingClientRect();
|
||||
|
||||
/*
|
||||
Keep this as pixels because getBoundingClientRect() and innerHeight are
|
||||
pixel-based browser measurements. The row stays in normal document flow:
|
||||
when its top moves upward during right-column scrolling, the available
|
||||
viewport space grows and the chat panel expands until the max height.
|
||||
*/
|
||||
const availableHeight = window.innerHeight - top - CHAT_DOCK_BOTTOM_INSET;
|
||||
const nextHeight = Math.round(
|
||||
Math.max(CHAT_DOCK_MIN_HEIGHT, Math.min(CHAT_DOCK_MAX_HEIGHT, availableHeight)),
|
||||
);
|
||||
setChatDockHeight((currentHeight) => (Math.abs(currentHeight - nextHeight) > 1 ? nextHeight : currentHeight));
|
||||
};
|
||||
|
||||
const scheduleMeasure = () => {
|
||||
if (animationFrame) return;
|
||||
animationFrame = window.requestAnimationFrame(measureChatDock);
|
||||
};
|
||||
|
||||
const scheduleSettledMeasure = () => {
|
||||
settledFirstFrame = window.requestAnimationFrame(() => {
|
||||
settledFirstFrame = 0;
|
||||
settledSecondFrame = window.requestAnimationFrame(() => {
|
||||
settledSecondFrame = 0;
|
||||
measureChatDock();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'function'
|
||||
? new ResizeObserver(() => {
|
||||
/*
|
||||
Some panels above chat settle after the first React commit as data,
|
||||
images, or intrinsic layout measurements arrive. Watching the
|
||||
parent group means those height changes recalculate the chat row
|
||||
immediately instead of requiring a user scroll to repair the
|
||||
initial bottom alignment.
|
||||
*/
|
||||
scheduleMeasure();
|
||||
})
|
||||
: null;
|
||||
|
||||
scheduleMeasure();
|
||||
scheduleSettledMeasure();
|
||||
if (resizeObserver && chatDock.parentElement) {
|
||||
resizeObserver.observe(chatDock.parentElement);
|
||||
}
|
||||
window.addEventListener('resize', scheduleMeasure);
|
||||
/*
|
||||
The desktop layout scrolls inside the right-column pane, not the window.
|
||||
Capturing scroll events at the document level keeps the effect small while
|
||||
still noticing that nested scroll movement without wiring a dedicated ref
|
||||
through App.jsx just for this row.
|
||||
*/
|
||||
document.addEventListener('scroll', scheduleMeasure, { capture: true, passive: true });
|
||||
|
||||
return () => {
|
||||
if (animationFrame) {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
if (settledFirstFrame) {
|
||||
window.cancelAnimationFrame(settledFirstFrame);
|
||||
}
|
||||
if (settledSecondFrame) {
|
||||
window.cancelAnimationFrame(settledSecondFrame);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', scheduleMeasure);
|
||||
document.removeEventListener('scroll', scheduleMeasure, { capture: true });
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<section className="text-base">
|
||||
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={handleTabChange}>
|
||||
<TabList>
|
||||
<Tab id="telemetry">Controls</Tab>
|
||||
<Tab id="activities">Activities</Tab>
|
||||
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>VIP</span>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
|
||||
aria-hidden="true"
|
||||
title={isVerified ? 'Verified' : 'Not verified'}
|
||||
/>
|
||||
</span>
|
||||
</Tab>
|
||||
<Tab id="help">Help</Tab>
|
||||
<Tab id="settings">Settings</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="telemetry">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
{/*
|
||||
The first desktop telemetry group is intentionally a viewport
|
||||
filler instead of a sticky overlay. The rows above chat keep
|
||||
their natural height, and the chat row receives only the
|
||||
leftover room between those rows and the bottom of the visible
|
||||
right column. That keeps the chat composer at the bottom of the
|
||||
screen when space is available while still letting later panels
|
||||
sit below it in normal scroll flow instead of being covered.
|
||||
*/}
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<div className={`grid items-stretch ${themeGapClass} grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)]`}>
|
||||
<TopDownMapPanel />
|
||||
<DriveDockPanel />
|
||||
</div>
|
||||
<QueueReplayLinksRow />
|
||||
{/*
|
||||
This row gets an explicit measured height because the target
|
||||
behavior depends on the row's live viewport position during
|
||||
right-column scrolling. Pure CSS can size against the viewport
|
||||
itself, but it cannot calculate the remaining visible distance
|
||||
from this particular row's current top edge to the bottom of
|
||||
the nested scroll viewport.
|
||||
*/}
|
||||
<div
|
||||
ref={chatDockRef}
|
||||
className={`grid min-h-0 items-stretch ${themeGapClass} grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)]`}
|
||||
style={{ height: `${chatDockHeight}px` }}
|
||||
>
|
||||
<ChatPanel fillHeight />
|
||||
<div
|
||||
className={`grid min-h-0 ${themeGapClass} ${
|
||||
showOverseerPreferencePanel
|
||||
? 'grid-rows-[auto_minmax(0,1fr)]'
|
||||
: 'grid-rows-[minmax(0,1fr)]'
|
||||
}`}
|
||||
>
|
||||
{/*
|
||||
This side column is height-constrained by the measured
|
||||
chat dock row. When overseer voting is unavailable on the
|
||||
server, the user pile becomes the only child and should
|
||||
receive the whole side-column height.
|
||||
*/}
|
||||
{showOverseerPreferencePanel ? <OverseerPreferencePanel /> : null}
|
||||
<RawUserPilePanel compact hideNicknameForm fillHeight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HomeAssistantControls />
|
||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{/* activities tab */}
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<BalanceBoardPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<OdometerPanel />
|
||||
<ButtonBoxPanel />
|
||||
<KinectPanel />
|
||||
{/* The compact report is a terminal summary for Activities; the
|
||||
component itself disappears when the server feature is off. */}
|
||||
<FleetReportsCard />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{/* VIP tab */}
|
||||
<TabPanel id="vip" keepMounted>
|
||||
<VipPanel isActive={activeTab === 'vip'} layout={layout} />
|
||||
</TabPanel>
|
||||
|
||||
{/* help tab */}
|
||||
<TabPanel id="help">
|
||||
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
|
||||
</TabPanel>
|
||||
|
||||
{/* settings tab */}
|
||||
<TabPanel id="settings">
|
||||
<SettingsPanel />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,16 @@ function useTabsContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useTabIsActive(id) {
|
||||
/*
|
||||
Tab content that manages a mounted media lifecycle needs to know whether it
|
||||
is selected without having the parent shell relay activeTab as a prop. This
|
||||
reads the same Tabs context that already controls TabPanel visibility.
|
||||
*/
|
||||
return useTabsContext().activeTab === id;
|
||||
}
|
||||
|
||||
const TAB_VARIANTS = {
|
||||
primary: {
|
||||
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500 rounded-md border border-slate-800',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
|
||||
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import '../MobileControls/mobileControls.css';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
Reference in New Issue
Block a user