mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
moving a LOT of stuff around in web ui
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
// Desktop Driver Layout
|
||||
// Purpose: Owns the concrete two-pane desktop driver-page composition.
|
||||
// Scope: Places existing cards without abstracting or changing their presentation.
|
||||
import DriverVideo from '../../../components/DriverVideo/index.jsx';
|
||||
import PiHostStatsCard from '../../../components/PiHostStatsCard/index.jsx';
|
||||
import DriverAdCard from '../../../components/DriverAdCard/index.jsx';
|
||||
import GlobalObjectiveBanner from '../../../components/GlobalObjectiveBanner/index.jsx';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import DesktopRightPaneTabs from '../DesktopRightPaneTabs/index.jsx';
|
||||
import { useTelemetryVisualPolicy } from '../../../context/TelemetryContext.jsx';
|
||||
|
||||
export default function DesktopLayout() {
|
||||
useTelemetryVisualPolicy({ mobile: false });
|
||||
return (
|
||||
<div className={`flex h-screen ${themeGapClass} overflow-hidden`}>
|
||||
<div className={`flex min-w-0 flex-[1.22] flex-col ${themeGapClass} overflow-y-auto pr-0`}>
|
||||
<DriverVideo />
|
||||
<PiHostStatsCard />
|
||||
{/* The self-gating ad remains pinned to the bottom of the left pane. */}
|
||||
<DriverAdCard className="mt-auto" />
|
||||
</div>
|
||||
<div className={`flex min-w-0 flex-1 flex-col ${themeGapClass} overflow-y-auto`}>
|
||||
<GlobalObjectiveBanner layout="desktop" />
|
||||
<DesktopRightPaneTabs />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// 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 '../../../components/RoomCameraPanel/index.jsx';
|
||||
import HomeAssistantControls from '../../../components/HomeAssistantControls/index.jsx';
|
||||
import ChatPanel from '../../../components/ChatPanel/index.jsx';
|
||||
import { LinkButtonsPanel } from '../../../components/UserListPanel/index.jsx';
|
||||
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
|
||||
import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../../../components/Tabs/index.jsx';
|
||||
import TopDownMap from '../../../components/TopDownMap/index.jsx';
|
||||
import DriveDockAction from '../../../components/DriveDockAction/index.jsx';
|
||||
import { useDriveDockState } from '../../../components/DriveDockAction/driveDockState.js';
|
||||
import { useControlActions, useControlSelector } from '../../../controls/index.js';
|
||||
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
|
||||
import RawUserPilePanel from '../../../components/RawUserPilePanel/index.jsx';
|
||||
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
|
||||
import GPIOToggleControl from '../../../components/GPIOToggleControl/index.jsx';
|
||||
import HornControl from '../../../components/HornControl/index.jsx';
|
||||
import CameraTiltControl from '../../../components/CameraTiltControl/index.jsx';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../../../components/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';
|
||||
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
||||
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
||||
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
|
||||
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
|
||||
|
||||
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() {
|
||||
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>
|
||||
|
||||
<ActivitiesTab />
|
||||
<VipTab />
|
||||
<HelpTab />
|
||||
<SettingsTab />
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Driver Layout Context
|
||||
// Purpose: Exposes the existing global Help-overlay action to deeply placed tab content.
|
||||
// Scope: Avoids threading a layout callback through otherwise concrete composition files.
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const DriverLayoutContext = createContext(null);
|
||||
|
||||
export function DriverLayoutProvider({ layout, openHelp, children }) {
|
||||
/*
|
||||
Layout mode is classified once at the driver-route boundary. Concrete
|
||||
layouts and tabs read it here instead of each installing a resize listener
|
||||
or receiving chains of layout/configuration props.
|
||||
*/
|
||||
return <DriverLayoutContext.Provider value={{ layout, openHelp }}>{children}</DriverLayoutContext.Provider>;
|
||||
}
|
||||
|
||||
/*
|
||||
This hook intentionally lives beside its tiny route-local provider so the
|
||||
driver layout contract remains discoverable in one file. It is not a React
|
||||
component export, so the Fast Refresh rule needs this narrow exception.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useOpenDriverHelp() {
|
||||
const context = useContext(DriverLayoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useOpenDriverHelp must be used within DriverLayoutProvider.');
|
||||
}
|
||||
return context.openHelp;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDriverLayout() {
|
||||
const context = useContext(DriverLayoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useDriverLayout must be used within DriverLayoutProvider.');
|
||||
}
|
||||
return context.layout;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Driver Layout Root
|
||||
// Purpose: Selects and frames the concrete desktop or mobile driver composition.
|
||||
// Scope: Owns layout-only mobile framing so App.jsx does not place page cards.
|
||||
import DesktopLayout from './DesktopLayout/index.jsx';
|
||||
import MobilePortraitLayout from './MobilePortraitLayout/index.jsx';
|
||||
import MobileLandscapeLayout from './MobileLandscapeLayout/index.jsx';
|
||||
import { useDriverLayout } from './DriverLayoutContext.jsx';
|
||||
|
||||
export default function DriverLayoutRoot() {
|
||||
const layout = useDriverLayout();
|
||||
|
||||
if (layout === 'desktop') {
|
||||
return <DesktopLayout />;
|
||||
}
|
||||
|
||||
return layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Mobile Landscape Driver Layout
|
||||
// Purpose: Owns the concrete landscape video, controls, and secondary-card placement.
|
||||
import AuxColumn from '../../../components/MobileControls/AuxColumn.jsx';
|
||||
import MovementColumn from '../../../components/MobileControls/MovementColumn.jsx';
|
||||
import DriverVideo from '../../../components/DriverVideo/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import MobileLayoutFrame from '../MobileLayoutFrame/index.jsx';
|
||||
import MobileSecondaryContent from '../MobileSecondaryContent/index.jsx';
|
||||
|
||||
export default function MobileLandscapeLayout() {
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
|
||||
const swap = Boolean(pageSettings?.swapMobileControlColumns);
|
||||
const columnClass = 'self-start h-[min(100svh,32rem)]';
|
||||
const firstColumn = swap
|
||||
? <MovementColumn layout="landscape" className={columnClass} />
|
||||
: <AuxColumn layout="landscape" className={columnClass} />;
|
||||
const secondColumn = swap
|
||||
? <AuxColumn layout="landscape" className={columnClass} />
|
||||
: <MovementColumn layout="landscape" className={columnClass} />;
|
||||
|
||||
return (
|
||||
<MobileLayoutFrame>
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
{/* The center column deliberately contains only the driver video. */}
|
||||
<section className={`grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
|
||||
{firstColumn}
|
||||
<div className="min-w-0 self-start">
|
||||
<DriverVideo layoutFormat="mobile-landscape" />
|
||||
</div>
|
||||
{secondColumn}
|
||||
</section>
|
||||
<MobileSecondaryContent />
|
||||
</div>
|
||||
</MobileLayoutFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Mobile Driver Layout Frame
|
||||
// Purpose: Owns behavior and framing shared exclusively by portrait and landscape driver layouts.
|
||||
// Scope: Keeps fullscreen, mobile telemetry policy, objective banner, and snap framing out of the shared driver root.
|
||||
import { useCallback } from 'react';
|
||||
import FloatingFullscreenButton from '../../../components/FloatingFullscreenButton/index.jsx';
|
||||
import FullscreenPrompt from '../../../components/FullscreenPrompt/index.jsx';
|
||||
import GlobalObjectiveBanner from '../../../components/GlobalObjectiveBanner/index.jsx';
|
||||
import { useTelemetryVisualPolicy } from '../../../context/TelemetryContext.jsx';
|
||||
import { useFullscreenPrompt } from '../../../hooks/useFullscreenPrompt.js';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import { useDriverLayout } from '../DriverLayoutContext.jsx';
|
||||
import './styles.css';
|
||||
|
||||
export default function MobileLayoutFrame({ children }) {
|
||||
const layout = useDriverLayout();
|
||||
useTelemetryVisualPolicy({ mobile: true });
|
||||
const fullscreen = useFullscreenPrompt(layout);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
|
||||
const buttonSide = pageSettings?.swapMobileControlColumns ? 'left' : 'right';
|
||||
const showButton = fullscreen.isIOS || fullscreen.nativeSupported;
|
||||
|
||||
const handleFloatingFullscreen = useCallback(async () => {
|
||||
if (fullscreen.isIOS) {
|
||||
fullscreen.showPrompt();
|
||||
return;
|
||||
}
|
||||
const entered = await fullscreen.enterFullscreen();
|
||||
if (!entered) fullscreen.showPrompt();
|
||||
}, [fullscreen]);
|
||||
|
||||
return (
|
||||
<div className={`driver-mobile-layout min-h-screen flex flex-col ${themeGapClass}`}>
|
||||
{/* The absolute marker creates the literal page-top snap without taking flex space. */}
|
||||
<div className="mobile-top-snap" aria-hidden="true" />
|
||||
<GlobalObjectiveBanner layout={layout} />
|
||||
{children}
|
||||
<FullscreenPrompt
|
||||
visible={fullscreen.visible}
|
||||
mode={fullscreen.mode}
|
||||
onEnterFullscreen={fullscreen.enterFullscreen}
|
||||
onDismiss={fullscreen.dismiss}
|
||||
/>
|
||||
{showButton ? <FloatingFullscreenButton side={buttonSide} onClick={handleFloatingFullscreen} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@media (max-width: 1023px) {
|
||||
html:has(.driver-mobile-layout) {
|
||||
/* These three driver-only positions deliberately use strict page snapping. */
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
.driver-mobile-layout .mobile-top-snap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
.driver-mobile-layout .mobile-content-snap,
|
||||
.driver-mobile-layout .mobile-tabs-snap {
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Mobile Portrait Driver Layout
|
||||
// Purpose: Owns the concrete portrait video, controls, and secondary-card placement.
|
||||
import AuxColumn from '../../../components/MobileControls/AuxColumn.jsx';
|
||||
import MovementColumn from '../../../components/MobileControls/MovementColumn.jsx';
|
||||
import DriverVideo from '../../../components/DriverVideo/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import MobileLayoutFrame from '../MobileLayoutFrame/index.jsx';
|
||||
import MobileSecondaryContent from '../MobileSecondaryContent/index.jsx';
|
||||
|
||||
export default function MobilePortraitLayout() {
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
|
||||
const swap = Boolean(pageSettings?.swapMobileControlColumns);
|
||||
const columnHeight = 'h-[min(60svh,24rem)]';
|
||||
const firstColumn = swap
|
||||
? <MovementColumn layout="portrait" className={columnHeight} />
|
||||
: <AuxColumn layout="portrait" className={columnHeight} />;
|
||||
const secondColumn = swap
|
||||
? <AuxColumn layout="portrait" className={columnHeight} />
|
||||
: <MovementColumn layout="portrait" className={columnHeight} />;
|
||||
|
||||
return (
|
||||
<MobileLayoutFrame>
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<DriverVideo layoutFormat="mobile-portrait" />
|
||||
{/* Preserve the existing two-column portrait control surface exactly. */}
|
||||
<section className="mobile-touch-control text-white">
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 items-stretch">
|
||||
{firstColumn}
|
||||
{secondColumn}
|
||||
</div>
|
||||
</section>
|
||||
<MobileSecondaryContent />
|
||||
</div>
|
||||
</MobileLayoutFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Mobile Driver Secondary Content
|
||||
// Purpose: Owns the identical replay, queue, tabs, and terminal-ad composition used by both mobile layouts.
|
||||
import DriverAdCard from '../../../components/DriverAdCard/index.jsx';
|
||||
import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
|
||||
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
|
||||
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import { useDriverLayout } from '../DriverLayoutContext.jsx';
|
||||
import MobileTabs from '../MobileTabs/index.jsx';
|
||||
|
||||
export default function MobileSecondaryContent() {
|
||||
const layout = useDriverLayout();
|
||||
const portrait = layout === 'mobile-portrait';
|
||||
const replayPanelId = portrait
|
||||
? 'replay-sources-mobile-portrait'
|
||||
: 'replay-sources-mobile-landscape';
|
||||
|
||||
return (
|
||||
<div className={`mobile-content-snap flex flex-col ${themeGapClass} ${portrait ? '' : 'pb-0'}`}>
|
||||
<div className={`grid ${themeGapClass} grid-cols-2`}>
|
||||
<div className="space-y-0.5">
|
||||
<ReplaySourcesPanel panelId={replayPanelId} />
|
||||
<PtzQueueCard layout={layout} />
|
||||
</div>
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
<MobileTabs />
|
||||
<DriverAdCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Mobile Driver Tabs
|
||||
// Purpose: Owns the shared mobile tab selector, state, and per-tab modules.
|
||||
import { useCallback, useState } from 'react';
|
||||
import Tabs, { Tab, TabList, TabPanels } from '../../../components/Tabs/index.jsx';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../../controls/index.js';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import MobileChatTab from '../tabs/mobile/ChatTab/index.jsx';
|
||||
import RoomControlsTab from '../tabs/mobile/RoomControlsTab/index.jsx';
|
||||
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
||||
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
||||
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
|
||||
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
|
||||
|
||||
export default function MobileTabs() {
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
|
||||
const ownAudioForward = useSessionSelector((state) => ownRoverId ? state.session?.audioForward?.[ownRoverId] || null : null);
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
||||
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
|
||||
const vipMicActive = Boolean(ownRoverId && isVerified && (pttMode === 'clip' ? pttActive : (vipAudio?.openMicEnabled || pttActive)));
|
||||
const vipClipPlaying = Boolean(ownRoverId && isVerified && pttMode === 'clip' && ownAudioForward?.source === 'upload' && ownAudioForward?.state === 'playing');
|
||||
const handleTabChange = useCallback((tab) => setActiveTab(tab), []);
|
||||
|
||||
return (
|
||||
<section className="mobile-tabs-snap text-base">
|
||||
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={handleTabChange}>
|
||||
<TabList>
|
||||
<Tab id="chat">Chat</Tab>
|
||||
<Tab id="activities">Activities</Tab>
|
||||
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<span>VIP</span>
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${isVerified ? 'bg-emerald-400' : 'bg-amber-400'}`} aria-hidden="true" title={isVerified ? 'Verified' : 'Not verified'} />
|
||||
</span>
|
||||
</Tab>
|
||||
<Tab id="roomcontrols">Room Controls</Tab>
|
||||
<Tab id="help">Help</Tab>
|
||||
<Tab id="settings">Settings</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<MobileChatTab />
|
||||
<ActivitiesTab />
|
||||
<VipTab />
|
||||
<RoomControlsTab />
|
||||
<HelpTab />
|
||||
<SettingsTab />
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Mobile Chat Tab
|
||||
// Purpose: Owns the mobile chat and compact supporting-card composition.
|
||||
import ChatPanel from '../../../../../components/ChatPanel/index.jsx';
|
||||
import SocialButtonsGrid from '../../../../../components/SocialButtonsGrid/index.jsx';
|
||||
import OverseerPreferencePanel from '../../../../../components/OverseerPreferencePanel/index.jsx';
|
||||
import RawUserPilePanel from '../../../../../components/RawUserPilePanel/index.jsx';
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import { useSessionSelector } from '../../../../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../../../../lib/features.js';
|
||||
import { themeGapClass, themeStackClass } from '../../../../../themes/index.js';
|
||||
|
||||
export default function MobileChatTab() {
|
||||
const showOverseerPreferencePanel = useSessionSelector((state) => Boolean(state.session?.overseerVote?.votingEnabled));
|
||||
const showSocialButtons = useSessionSelector((state) => {
|
||||
/* Match the card's own gate so its absence cannot reserve an empty column. */
|
||||
const socials = Array.isArray(state.session?.socials) ? state.session.socials : [];
|
||||
return isFeatureEnabled(state, 'socials') && socials.length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<TabPanel id="chat">
|
||||
<div className={themeStackClass}>
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
<div className={`grid items-start ${themeGapClass} ${showSocialButtons ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]'}`}>
|
||||
{showSocialButtons ? <SocialButtonsGrid /> : null}
|
||||
<div className={`flex min-w-0 flex-col ${themeGapClass} ${showSocialButtons ? '' : 'max-w-sm'}`}>
|
||||
{showOverseerPreferencePanel ? <OverseerPreferencePanel /> : null}
|
||||
<RawUserPilePanel hideNicknameForm compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Mobile Room Controls Tab
|
||||
// Purpose: Owns the concrete mobile room-controls card order.
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import HomeAssistantControls from '../../../../../components/HomeAssistantControls/index.jsx';
|
||||
import RoomCameraPanel from '../../../../../components/RoomCameraPanel/index.jsx';
|
||||
import { themeStackClass } from '../../../../../themes/index.js';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
|
||||
export default function RoomControlsTab() {
|
||||
const layout = useDriverLayout();
|
||||
const panelId = layout === 'mobile-landscape' ? 'mobile-landscape-room' : 'mobile-portrait-room';
|
||||
return (
|
||||
<TabPanel id="roomcontrols">
|
||||
<div className={themeStackClass}>
|
||||
<HomeAssistantControls />
|
||||
<RoomCameraPanel panelId={panelId} />
|
||||
</div>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Driver Activities Tab
|
||||
// Purpose: Owns the shared desktop/mobile ordering of activity cards.
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import NeatoCard from '../../../../../components/NeatoCard/index.jsx';
|
||||
import LiftCard from '../../../../../components/LiftCard/index.jsx';
|
||||
import BalanceBoardPanel from '../../../../../components/BalanceBoardPanel/index.jsx';
|
||||
import BarcodeGamesPanel from '../../../../../components/BarcodeGamesPanel/index.jsx';
|
||||
import OdometerPanel from '../../../../../components/OdometerPanel/index.jsx';
|
||||
import ButtonBoxPanel from '../../../../../components/ButtonBoxPanel/index.jsx';
|
||||
import KinectPanel from '../../../../../components/KinectPanel/index.jsx';
|
||||
import FleetReportsCard from '../../../../../components/FleetReportsCard/index.jsx';
|
||||
import { themeGapClass } from '../../../../../themes/index.js';
|
||||
|
||||
export default function ActivitiesTab() {
|
||||
return (
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<BalanceBoardPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<OdometerPanel />
|
||||
<ButtonBoxPanel />
|
||||
<KinectPanel />
|
||||
{/* Fleet reports retains its existing terminal position and self-gate. */}
|
||||
<FleetReportsCard />
|
||||
</div>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Driver Help Tab
|
||||
// Purpose: Owns the shared driver help panel placement.
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import HelpPanel from '../../../../../components/HelpPanel/index.jsx';
|
||||
import { useOpenDriverHelp } from '../../../DriverLayoutContext.jsx';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
|
||||
export default function HelpTab() {
|
||||
const openHelp = useOpenDriverHelp();
|
||||
const layout = useDriverLayout();
|
||||
return (
|
||||
<TabPanel id="help">
|
||||
<HelpPanel layout={layout} onOpenOverlay={openHelp} />
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Driver Settings Tab
|
||||
// Purpose: Owns the shared driver settings panel placement.
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import SettingsPanel from '../../../../../components/SettingsPanel/index.jsx';
|
||||
import { themeStackClass } from '../../../../../themes/index.js';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
|
||||
export default function SettingsTab() {
|
||||
const layout = useDriverLayout();
|
||||
const wrapped = layout !== 'desktop';
|
||||
return (
|
||||
<TabPanel id="settings">
|
||||
{wrapped ? <div className={themeStackClass}><SettingsPanel /></div> : <SettingsPanel />}
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Driver VIP Tab
|
||||
// Purpose: Owns the shared keep-mounted VIP panel lifecycle.
|
||||
import { TabPanel, useTabIsActive } from '../../../../../components/Tabs/index.jsx';
|
||||
import VipPanel from '../../../../../components/VipPanel/index.jsx';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
|
||||
export default function VipTab() {
|
||||
const layout = useDriverLayout();
|
||||
const isActive = useTabIsActive('vip');
|
||||
return (
|
||||
<TabPanel id="vip" keepMounted>
|
||||
<VipPanel isActive={isActive} layout={layout} />
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user