mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
big ui sweep. combined queue and roster.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B5QFIE1I.js"></script>
|
<script type="module" crossorigin src="/assets/index-D58yshkA.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BjSf29Wv.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cbwue-lt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+7
-7
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||||
import ControlSummary, { RoverRosterPanel } from './components/ControlSummary.jsx';
|
|
||||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||||
import AlertFeed from './components/AlertFeed.jsx';
|
import AlertFeed from './components/AlertFeed.jsx';
|
||||||
import MobileControls, {
|
import MobileControls, {
|
||||||
@@ -16,7 +15,7 @@ import RightPaneTabs from './components/RightPaneTabs.jsx';
|
|||||||
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
||||||
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
|
||||||
import TurnAlertListener from './components/TurnAlertListener.jsx';
|
import TurnAlertListener from './components/TurnAlertListener.jsx';
|
||||||
import UserListPanel from './components/UserListPanel.jsx';
|
import RawUserPilePanel from './components/RawUserPilePanel.jsx';
|
||||||
import ChatPanel from './components/ChatPanel.jsx';
|
import ChatPanel from './components/ChatPanel.jsx';
|
||||||
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
||||||
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
||||||
@@ -27,6 +26,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
|
|||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
|
||||||
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||||
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||||
|
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
|
||||||
|
|
||||||
function useLayoutMode() {
|
function useLayoutMode() {
|
||||||
const [mode, setMode] = useState(() => {
|
const [mode, setMode] = useState(() => {
|
||||||
@@ -61,7 +61,7 @@ function useLayoutMode() {
|
|||||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-0.5 overflow-hidden">
|
<div className="flex h-full gap-0.5 overflow-hidden">
|
||||||
<div className="flex min-w-0 flex-[1.22] 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">
|
||||||
<DriverVideoPanel />
|
<DriverVideoPanel />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
@@ -94,7 +94,7 @@ function MobileFeatureTabs({
|
|||||||
<TabPanel id="chat">
|
<TabPanel id="chat">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
<UserListPanel />
|
<RawUserPilePanel />
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="roomcontrols">
|
<TabPanel id="roomcontrols">
|
||||||
@@ -126,7 +126,7 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
|
|||||||
<MobileControls />
|
<MobileControls />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||||
<RoverRosterPanel />
|
<RoverQueuesPanel />
|
||||||
</div>
|
</div>
|
||||||
{/* <ControlSummary /> */}
|
{/* <ControlSummary /> */}
|
||||||
<MobileFeatureTabs
|
<MobileFeatureTabs
|
||||||
@@ -147,13 +147,13 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
|||||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||||
<RoverRosterPanel />
|
<RoverQueuesPanel />
|
||||||
</div>
|
</div>
|
||||||
{/* <TelemetryPanel /> */}
|
{/* <TelemetryPanel /> */}
|
||||||
</div>
|
</div>
|
||||||
<MobileLandscapeControlColumn />
|
<MobileLandscapeControlColumn />
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
<div className="flex flex-col gap-0.5 pb-0">
|
||||||
<MobileFeatureTabs
|
<MobileFeatureTabs
|
||||||
layout="mobile-landscape"
|
layout="mobile-landscape"
|
||||||
onOpenHelpOverlay={onOpenHelpOverlay}
|
onOpenHelpOverlay={onOpenHelpOverlay}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25 text-xs text-slate-300">
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
{replay.sources.map((source) => (
|
{replay.sources.map((source) => (
|
||||||
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
||||||
<span>{source.label}</span>
|
<span>{source.label}</span>
|
||||||
@@ -177,7 +177,7 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25 text-xs text-slate-300">
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
{snapshots.rovers.map((entry) => (
|
{snapshots.rovers.map((entry) => (
|
||||||
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
||||||
<span>{entry.name}</span>
|
<span>{entry.name}</span>
|
||||||
@@ -187,7 +187,7 @@ function ReplaySnapshotHealth({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25 text-xs text-slate-300">
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
{snapshots.rooms.map((entry) => (
|
{snapshots.rooms.map((entry) => (
|
||||||
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
||||||
<span>{entry.name}</span>
|
<span>{entry.name}</span>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function CameraServoPanel() {
|
|||||||
onTouchEnd={commitSlider}
|
onTouchEnd={commitSlider}
|
||||||
onPointerUp={commitSlider}
|
onPointerUp={commitSlider}
|
||||||
/>
|
/>
|
||||||
<div className="mt-0.5 flex justify-between text-xs text-slate-400">
|
<div className="mt-0 flex justify-between text-xs text-slate-400">
|
||||||
<span>{formatDegrees(min)}</span>
|
<span>{formatDegrees(min)}</span>
|
||||||
<span>{formatDegrees(max)}</span>
|
<span>{formatDegrees(max)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function ChatMessageRow({ message }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
|
className={`surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||||
isAdmin
|
isAdmin
|
||||||
? 'border border-amber-400/30'
|
? 'border border-amber-400/30'
|
||||||
: message.fromDiscord
|
: message.fromDiscord
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
||||||
<div className={`surface overflow-y-auto space-y-0.25 ${listClass}`} ref={listRef}>
|
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -93,7 +93,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
<form className="flex flex-wrap items-start gap-0.5" onSubmit={handleSend}>
|
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1 min-w-[10rem]"
|
className="field-input flex-1 min-w-[10rem]"
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -112,7 +112,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
/>
|
/>
|
||||||
{ttsSupported && (
|
{ttsSupported && (
|
||||||
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
||||||
<label className="flex items-center gap-0.25 text-xs text-slate-300">
|
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={speak}
|
checked={speak}
|
||||||
@@ -171,7 +171,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canChat || sending}
|
disabled={!canChat || sending}
|
||||||
className="button-dark disabled:opacity-50 self-start"
|
className="button-dark h-full disabled:opacity-50 self-stretch"
|
||||||
>
|
>
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ export default function ControlSummary() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||||
<div className="flex h-full w-full items-stretch justify-center">
|
<div className="flex h-full w-full items-stretch justify-center">
|
||||||
<div className="aspect-square h-full w-full">
|
<div className="aspect-square h-full w-full">
|
||||||
<TopDownMap sensors={sensors} />
|
<TopDownMap sensors={sensors} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.75">
|
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.5">
|
||||||
<div className="row-span-2">
|
<div className="row-span-2">
|
||||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||||
</div>
|
</div>
|
||||||
@@ -134,12 +134,12 @@ export function InlineCameraTilt({ keymap }) {
|
|||||||
onTouchEnd={commitSlider}
|
onTouchEnd={commitSlider}
|
||||||
onPointerUp={commitSlider}
|
onPointerUp={commitSlider}
|
||||||
/>
|
/>
|
||||||
<div className="mt-0.25 flex items-center justify-between text-[0.7rem] text-slate-400">
|
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||||
<span className="flex items-center gap-0.25">
|
<span className="flex items-center gap-0.5">
|
||||||
{downLabel ? <KeyPill label={downLabel} /> : null}
|
{downLabel ? <KeyPill label={downLabel} /> : null}
|
||||||
{formatDegrees(min)}
|
{formatDegrees(min)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-0.25">
|
<span className="flex items-center gap-0.5">
|
||||||
{formatDegrees(max)}
|
{formatDegrees(max)}
|
||||||
{upLabel ? <KeyPill label={upLabel} /> : null}
|
{upLabel ? <KeyPill label={upLabel} /> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export default function DiscordInviteButton({ text = "Join our Discord!", classN
|
|||||||
href={discordInvite}
|
href={discordInvite}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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}`}
|
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 gap-1 ${className}`}
|
||||||
// animated rainbow backgound
|
// 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"
|
// 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"
|
||||||
>
|
>
|
||||||
<FaDiscord className="mr-2" />
|
<FaDiscord className="mr-0" />
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
||||||
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
||||||
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
||||||
<StepList steps={instructions.steps} tone="indigo" />
|
<StepList steps={instructions.steps} tone="indigo" />
|
||||||
@@ -186,7 +186,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
|
|||||||
disabled={driveDisabled}
|
disabled={driveDisabled}
|
||||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
|
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.25 w-full">
|
<div className="space-y-0.5 w-full">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
|
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
|
||||||
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
||||||
@@ -217,7 +217,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
|
|||||||
onClick={handleReturnToDrive}
|
onClick={handleReturnToDrive}
|
||||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
|
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.25 w-full">
|
<div className="space-y-0.5 w-full">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
|
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
|
||||||
{!isMobile ? <ActionPill label="Click to return to driving mode" tone="amber" /> : null}
|
{!isMobile ? <ActionPill label="Click to return to driving mode" tone="amber" /> : null}
|
||||||
@@ -299,10 +299,10 @@ function StepList({ steps, tone = 'emerald' }) {
|
|||||||
{steps.map((step, idx) => (
|
{steps.map((step, idx) => (
|
||||||
<div key={step} className="text-[0.85rem] leading-snug break-words">
|
<div key={step} className="text-[0.85rem] leading-snug break-words">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<span className={`mr-0.35 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
|
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
|
||||||
<span className="align-top">{step}</span>
|
<span className="align-top">{step}</span>
|
||||||
</div>
|
</div>
|
||||||
{idx < steps.length - 1 ? <div className="mt-0.35 h-px bg-white/10" /> : null}
|
{idx < steps.length - 1 ? <div className="mt-0 h-px bg-white/10" /> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
|||||||
<p className="text-base font-semibold">{title}</p>
|
<p className="text-base font-semibold">{title}</p>
|
||||||
<p className="text-sm text-white/90">{description}</p>
|
<p className="text-sm text-white/90">{description}</p>
|
||||||
{/* center statuses in button */}
|
{/* center statuses in button */}
|
||||||
<div className="mt-0.5 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
<div className="mt-0 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
||||||
{statuses.map((status) => (
|
{statuses.map((status) => (
|
||||||
<span
|
<span
|
||||||
key={status.label}
|
key={status.label}
|
||||||
@@ -87,7 +87,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
|
{footnote && <p className="mt-0 text-xs text-emerald-50/80">{footnote}</p>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
<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>
|
<p>You are not assigned to a rover.</p>
|
||||||
{/* colored button to visit the spectator page */}
|
{/* colored button to visit the spectator page */}
|
||||||
<p className="mt-2">
|
<p className="mt-0">
|
||||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||||
Click here to visit the spectator page.
|
Click here to visit the spectator page.
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
|
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
|
||||||
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
|
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
|
||||||
<div className="space-y-2 p-4 text-sm text-slate-100">
|
<div className="space-y-0.5 p-4 text-sm text-slate-100">
|
||||||
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
|
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
|
||||||
{isIOSMode ? (
|
{isIOSMode ? (
|
||||||
<p className="text-slate-300">
|
<p className="text-slate-300">
|
||||||
@@ -18,7 +18,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
|||||||
via the system back or home gesture.
|
via the system back or home gesture.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-1 text-sm">
|
<div className="flex justify-end gap-0.5 pt-1 text-sm">
|
||||||
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
||||||
{isIOSMode ? 'Got it' : 'Not now'}
|
{isIOSMode ? 'Got it' : 'Not now'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
|
|||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
className="mt-0.5 w-full accent-emerald-400"
|
className="mt-0 w-full accent-emerald-400"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -187,7 +187,7 @@ export default function GamepadMappingSettings() {
|
|||||||
: 'Connect a controller to configure.'}
|
: 'Connect a controller to configure.'}
|
||||||
</p>
|
</p>
|
||||||
{capture && (
|
{capture && (
|
||||||
<p className="mt-0.5 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ function renderLine(line, keymap, idx) {
|
|||||||
function Hero({ hero, keymap }) {
|
function Hero({ hero, keymap }) {
|
||||||
if (!hero) return null;
|
if (!hero) return null;
|
||||||
return (
|
return (
|
||||||
<div className="surface space-y-0.25 px-0.5 py-0.5">
|
<div className="surface space-y-0.5 px-0.5 py-0.5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-white">{hero.title}</p>
|
<p className="text-sm font-semibold text-white">{hero.title}</p>
|
||||||
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
|
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{hero.chips && (
|
{hero.chips && (
|
||||||
<div className="flex flex-wrap gap-0.25 text-[0.7rem] text-slate-200">
|
<div className="flex flex-wrap gap-0.5 text-[0.7rem] text-slate-200">
|
||||||
{hero.chips.map((chip) => (
|
{hero.chips.map((chip) => (
|
||||||
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
|
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
|
||||||
{chip}
|
{chip}
|
||||||
@@ -57,7 +57,7 @@ function Hero({ hero, keymap }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hero.bullets && (
|
{hero.bullets && (
|
||||||
<ul className="space-y-0.25 text-[0.85rem] text-slate-200">
|
<ul className="space-y-0.5 text-[0.85rem] text-slate-200">
|
||||||
{hero.bullets.map((line, idx) => {
|
{hero.bullets.map((line, idx) => {
|
||||||
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
|
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
|
||||||
return (
|
return (
|
||||||
@@ -74,13 +74,13 @@ function Hero({ hero, keymap }) {
|
|||||||
|
|
||||||
function ListBlock({ block, keymap }) {
|
function ListBlock({ block, keymap }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
||||||
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
|
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
|
||||||
{block.items.map((item, idx) => {
|
{block.items.map((item, idx) => {
|
||||||
const key = `item-${idx}`;
|
const key = `item-${idx}`;
|
||||||
return (
|
return (
|
||||||
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.25 px-0.5 py-0.25">
|
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.5 px-0.5 py-0.25">
|
||||||
{renderLine(item, keymap, idx)}
|
{renderLine(item, keymap, idx)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -93,10 +93,10 @@ function ListBlock({ block, keymap }) {
|
|||||||
function CalloutBlock({ block }) {
|
function CalloutBlock({ block }) {
|
||||||
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
|
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
|
||||||
return (
|
return (
|
||||||
<div className={`surface space-y-0.25 border ${toneClass} px-0.5 py-0.5`}>
|
<div className={`surface space-y-0.5 border ${toneClass} px-0.5 py-0.5`}>
|
||||||
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
|
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
|
||||||
{block.body && (
|
{block.body && (
|
||||||
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
|
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
|
||||||
{block.body.map((line, idx) => (
|
{block.body.map((line, idx) => (
|
||||||
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
|
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
|
||||||
{renderLine(line, {})}
|
{renderLine(line, {})}
|
||||||
@@ -110,9 +110,9 @@ function CalloutBlock({ block }) {
|
|||||||
|
|
||||||
function KeyboardGroup({ group, keymap }) {
|
function KeyboardGroup({ group, keymap }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25 surface">
|
<div className="space-y-0.5 surface">
|
||||||
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
|
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
|
||||||
<div className="space-y-0.25 px-0.5 pb-0.25">
|
<div className="space-y-0.5 px-0.5 pb-0.25">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
|
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
|
||||||
<span className="text-slate-200">{item.label}</span>
|
<span className="text-slate-200">{item.label}</span>
|
||||||
@@ -127,7 +127,7 @@ function KeyboardGroup({ group, keymap }) {
|
|||||||
function KeyboardBlock({ block, keymap }) {
|
function KeyboardBlock({ block, keymap }) {
|
||||||
if (!block) return null;
|
if (!block) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-200">
|
<div className="flex items-center justify-between text-xs text-slate-200">
|
||||||
<span className="font-semibold">{block.title}</span>
|
<span className="font-semibold">{block.title}</span>
|
||||||
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
|
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
|
||||||
@@ -144,9 +144,9 @@ function KeyboardBlock({ block, keymap }) {
|
|||||||
function GamepadBlock({ block }) {
|
function GamepadBlock({ block }) {
|
||||||
if (!block) return null;
|
if (!block) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
|
||||||
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
|
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
|
||||||
{block.items?.map((line, idx) => (
|
{block.items?.map((line, idx) => (
|
||||||
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
|
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
|
||||||
{renderLine(line, {})}
|
{renderLine(line, {})}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function HelpOverlay({ visible, layout, onClose, showOnLoad, onTo
|
|||||||
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
|
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
|
||||||
<span className="font-semibold">Help & controls</span>
|
<span className="font-semibold">Help & controls</span>
|
||||||
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
|
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
|
||||||
<label className="flex items-center gap-0.25">
|
<label className="flex items-center gap-0.5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!showOnLoad}
|
checked={!showOnLoad}
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ function EntityRow({ entity, connected, onToggle }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(entity.id)}
|
onClick={() => onToggle(entity.id)}
|
||||||
disabled={disableToggle}
|
disabled={disableToggle}
|
||||||
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`}
|
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-0.5 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="min-w-0">
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-0.5 text-sm">
|
||||||
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
|
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
|
||||||
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
|
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
<div className="flex items-center gap-0.5 text-xs text-slate-400">
|
||||||
<StatusBadge label={statusLabel} tone={statusTone} />
|
<StatusBadge label={statusLabel} tone={statusTone} />
|
||||||
{!connected && <span className="text-amber-200"> · Offline</span>}
|
{!connected && <span className="text-amber-200"> · Offline</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -84,10 +84,10 @@ export default function HomeAssistantControls() {
|
|||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<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">
|
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<p>Light Controls</p>
|
<p>Light Controls</p>
|
||||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
<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 items-center gap-0.5 text-xs text-slate-300 background-black">
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
<span>On</span>
|
<span>On</span>
|
||||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
||||||
<div className="mt-0.5 flex items-center gap-0.5">
|
<div className="mt-0 flex items-center gap-0.5">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export default function KoFiButton({ text = "Support me on Ko-fi!", className =
|
|||||||
href={kofiLink}
|
href={kofiLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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}`}
|
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 gap-1 ${className}`}
|
||||||
>
|
>
|
||||||
<FaCoffee className="mr-1" />
|
<FaCoffee className="mr-0" />
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function MobileJoystickPanel({ layout }) {
|
|||||||
step={0.5}
|
step={0.5}
|
||||||
value={cameraValue}
|
value={cameraValue}
|
||||||
onChange={handleCameraSlider}
|
onChange={handleCameraSlider}
|
||||||
className="mt-0.5 w-full accent-cyan-400"
|
className="mt-0 w-full accent-cyan-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function NicknameForm({ compact = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-0.5" onSubmit={handleSave}>
|
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-stretch gap-0.5" onSubmit={handleSave}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1 min-w-0"
|
className="field-input flex-1 min-w-0"
|
||||||
value={nicknameInput}
|
value={nicknameInput}
|
||||||
@@ -43,7 +43,7 @@ export default function NicknameForm({ compact = false }) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSetNickname || saving}
|
disabled={!canSetNickname || saving}
|
||||||
className="button-dark shrink-0 whitespace-nowrap disabled:opacity-50"
|
className="button-dark h-full shrink-0 whitespace-nowrap px-0.5 py-0 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
import NicknameForm from './NicknameForm.jsx';
|
||||||
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
|
import KoFiButton from './KoFiButton.jsx';
|
||||||
|
|
||||||
|
function roleColors(role) {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
case 'lockdown':
|
||||||
|
case 'lockdown-admin':
|
||||||
|
return 'text-amber-300';
|
||||||
|
case 'spectator':
|
||||||
|
return 'text-slate-400';
|
||||||
|
default:
|
||||||
|
return 'text-sky-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(user, selfId) {
|
||||||
|
if (!user) return '';
|
||||||
|
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
|
||||||
|
if (user.socketId && user.socketId === selfId) {
|
||||||
|
return `${base} (you)`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RawUserPilePanel({
|
||||||
|
hideNicknameForm = false,
|
||||||
|
hideHeader = false,
|
||||||
|
className = '',
|
||||||
|
fillHeight = false,
|
||||||
|
compact = false,
|
||||||
|
}) {
|
||||||
|
const { session, setNickname } = useSession();
|
||||||
|
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
|
const lastSyncedSocketRef = useRef(null);
|
||||||
|
const socket = useSocket();
|
||||||
|
const canSetNickname = session?.role !== 'spectator';
|
||||||
|
const users = session?.users ?? [];
|
||||||
|
const selfId = session?.socketId || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canSetNickname) return;
|
||||||
|
if (!session?.socketId) return;
|
||||||
|
const nicknameInput = value.nickname || '';
|
||||||
|
if (!nicknameInput) return;
|
||||||
|
if (session.socketId === lastSyncedSocketRef.current) return;
|
||||||
|
const currentId = session.socketId;
|
||||||
|
setNickname(nicknameInput).then(() => {
|
||||||
|
lastSyncedSocketRef.current = currentId;
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return undefined;
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (!canSetNickname) return;
|
||||||
|
const nick = (value.nickname || '').trim();
|
||||||
|
if (!nick) return;
|
||||||
|
setNickname(nick).then(() => {
|
||||||
|
lastSyncedSocketRef.current = session?.socketId || null;
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
socket.on('connect', handleConnect);
|
||||||
|
return () => socket.off('connect', handleConnect);
|
||||||
|
}, [canSetNickname, setNickname, socket, value.nickname, session?.socketId]);
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...users].sort((a, b) => {
|
||||||
|
if (a.socketId === selfId) return -1;
|
||||||
|
if (b.socketId === selfId) return 1;
|
||||||
|
return (a.nickname || '').localeCompare(b.nickname || '');
|
||||||
|
}),
|
||||||
|
[selfId, users],
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseListClass = fillHeight
|
||||||
|
? 'flex-1 min-h-0 overflow-y-auto'
|
||||||
|
: compact
|
||||||
|
? 'h-28 overflow-y-auto'
|
||||||
|
: 'h-48 overflow-y-auto';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
|
||||||
|
>
|
||||||
|
{!hideNicknameForm && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<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 px-0 py-0">
|
||||||
|
<NicknameForm compact={compact} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-0.5 sm:grid-cols-2 md:grid-cols-1">
|
||||||
|
<DiscordInviteButton />
|
||||||
|
<KoFiButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 ${compact ? 'text-xs' : ''}`}>
|
||||||
|
<span>Users</span>
|
||||||
|
<span className="text-xs text-slate-500">{sorted.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
|
||||||
|
>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Waiting for users…</p>
|
||||||
|
) : (
|
||||||
|
sorted.map((user) => (
|
||||||
|
<span
|
||||||
|
key={user.socketId}
|
||||||
|
className={`rounded bg-slate-800/80 px-1 py-0.25 text-[0.7rem] ${roleColors(user.role)}`}
|
||||||
|
>
|
||||||
|
{formatLabel(user, selfId)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-dark w-full text-xs disabled:opacity-40"
|
className="button-dark w-full text-xs disabled:opacity-40"
|
||||||
@@ -132,9 +132,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
function GroupList({ title, items, selected, onToggle }) {
|
function GroupList({ title, items, selected, onToggle }) {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<div className="panel-muted text-xs">{title}</div>
|
<div className="panel-muted text-xs">{title}</div>
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { InlineCameraTilt, RoverRosterPanel } from './ControlSummary.jsx';
|
import { InlineCameraTilt } from './ControlSummary.jsx';
|
||||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||||
import SettingsPanel from './SettingsPanel.jsx';
|
import SettingsPanel from './SettingsPanel.jsx';
|
||||||
import HelpPanel from './HelpPanel.jsx';
|
import HelpPanel from './HelpPanel.jsx';
|
||||||
import ChatPanel from './ChatPanel.jsx';
|
import ChatPanel from './ChatPanel.jsx';
|
||||||
import UserListPanel, { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
|
import { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
|
||||||
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||||
import TopDownMap from './TopDownMap.jsx';
|
import TopDownMap from './TopDownMap.jsx';
|
||||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
|
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
||||||
|
import RawUserPilePanel from './RawUserPilePanel.jsx';
|
||||||
|
|
||||||
function TopDownMapPanel() {
|
function TopDownMapPanel() {
|
||||||
const {
|
const {
|
||||||
@@ -60,15 +62,15 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
<DriveDockPanel />
|
<DriveDockPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]">
|
||||||
<RoverRosterPanel />
|
<RoverQueuesPanel />
|
||||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||||
<LinkButtonsPanel />
|
<LinkButtonsPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)] h-[14rem]">
|
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
|
||||||
<ChatPanel fillHeight />
|
<ChatPanel fillHeight />
|
||||||
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)]">
|
<div className="grid min-h-0 gap-0.5 grid-rows-[minmax(0,1fr)_auto]">
|
||||||
|
<RawUserPilePanel compact hideNicknameForm fillHeight />
|
||||||
<NicknameEntryPanel compact />
|
<NicknameEntryPanel compact />
|
||||||
<UserListPanel compact hideNicknameForm fillHeight />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HomeAssistantControls />
|
<HomeAssistantControls />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function RoomCameraFeed({ feed, label }) {
|
|||||||
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute bottom-0 left-0 m-0.5 flex items-center gap-1 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
|
<div className="pointer-events-none absolute bottom-0 left-0 m-0 flex items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
|
||||||
<span className={`h-2 w-2 rounded-full ${blink ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
<span className={`h-2 w-2 rounded-full ${blink ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
||||||
<span>{statusText}</span>
|
<span>{statusText}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ export default function RoomCameraPanel({
|
|||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-base">
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<p>Room cameras</p>
|
<p>Room cameras</p>
|
||||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
<div className="flex flex-wrap items-center gap-0.5 text-xs">
|
||||||
{showLayoutToggle && (
|
{showLayoutToggle && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-500">Layout:</span>
|
<span className="text-slate-500">Layout:</span>
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
function classNames(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBattery(rover) {
|
||||||
|
const percent = rover?.batteryState?.percentDisplay;
|
||||||
|
if (percent == null) return '--';
|
||||||
|
return `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryClass(rover) {
|
||||||
|
if (!rover?.batteryState) return 'text-slate-400';
|
||||||
|
if (rover.batteryState.urgentActive) return 'text-red-400';
|
||||||
|
if (rover.batteryState.warnActive) return 'text-amber-300';
|
||||||
|
return 'text-emerald-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleColors(role) {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
case 'lockdown':
|
||||||
|
case 'lockdown-admin':
|
||||||
|
return 'text-amber-300';
|
||||||
|
case 'spectator':
|
||||||
|
return 'text-slate-400';
|
||||||
|
default:
|
||||||
|
return 'text-sky-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(user, selfId) {
|
||||||
|
if (!user) return '';
|
||||||
|
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
|
||||||
|
if (user.socketId && user.socketId === selfId) {
|
||||||
|
return `${base} (you)`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||||
|
const { session, requestControl } = useSession();
|
||||||
|
const [pending, setPending] = useState({});
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
|
||||||
|
const roster = session?.roster ?? [];
|
||||||
|
const turnQueues = session?.turnQueues ?? {};
|
||||||
|
const users = session?.users ?? [];
|
||||||
|
const selfId = session?.socketId || null;
|
||||||
|
const hasDeadlines = useMemo(
|
||||||
|
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
||||||
|
[turnQueues],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDeadlines) return undefined;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [hasDeadlines]);
|
||||||
|
|
||||||
|
const rosterItems = useMemo(() => {
|
||||||
|
const known = new Set(roster.map((rover) => String(rover.id)));
|
||||||
|
const extra = Object.keys(turnQueues || {})
|
||||||
|
.filter((id) => !known.has(String(id)))
|
||||||
|
.map((id) => ({ id, name: id, locked: false, batteryState: null }));
|
||||||
|
return [...roster, ...extra];
|
||||||
|
}, [roster, turnQueues]);
|
||||||
|
|
||||||
|
async function handleRequest(targetRoverId) {
|
||||||
|
if (!targetRoverId) return;
|
||||||
|
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||||
|
try {
|
||||||
|
await requestControl(targetRoverId);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupUser = (socketId) =>
|
||||||
|
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
{title && <p className="text-sm text-slate-400">{title}</p>}
|
||||||
|
{rosterItems.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5 text-sm">
|
||||||
|
{rosterItems.map((rover) => {
|
||||||
|
const roverId = String(rover.id);
|
||||||
|
const info = turnQueues?.[roverId] || null;
|
||||||
|
const queue = info?.queue || [];
|
||||||
|
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||||
|
const remainingSeconds =
|
||||||
|
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||||
|
const currentId = info?.current || null;
|
||||||
|
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||||
|
const nextId =
|
||||||
|
queue.length > 1
|
||||||
|
? currentIdx >= 0
|
||||||
|
? queue[(currentIdx + 1) % queue.length]
|
||||||
|
: queue[0]
|
||||||
|
: null;
|
||||||
|
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||||
|
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||||
|
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||||
|
const locked = Boolean(rover.locked);
|
||||||
|
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||||
|
const buttonLabel = locked ? lockLabel : pending[roverId] ? '...' : 'request';
|
||||||
|
const canClickRow = canRequest && !locked && !pending[roverId];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={rover.id}
|
||||||
|
className={classNames(
|
||||||
|
'surface flex flex-wrap items-start justify-between gap-0.5',
|
||||||
|
canClickRow && 'cursor-pointer',
|
||||||
|
locked && 'bg-red-900/40',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canClickRow) return;
|
||||||
|
handleRequest(rover.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<p className="text-slate-200">{rover.name}</p>
|
||||||
|
{showTimer ? (
|
||||||
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||||
|
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className={classNames('text-[0.75rem] font-semibold', batteryClass(rover))}>
|
||||||
|
{formatBattery(rover)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<p className="text-[0.7rem] text-slate-500">No queue.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
{queue.map((socketId, idx) => {
|
||||||
|
const user = lookupUser(socketId);
|
||||||
|
const isCurrent = socketId === currentId;
|
||||||
|
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||||
|
const highlightClass = isCurrent
|
||||||
|
? 'bg-sky-600 text-white ring-2 ring-amber-300'
|
||||||
|
: isNext
|
||||||
|
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
|
||||||
|
: 'bg-slate-800 text-slate-200';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${roverId}-${socketId}-${idx}`}
|
||||||
|
className={`flex items-center gap-0.5 rounded px-1 text-[0.7rem] ${highlightClass}`}
|
||||||
|
>
|
||||||
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
|
{formatLabel(user, selfId)}
|
||||||
|
</span>
|
||||||
|
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||||
|
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canRequest ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleRequest(rover.id);
|
||||||
|
}}
|
||||||
|
disabled={pending[roverId] || locked}
|
||||||
|
className={classNames(
|
||||||
|
'button-dark disabled:opacity-40',
|
||||||
|
locked && 'bg-red-600/70 text-white hover:bg-red-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export default function RoverRoster({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-200">{rover.name}</p>
|
<p className="text-slate-200">{rover.name}</p>
|
||||||
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-1">
|
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
|
||||||
<span>{rover.locked ? 'locked' : 'free'}</span>
|
<span>{rover.locked ? 'locked' : 'free'}</span>
|
||||||
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
||||||
<span className={classNames('font-semibold', batteryClass(rover))}>
|
<span className={classNames('font-semibold', batteryClass(rover))}>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TabPanels({ children, className = '' }) {
|
export function TabPanels({ children, className = '' }) {
|
||||||
return <div className={classNames('mt-0.5 space-y-0.5', className)}>{children}</div>;
|
return <div className={classNames('mt-0 space-y-0.5', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabPanel({ id, children, keepMounted = false }) {
|
export function TabPanel({ id, children, keepMounted = false }) {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ function DetailCard({ title, children }) {
|
|||||||
|
|
||||||
function ValueRow({ label, value }) {
|
function ValueRow({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
<span className="text-slate-300">{label}</span>
|
<span className="text-slate-300">{label}</span>
|
||||||
<span className="text-slate-100">{value}</span>
|
<span className="text-slate-100">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,16 +212,16 @@ function DockMiniStatus({ sensors }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-1 text-xs text-slate-200">
|
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-400">L</span>
|
<span className="text-slate-400">L</span>
|
||||||
{badge(left)}
|
{badge(left)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-400">O</span>
|
<span className="text-slate-400">O</span>
|
||||||
{badge(omni)}
|
{badge(omni)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-400">R</span>
|
<span className="text-slate-400">R</span>
|
||||||
{badge(right)}
|
{badge(right)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import KoFiButton from './KoFiButton.jsx';
|
|||||||
export function NicknameEntryPanel({ compact = false }) {
|
export function NicknameEntryPanel({ compact = false }) {
|
||||||
return (
|
return (
|
||||||
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
<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">
|
<div className="surface flex w-full items-center px-0 py-0">
|
||||||
<NicknameForm compact={compact} />
|
<NicknameForm compact={compact} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -155,7 +155,7 @@ export default function UserListPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.socketId}
|
key={user.socketId}
|
||||||
className={`surface-muted flex items-center gap-1 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
className={`surface-muted flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
||||||
>
|
>
|
||||||
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
||||||
{user.roverId ? (
|
{user.roverId ? (
|
||||||
@@ -181,7 +181,7 @@ export default function UserListPanel({
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="surface flex w-full items-center">
|
<div className="surface flex w-full items-center px-0 py-0">
|
||||||
<NicknameForm compact={compact} />
|
<NicknameForm compact={compact} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,7 +230,7 @@ export default function UserListPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showQueuesSection ? (
|
{showQueuesSection ? (
|
||||||
<div className={`surface space-y-0.25 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
<div className={`surface space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
{Object.keys(turnQueues || {}).length === 0 ? (
|
{Object.keys(turnQueues || {}).length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -247,8 +247,8 @@ export default function UserListPanel({
|
|||||||
: queue[0]
|
: queue[0]
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={roverId} className={`surface-muted flex flex-col gap-0.25 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
<div key={roverId} className={`surface-muted flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
||||||
{remaining != null && (
|
{remaining != null && (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
||||||
@@ -259,7 +259,7 @@ export default function UserListPanel({
|
|||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{queue.map((socketId, idx) => {
|
{queue.map((socketId, idx) => {
|
||||||
const user = lookupUser(socketId);
|
const user = lookupUser(socketId);
|
||||||
const isCurrent = socketId === currentId;
|
const isCurrent = socketId === currentId;
|
||||||
@@ -297,18 +297,18 @@ export default function UserListPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showUsersSection ? (
|
{showUsersSection ? (
|
||||||
<div className={`surface space-y-0.25 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
<div className={`surface space-y-0.5 px-0 pb-0 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
{renderUserList()}
|
{renderUserList()}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showUsersSecondary ? (
|
{showUsersSecondary ? (
|
||||||
<div className={`space-y-0.25 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
<div className={`space-y-0.5 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`surface space-y-0.25 ${usersListClass}`}>{renderUserList()}</div>
|
<div className={`surface space-y-0.5 px-0 pb-0 ${usersListClass}`}>{renderUserList()}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ export default function VideoTile({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-center">{qualityNotice}</div>
|
<div className="text-center">{qualityNotice}</div>
|
||||||
<div className="pointer-events-auto mt-1">
|
<div className="pointer-events-auto mt-0">
|
||||||
<DiscordInviteButton text={'Join our Discord server while you wait!'} />
|
<DiscordInviteButton text={'Join our Discord server while you wait!'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,7 +441,7 @@ export default function VideoTile({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!showVerticalBattery && (
|
{!showVerticalBattery && (
|
||||||
<div className="space-y-0.25">
|
<div className="space-y-0.5">
|
||||||
<LightBumpBars sensors={sensors} />
|
<LightBumpBars sensors={sensors} />
|
||||||
<BatteryBar visual={batteryVisual} />
|
<BatteryBar visual={batteryVisual} />
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +484,7 @@ function BatteryBarVertical({ visual }) {
|
|||||||
style={{ height: `${visual.percentDisplay}%` }}
|
style={{ height: `${visual.percentDisplay}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-0.5 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
|
<span className="mt-0 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,7 +510,7 @@ function LightBumpBars({ sensors }) {
|
|||||||
const barHeight = 12;
|
const barHeight = 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center gap-1">
|
<div className="flex w-full items-center justify-center gap-0.5">
|
||||||
{values.map((v, idx) => {
|
{values.map((v, idx) => {
|
||||||
const t = eased(v);
|
const t = eased(v);
|
||||||
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
|
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
|
||||||
@@ -600,15 +600,15 @@ function HudOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
<div className="flex flex-col gap-[1px] leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span>Status: {status}</span>
|
<span>Status: {status}</span>
|
||||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.1 leading-tight">
|
<div className="space-y-0.5 leading-tight">
|
||||||
<span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
|
<span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
|
||||||
Telemetry
|
Telemetry
|
||||||
</span>
|
</span>
|
||||||
@@ -639,7 +639,7 @@ function HudOverlay({
|
|||||||
|
|
||||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
||||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
@@ -652,7 +652,7 @@ function HudOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
<div className="flex flex-col gap-[1px] leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span>Status: {status}</span>
|
<span>Status: {status}</span>
|
||||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -700,7 +700,7 @@ function TurnCueOverlay({
|
|||||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
||||||
<div className={`flex flex-col items-center gap-1 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
||||||
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
||||||
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
||||||
{showCountdown ? (
|
{showCountdown ? (
|
||||||
@@ -732,7 +732,7 @@ function OvercurrentOverlay({ motors, compact = false }) {
|
|||||||
>
|
>
|
||||||
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
||||||
<div>OVERCURRENT</div>
|
<div>OVERCURRENT</div>
|
||||||
<div className={`mt-0.5 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
|
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -796,7 +796,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
return { speak: true, engine, voice };
|
return { speak: true, engine, voice };
|
||||||
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
||||||
const containerClass = compact
|
const containerClass = compact
|
||||||
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.25 rounded bg-black/70 px-0.4 py-0.2'
|
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
|
||||||
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
||||||
const inputClass = compact
|
const inputClass = compact
|
||||||
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function DriveModeToggle({ size = 'default' }) {
|
|||||||
{currentLabel}
|
{currentLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 grid grid-cols-2 gap-0.5 text-xs">
|
<div className="mt-0 grid grid-cols-2 gap-0.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDrive}
|
onClick={handleDrive}
|
||||||
|
|||||||
+2
-2
@@ -44,11 +44,11 @@ body {
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.panel {
|
.panel {
|
||||||
@apply bg-black text-white p-0.5;
|
@apply bg-black text-white p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-section {
|
.panel-section {
|
||||||
@apply bg-neutral-900 text-white p-0.5;
|
@apply bg-neutral-900 text-white p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-muted {
|
.panel-muted {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function MiniSummaryContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-screen overflow-hidden bg-black p-0.5 text-slate-100 flex flex-col gap-0.5">
|
<div className="relative h-screen w-screen overflow-hidden bg-black p-0 text-slate-100 flex flex-col gap-0.5">
|
||||||
<ChatOverlay />
|
<ChatOverlay />
|
||||||
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
|
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
{!spectatorReady ? (
|
{!spectatorReady ? (
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
|||||||
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||||
import VideoTile from '../components/VideoTile.jsx';
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
|
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
|
||||||
import UserListPanel from '../components/UserListPanel.jsx';
|
|
||||||
import ChatPanel from '../components/ChatPanel.jsx';
|
import ChatPanel from '../components/ChatPanel.jsx';
|
||||||
import LogPanel from '../components/LogPanel.jsx';
|
import LogPanel from '../components/LogPanel.jsx';
|
||||||
import RoverRoster from '../components/RoverRoster.jsx';
|
|
||||||
import AlertFeed from '../components/AlertFeed.jsx';
|
import AlertFeed from '../components/AlertFeed.jsx';
|
||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
||||||
|
import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
|
||||||
|
import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
|
||||||
|
|
||||||
function formatDriverLabel({ roverId, session }) {
|
function formatDriverLabel({ roverId, session }) {
|
||||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
@@ -28,7 +28,7 @@ function formatDriverLabel({ roverId, session }) {
|
|||||||
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
|
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
|
||||||
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
||||||
return (
|
return (
|
||||||
<article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]">
|
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
|
||||||
<div className="min-h-0 overflow-hidden rounded bg-black/20">
|
<div className="min-h-0 overflow-hidden rounded bg-black/20">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={null}
|
sessionInfo={null}
|
||||||
@@ -124,7 +124,7 @@ function SpectatorContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
|
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
|
||||||
<main className="grid min-h-screen grid-cols-1 gap-0.5 p-0.5 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<main className="grid min-h-screen grid-cols-1 gap-0.5 p-0 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto">
|
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto">
|
||||||
<RoverRow
|
<RoverRow
|
||||||
roster={roster}
|
roster={roster}
|
||||||
@@ -138,10 +138,10 @@ function SpectatorContent() {
|
|||||||
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
|
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
|
||||||
<CommunityGoalBanner layout="desktop" />
|
<CommunityGoalBanner layout="desktop" />
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
|
<RoverQueuesPanel title="Rovers" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||||
<UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" showBothTurnsAndUsers />
|
<RawUserPilePanel hideNicknameForm hideHeader fillHeight className="h-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
||||||
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
||||||
|
|||||||
Reference in New Issue
Block a user