mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -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-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-B5QFIE1I.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BjSf29Wv.css">
|
||||
<script type="module" crossorigin src="/assets/index-D58yshkA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cbwue-lt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+7
-7
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||
import ControlSummary, { RoverRosterPanel } from './components/ControlSummary.jsx';
|
||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||
import AlertFeed from './components/AlertFeed.jsx';
|
||||
import MobileControls, {
|
||||
@@ -16,7 +15,7 @@ import RightPaneTabs from './components/RightPaneTabs.jsx';
|
||||
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
||||
import HomeAssistantControls from './components/HomeAssistantControls.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 FullscreenPrompt from './components/FullscreenPrompt.jsx';
|
||||
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 useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
@@ -61,7 +61,7 @@ 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.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 />
|
||||
<TelemetryPanel />
|
||||
<LogPanel />
|
||||
@@ -94,7 +94,7 @@ function MobileFeatureTabs({
|
||||
<TabPanel id="chat">
|
||||
<div className="space-y-0.5">
|
||||
<ChatPanel />
|
||||
<UserListPanel />
|
||||
<RawUserPilePanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="roomcontrols">
|
||||
@@ -126,7 +126,7 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
|
||||
<MobileControls />
|
||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||
<RoverRosterPanel />
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
{/* <ControlSummary /> */}
|
||||
<MobileFeatureTabs
|
||||
@@ -147,13 +147,13 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||
<RoverRosterPanel />
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
{/* <TelemetryPanel /> */}
|
||||
</div>
|
||||
<MobileLandscapeControlColumn />
|
||||
</section>
|
||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
||||
<div className="flex flex-col gap-0.5 pb-0">
|
||||
<MobileFeatureTabs
|
||||
layout="mobile-landscape"
|
||||
onOpenHelpOverlay={onOpenHelpOverlay}
|
||||
|
||||
@@ -167,7 +167,7 @@ function ReplaySnapshotHealth({ health }) {
|
||||
</span>
|
||||
</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) => (
|
||||
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
||||
<span>{source.label}</span>
|
||||
@@ -177,7 +177,7 @@ function ReplaySnapshotHealth({ health }) {
|
||||
</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) => (
|
||||
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
||||
<span>{entry.name}</span>
|
||||
@@ -187,7 +187,7 @@ function ReplaySnapshotHealth({ health }) {
|
||||
</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) => (
|
||||
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
||||
<span>{entry.name}</span>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function CameraServoPanel() {
|
||||
onTouchEnd={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(max)}</span>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function ChatMessageRow({ message }) {
|
||||
|
||||
return (
|
||||
<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
|
||||
? 'border border-amber-400/30'
|
||||
: message.fromDiscord
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||
) : (
|
||||
@@ -93,7 +93,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
)}
|
||||
</div>
|
||||
{!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
|
||||
className="field-input flex-1 min-w-[10rem]"
|
||||
value={draft}
|
||||
@@ -112,7 +112,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
/>
|
||||
{ttsSupported && (
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={speak}
|
||||
@@ -171,7 +171,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canChat || sending}
|
||||
className="button-dark disabled:opacity-50 self-start"
|
||||
className="button-dark h-full disabled:opacity-50 self-stretch"
|
||||
>
|
||||
{sending ? '...' : 'Send'}
|
||||
</button>
|
||||
|
||||
@@ -56,13 +56,13 @@ export default function ControlSummary() {
|
||||
|
||||
return (
|
||||
<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="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</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">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
</div>
|
||||
@@ -134,12 +134,12 @@ export function InlineCameraTilt({ keymap }) {
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-0.25 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span className="flex items-center gap-0.25">
|
||||
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
{downLabel ? <KeyPill label={downLabel} /> : null}
|
||||
{formatDegrees(min)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.25">
|
||||
<span className="flex items-center gap-0.5">
|
||||
{formatDegrees(max)}
|
||||
{upLabel ? <KeyPill label={upLabel} /> : null}
|
||||
</span>
|
||||
|
||||
@@ -12,12 +12,12 @@ export default function DiscordInviteButton({ text = "Join our Discord!", classN
|
||||
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}`}
|
||||
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
|
||||
// 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}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
||||
return (
|
||||
<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="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-sm text-slate-200">{instructions.summary}</p>
|
||||
<StepList steps={instructions.steps} tone="indigo" />
|
||||
@@ -186,7 +186,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
|
||||
disabled={driveDisabled}
|
||||
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">
|
||||
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
|
||||
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
||||
@@ -217,7 +217,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
|
||||
onClick={handleReturnToDrive}
|
||||
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">
|
||||
<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}
|
||||
@@ -299,10 +299,10 @@ function StepList({ steps, tone = 'emerald' }) {
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step} className="text-[0.85rem] leading-snug break-words">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -75,7 +75,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
||||
<p className="text-base font-semibold">{title}</p>
|
||||
<p className="text-sm text-white/90">{description}</p>
|
||||
{/* 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) => (
|
||||
<span
|
||||
key={status.label}
|
||||
@@ -87,7 +87,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
<p>You are not assigned to a rover.</p>
|
||||
{/* 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">
|
||||
Click here to visit the spectator page.
|
||||
</a>
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
||||
return (
|
||||
<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="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>
|
||||
{isIOSMode ? (
|
||||
<p className="text-slate-300">
|
||||
@@ -18,7 +18,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
||||
via the system back or home gesture.
|
||||
</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}>
|
||||
{isIOSMode ? 'Got it' : 'Not now'}
|
||||
</button>
|
||||
|
||||
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
step={step}
|
||||
value={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>
|
||||
);
|
||||
@@ -187,7 +187,7 @@ export default function GamepadMappingSettings() {
|
||||
: 'Connect a controller to configure.'}
|
||||
</p>
|
||||
{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>
|
||||
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
||||
|
||||
@@ -40,14 +40,14 @@ function renderLine(line, keymap, idx) {
|
||||
function Hero({ hero, keymap }) {
|
||||
if (!hero) return null;
|
||||
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>
|
||||
<p className="text-sm font-semibold text-white">{hero.title}</p>
|
||||
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
|
||||
</div>
|
||||
{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) => (
|
||||
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
|
||||
{chip}
|
||||
@@ -57,7 +57,7 @@ function Hero({ hero, keymap }) {
|
||||
)}
|
||||
</div>
|
||||
{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) => {
|
||||
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
|
||||
return (
|
||||
@@ -74,13 +74,13 @@ function Hero({ hero, keymap }) {
|
||||
|
||||
function ListBlock({ block, keymap }) {
|
||||
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>
|
||||
<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) => {
|
||||
const key = `item-${idx}`;
|
||||
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)}
|
||||
</li>
|
||||
);
|
||||
@@ -93,10 +93,10 @@ function ListBlock({ block, keymap }) {
|
||||
function CalloutBlock({ block }) {
|
||||
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
|
||||
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>
|
||||
{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) => (
|
||||
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
|
||||
{renderLine(line, {})}
|
||||
@@ -110,9 +110,9 @@ function CalloutBlock({ block }) {
|
||||
|
||||
function KeyboardGroup({ group, keymap }) {
|
||||
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>
|
||||
<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) => (
|
||||
<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>
|
||||
@@ -127,7 +127,7 @@ function KeyboardGroup({ group, keymap }) {
|
||||
function KeyboardBlock({ block, keymap }) {
|
||||
if (!block) return null;
|
||||
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">
|
||||
<span className="font-semibold">{block.title}</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 }) {
|
||||
if (!block) return null;
|
||||
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>
|
||||
<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) => (
|
||||
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
|
||||
{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">
|
||||
<span className="font-semibold">Help & controls</span>
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={!showOnLoad}
|
||||
|
||||
@@ -34,14 +34,14 @@ function EntityRow({ entity, connected, onToggle }) {
|
||||
type="button"
|
||||
onClick={() => onToggle(entity.id)}
|
||||
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="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>
|
||||
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
|
||||
</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} />
|
||||
{!connected && <span className="text-amber-200"> · Offline</span>}
|
||||
</div>
|
||||
@@ -84,10 +84,10 @@ 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">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<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 items-center gap-0.5 text-xs text-slate-300 background-black">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>On</span>
|
||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||
|
||||
@@ -115,7 +115,7 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
|
||||
/>
|
||||
</div>
|
||||
{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
|
||||
type="range"
|
||||
min={min}
|
||||
|
||||
@@ -12,9 +12,9 @@ export default function KoFiButton({ text = "Support me on Ko-fi!", className =
|
||||
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}`}
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -229,7 +229,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
step={0.5}
|
||||
value={cameraValue}
|
||||
onChange={handleCameraSlider}
|
||||
className="mt-0.5 w-full accent-cyan-400"
|
||||
className="mt-0 w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function NicknameForm({ compact = false }) {
|
||||
}
|
||||
|
||||
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
|
||||
className="field-input flex-1 min-w-0"
|
||||
value={nicknameInput}
|
||||
@@ -43,7 +43,7 @@ export default function NicknameForm({ compact = false }) {
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</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="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||
</div>
|
||||
<div className="space-y-0.25">
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
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 }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="space-y-0.25">
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs">{title}</div>
|
||||
<div className="space-y-0.25">
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
||||
<input
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { InlineCameraTilt, RoverRosterPanel } from './ControlSummary.jsx';
|
||||
import { InlineCameraTilt } 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, { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
|
||||
import { LinkButtonsPanel, NicknameEntryPanel } 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';
|
||||
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
||||
import RawUserPilePanel from './RawUserPilePanel.jsx';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
@@ -60,15 +62,15 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<DriveDockPanel />
|
||||
</div>
|
||||
<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 />
|
||||
<LinkButtonsPanel />
|
||||
</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 />
|
||||
<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 />
|
||||
<UserListPanel compact hideNicknameForm fillHeight />
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{label}
|
||||
</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>{statusText}</span>
|
||||
</div>
|
||||
|
||||
@@ -68,11 +68,11 @@ export default function RoomCameraPanel({
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
{!hideHeader && (
|
||||
<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>
|
||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<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>
|
||||
<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>
|
||||
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
||||
<span className={classNames('font-semibold', batteryClass(rover))}>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
|
||||
}
|
||||
|
||||
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 }) {
|
||||
|
||||
@@ -173,7 +173,7 @@ function DetailCard({ title, children }) {
|
||||
|
||||
function ValueRow({ label, value }) {
|
||||
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-100">{value}</span>
|
||||
</div>
|
||||
@@ -212,16 +212,16 @@ function DockMiniStatus({ sensors }) {
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1 text-xs text-slate-200">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">L</span>
|
||||
{badge(left)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">O</span>
|
||||
{badge(omni)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">R</span>
|
||||
{badge(right)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import KoFiButton from './KoFiButton.jsx';
|
||||
export function NicknameEntryPanel({ 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">
|
||||
<div className="surface flex w-full items-center px-0 py-0">
|
||||
<NicknameForm compact={compact} />
|
||||
</div>
|
||||
</section>
|
||||
@@ -155,7 +155,7 @@ export default function UserListPanel({
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
{user.roverId ? (
|
||||
@@ -181,7 +181,7 @@ export default function UserListPanel({
|
||||
<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">
|
||||
<div className="surface flex w-full items-center px-0 py-0">
|
||||
<NicknameForm compact={compact} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +230,7 @@ export default function UserListPanel({
|
||||
</div>
|
||||
)}
|
||||
{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 ? (
|
||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||
) : (
|
||||
@@ -247,8 +247,8 @@ export default function UserListPanel({
|
||||
: queue[0]
|
||||
: null;
|
||||
return (
|
||||
<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">
|
||||
<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-0.5">
|
||||
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
||||
{remaining != null && (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
||||
@@ -259,7 +259,7 @@ export default function UserListPanel({
|
||||
{queue.length === 0 ? (
|
||||
<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) => {
|
||||
const user = lookupUser(socketId);
|
||||
const isCurrent = socketId === currentId;
|
||||
@@ -297,18 +297,18 @@ export default function UserListPanel({
|
||||
) : null}
|
||||
|
||||
{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()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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">
|
||||
<span>Users</span>
|
||||
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -433,7 +433,7 @@ export default function VideoTile({
|
||||
}`}
|
||||
>
|
||||
<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!'} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,7 +441,7 @@ export default function VideoTile({
|
||||
) : null}
|
||||
</div>
|
||||
{!showVerticalBattery && (
|
||||
<div className="space-y-0.25">
|
||||
<div className="space-y-0.5">
|
||||
<LightBumpBars sensors={sensors} />
|
||||
<BatteryBar visual={batteryVisual} />
|
||||
</div>
|
||||
@@ -484,7 +484,7 @@ function BatteryBarVertical({ visual }) {
|
||||
style={{ height: `${visual.percentDisplay}%` }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -510,7 +510,7 @@ function LightBumpBars({ sensors }) {
|
||||
const barHeight = 12;
|
||||
|
||||
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) => {
|
||||
const t = eased(v);
|
||||
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
|
||||
@@ -600,15 +600,15 @@ function HudOverlay({
|
||||
return (
|
||||
<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="flex flex-col gap-[1px] leading-none">
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||
</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`}>
|
||||
Telemetry
|
||||
</span>
|
||||
@@ -639,7 +639,7 @@ function HudOverlay({
|
||||
|
||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||
<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>
|
||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||
@@ -652,7 +652,7 @@ function HudOverlay({
|
||||
return (
|
||||
<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="flex flex-col gap-[1px] leading-none">
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||
</div>
|
||||
@@ -700,7 +700,7 @@ function TurnCueOverlay({
|
||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||
return (
|
||||
<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={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
||||
{showCountdown ? (
|
||||
@@ -732,7 +732,7 @@ function OvercurrentOverlay({ motors, compact = false }) {
|
||||
>
|
||||
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
||||
<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>
|
||||
);
|
||||
@@ -796,7 +796,7 @@ function HudChatInput({ compact = false }) {
|
||||
return { speak: true, engine, voice };
|
||||
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
||||
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';
|
||||
const inputClass = compact
|
||||
? '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}
|
||||
</span>
|
||||
</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
|
||||
type="button"
|
||||
onClick={handleDrive}
|
||||
|
||||
+2
-2
@@ -44,11 +44,11 @@ body {
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply bg-black text-white p-0.5;
|
||||
@apply bg-black text-white p-0;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply bg-neutral-900 text-white p-0.5;
|
||||
@apply bg-neutral-900 text-white p-0;
|
||||
}
|
||||
|
||||
.panel-muted {
|
||||
|
||||
@@ -101,7 +101,7 @@ function MiniSummaryContent() {
|
||||
}
|
||||
|
||||
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 />
|
||||
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
|
||||
{!spectatorReady ? (
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||
import VideoTile from '../components/VideoTile.jsx';
|
||||
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
|
||||
import UserListPanel from '../components/UserListPanel.jsx';
|
||||
import ChatPanel from '../components/ChatPanel.jsx';
|
||||
import LogPanel from '../components/LogPanel.jsx';
|
||||
import RoverRoster from '../components/RoverRoster.jsx';
|
||||
import AlertFeed from '../components/AlertFeed.jsx';
|
||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
||||
import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
|
||||
import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
|
||||
|
||||
function formatDriverLabel({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
@@ -28,7 +28,7 @@ function formatDriverLabel({ roverId, session }) {
|
||||
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
|
||||
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
||||
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">
|
||||
<VideoTile
|
||||
sessionInfo={null}
|
||||
@@ -124,7 +124,7 @@ function SpectatorContent() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<RoverRow
|
||||
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">
|
||||
<CommunityGoalBanner layout="desktop" />
|
||||
<div className="panel">
|
||||
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
|
||||
<RoverQueuesPanel title="Rovers" />
|
||||
</div>
|
||||
<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 className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
||||
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
||||
|
||||
Reference in New Issue
Block a user