mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
4:3 UI switchover
This commit is contained in:
+2
-9
@@ -61,16 +61,9 @@ function useLayoutMode() {
|
||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||
return (
|
||||
<div className="flex h-full gap-0.5 overflow-hidden">
|
||||
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
||||
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
||||
<DriverVideoPanel />
|
||||
<div className="grid h-52 grid-cols-2 gap-0.5">
|
||||
<div className="h-full min-h-0">
|
||||
<UserListPanel fillHeight />
|
||||
</div>
|
||||
<div className="h-full min-h-0">
|
||||
<ChatPanel fillHeight />
|
||||
</div>
|
||||
</div>
|
||||
<TelemetryPanel />
|
||||
<LogPanel />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useControlSystem } from '../controls/index.js';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import TopDownMap from './TopDownMap.jsx';
|
||||
import RoverRoster from './RoverRoster.jsx';
|
||||
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
|
||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
@@ -46,7 +45,7 @@ export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ControlSummary({ showRoster = true }) {
|
||||
export default function ControlSummary() {
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
} = useControlSystem();
|
||||
@@ -57,7 +56,7 @@ export default function ControlSummary({ showRoster = true }) {
|
||||
|
||||
return (
|
||||
<section className="panel-section">
|
||||
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] md:min-h-[22rem]">
|
||||
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||
<div className="flex h-full w-full items-stretch justify-center">
|
||||
<div className="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
@@ -70,14 +69,6 @@ export default function ControlSummary({ showRoster = true }) {
|
||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showRoster ? (
|
||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<div>
|
||||
<ReplaySourcesPanel />
|
||||
</div>
|
||||
<RoverRosterPanel />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSession } from "../context/SessionContext";
|
||||
import { FaDiscord } from "react-icons/fa";
|
||||
|
||||
export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
||||
export default function DiscordInviteButton({ text = "Join our Discord!", className = "" }) {
|
||||
const { session } = useSession();
|
||||
const discordInvite = session?.discord?.invite || null;
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
||||
href={discordInvite}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center"
|
||||
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center ${className}`}
|
||||
// animated rainbow backgound
|
||||
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
||||
>
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
idleSkipSeconds={idleSkipSeconds}
|
||||
/>
|
||||
) : (
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||
<p>You are not assigned to a rover.</p>
|
||||
{/* colored button to visit the spectator page */}
|
||||
<p className="mt-2">
|
||||
|
||||
@@ -34,7 +34,7 @@ function EntityRow({ entity, connected, onToggle }) {
|
||||
type="button"
|
||||
onClick={() => onToggle(entity.id)}
|
||||
disabled={disableToggle}
|
||||
className={`flex min-w-[12rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
|
||||
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
@@ -83,11 +83,11 @@ export default function HomeAssistantControls() {
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<header className="flex flex-wrap items-start justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<p>Light Controls</p>
|
||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-300 background-black">
|
||||
<div className="flex w-full flex-wrap items-center gap-1 text-xs text-slate-300">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>On</span>
|
||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSession } from "../context/SessionContext";
|
||||
import { FaCoffee } from "react-icons/fa";
|
||||
|
||||
export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
||||
export default function KoFiButton({ text = "Support me on Ko-fi!", className = "" }) {
|
||||
const { session } = useSession();
|
||||
const kofiLink = session?.kofi?.link || null;
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
||||
href={kofiLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center"
|
||||
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center ${className}`}
|
||||
>
|
||||
<FaCoffee className="mr-1" />
|
||||
{text}
|
||||
|
||||
@@ -31,16 +31,20 @@ export default function NicknameForm({ compact = false }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex w-full gap-0.5" onSubmit={handleSave}>
|
||||
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-0.5" onSubmit={handleSave}>
|
||||
<input
|
||||
className="field-input flex-1"
|
||||
className="field-input flex-1 min-w-0"
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
maxLength={32}
|
||||
placeholder="Enter a nickname"
|
||||
disabled={!canSetNickname}
|
||||
/>
|
||||
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSetNickname || saving}
|
||||
className="button-dark shrink-0 whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -16,7 +16,7 @@ function normalizeSources(list = []) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
|
||||
const { session, triggerReplay } = useSession();
|
||||
const sources = normalizeSources(session?.replaySources || []);
|
||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||
@@ -100,13 +100,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const containerClass = fillHeight ? 'h-full flex flex-col' : '';
|
||||
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
||||
|
||||
return (
|
||||
<section className="panel-section p-0.75 text-sm">
|
||||
<section className={`panel-section p-0.75 text-sm ${containerClass}`}>
|
||||
<header className="panel-muted flex items-center justify-between text-xs">
|
||||
<span>Replay Sources</span>
|
||||
<span>{sources.length}</span>
|
||||
</header>
|
||||
<div className="grid gap-0.5 md:grid-cols-2">
|
||||
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
import TelemetryPanel from './TelemetryPanel.jsx';
|
||||
import ControlSummary from './ControlSummary.jsx';
|
||||
import { RoverRosterPanel } from './ControlSummary.jsx';
|
||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||
import SettingsPanel from './SettingsPanel.jsx';
|
||||
import HelpPanel from './HelpPanel.jsx';
|
||||
import ChatPanel from './ChatPanel.jsx';
|
||||
import UserListPanel, { NicknameLinksPanel } from './UserListPanel.jsx';
|
||||
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||
import TopDownMap from './TopDownMap.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
state: { roverId },
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
|
||||
return (
|
||||
<section className="panel-section">
|
||||
<div className="aspect-square w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DriveDockPanel() {
|
||||
const {
|
||||
state: { roverId },
|
||||
} = useControlSystem();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
|
||||
return (
|
||||
<section className="panel-section flex h-full flex-col">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
return (
|
||||
@@ -18,10 +53,21 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<TabPanels>
|
||||
<TabPanel id="telemetry">
|
||||
<div className="space-y-0.5">
|
||||
<ControlSummary />
|
||||
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)]">
|
||||
<TopDownMapPanel />
|
||||
<DriveDockPanel />
|
||||
</div>
|
||||
<div className="grid gap-0.5 grid-cols-[minmax(0,0.6fr)_minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<NicknameLinksPanel compact />
|
||||
<RoverRosterPanel />
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
</div>
|
||||
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)] h-[14rem]">
|
||||
<ChatPanel fillHeight />
|
||||
<UserListPanel compact hideNicknameForm fillHeight />
|
||||
</div>
|
||||
<HomeAssistantControls />
|
||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="help">
|
||||
|
||||
@@ -5,9 +5,11 @@ export default function SessionSnapshot() {
|
||||
const { session } = useSession();
|
||||
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
||||
return (
|
||||
<div className="panel-section space-y-0.5 text-xs">
|
||||
<div className="panel-section flex min-h-0 flex-col gap-0.5 text-xs">
|
||||
<p className="text-sm text-slate-400">Session snapshot</p>
|
||||
<pre className="surface h-64 overflow-y-auto font-mono text-[0.7rem] text-lime-300">{payload}</pre>
|
||||
<pre className="surface min-h-0 flex-1 overflow-y-auto font-mono text-[0.7rem] text-lime-300">
|
||||
{payload}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ import NicknameForm from './NicknameForm.jsx';
|
||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||
import KoFiButton from './KoFiButton.jsx';
|
||||
|
||||
export function NicknameLinksPanel({ compact = false }) {
|
||||
return (
|
||||
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
||||
<div className="surface flex w-full items-center">
|
||||
<NicknameForm compact={compact} />
|
||||
</div>
|
||||
<div className="grid flex-1 min-h-0 gap-0.5 grid-rows-2">
|
||||
<DiscordInviteButton className="h-full" />
|
||||
<KoFiButton className="h-full" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
@@ -28,7 +42,13 @@ function formatLabel(user, selfId) {
|
||||
return base;
|
||||
}
|
||||
|
||||
export default function UserListPanel({ hideNicknameForm = false, hideHeader = false, className = '', fillHeight = false }) {
|
||||
export default function UserListPanel({
|
||||
hideNicknameForm = false,
|
||||
hideHeader = false,
|
||||
className = '',
|
||||
fillHeight = false,
|
||||
compact = false,
|
||||
}) {
|
||||
const { session, setNickname } = useSession();
|
||||
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const lastSyncedSocketRef = useRef(null);
|
||||
@@ -93,9 +113,19 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
return Math.ceil(ms / 1000);
|
||||
}, []);
|
||||
|
||||
const baseListClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
||||
const turnsListClass = isTurnsMode && fillHeight ? 'max-h-40 overflow-y-auto' : baseListClass;
|
||||
const usersListClass = isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
|
||||
const baseListClass = fillHeight
|
||||
? 'flex-1 min-h-0 overflow-y-auto'
|
||||
: compact
|
||||
? 'h-28 overflow-y-auto'
|
||||
: 'h-48 overflow-y-auto';
|
||||
const turnsListClass =
|
||||
isTurnsMode && fillHeight
|
||||
? 'max-h-40 overflow-y-auto'
|
||||
: isTurnsMode && compact
|
||||
? 'max-h-32 overflow-y-auto'
|
||||
: baseListClass;
|
||||
const usersListClass =
|
||||
isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
|
||||
|
||||
const renderUserList = () =>
|
||||
sorted.length === 0 ? (
|
||||
@@ -107,7 +137,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
return (
|
||||
<div
|
||||
key={user.socketId}
|
||||
className="surface-muted flex items-center gap-1 text-sm"
|
||||
className={`surface-muted flex items-center gap-1 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
||||
>
|
||||
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
||||
{user.roverId ? (
|
||||
@@ -131,13 +161,13 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
>
|
||||
{!hideNicknameForm && (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-stretch gap-0.5">
|
||||
<div className="flex w-1/2 min-w-0">
|
||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<div className="min-w-0">
|
||||
<div className="surface flex w-full items-center">
|
||||
<NicknameForm />
|
||||
<NicknameForm compact={compact} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-col gap-0.5">
|
||||
<div className="grid gap-0.5 sm:grid-cols-2 md:grid-cols-1">
|
||||
<DiscordInviteButton />
|
||||
<KoFiButton />
|
||||
</div>
|
||||
@@ -148,14 +178,14 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
|
||||
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
|
||||
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`surface space-y-0.25 ${isTurnsMode ? turnsListClass : baseListClass}`}>
|
||||
<div className={`surface space-y-0.25 ${isTurnsMode ? turnsListClass : baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||
{isTurnsMode ? (
|
||||
Object.keys(turnQueues || {}).length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||
@@ -173,7 +203,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
: queue[0]
|
||||
: null;
|
||||
return (
|
||||
<div key={roverId} className="surface-muted flex flex-col gap-0.25 text-sm">
|
||||
<div key={roverId} className={`surface-muted flex flex-col gap-0.25 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
||||
{remaining != null && (
|
||||
@@ -201,7 +231,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
return (
|
||||
<span
|
||||
key={`${roverId}-${socketId}-${idx}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${highlightClass}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatLabel(user, selfId)}
|
||||
|
||||
@@ -368,7 +368,7 @@ export default function VideoTile({
|
||||
return (
|
||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||
<div
|
||||
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-video'}`}
|
||||
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}
|
||||
>
|
||||
{usingSnapshot ? (
|
||||
snapshotFeed?.objectUrl ? (
|
||||
|
||||
@@ -180,11 +180,11 @@ function FitViewportFrame({ children }) {
|
||||
<div
|
||||
className="relative flex items-center justify-center overflow-hidden bg-black"
|
||||
style={{
|
||||
width: 'min(100%, calc(100vh * 16 / 9))',
|
||||
height: 'min(100%, calc(100vw * 9 / 16))',
|
||||
width: 'min(100%, calc(100vh * 4 / 3))',
|
||||
height: 'min(100%, calc(100vw * 3 / 4))',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
aspectRatio: '4 / 3',
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
|
||||
|
||||
Reference in New Issue
Block a user