big ui sweep. combined queue and roster.

This commit is contained in:
legop3
2026-01-20 14:25:48 -05:00
parent 850ac3ec15
commit b9a007bd48
39 changed files with 568 additions and 234 deletions
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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>
+5 -5
View File
@@ -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>
+3 -3
View File
@@ -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>
);
}
}
+5 -5
View File
@@ -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>
+2 -2
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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">
+13 -13
View File
@@ -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, {})}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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>
);
+1 -1
View File
@@ -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>
)}
+2 -2
View File
@@ -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>
+135
View File
@@ -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>
);
}
+3 -3
View File
@@ -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
+8 -6
View File
@@ -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 />
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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>
+197
View File
@@ -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>
);
}
+1 -1
View File
@@ -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))}>
+1 -1
View File
@@ -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 }) {
+5 -5
View File
@@ -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>
+10 -10
View File
@@ -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>
+12 -12
View File
@@ -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}