moving a LOT of stuff around in web ui

This commit is contained in:
legop3
2026-08-02 23:06:30 -04:00
parent 3ebd1c7f9c
commit 15a60a58e6
45 changed files with 814 additions and 870 deletions
@@ -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>
);
}