mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
big ui sweep. combined queue and roster.
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user