mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
rover based colors!
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-D13hsMAR.js"></script>
|
<script type="module" crossorigin src="/assets/index-tQBHQF7J.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dva9v7lV.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C-j0HVkU.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function ButtonBoxPanel() {
|
|||||||
}, [effectiveAlertVolume, socket]);
|
}, [effectiveAlertVolume, socket]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Button Box" accent="#eab308" bodyClassName="space-y-0.5 text-base">
|
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
||||||
<div className="grid grid-cols-4 gap-0.5">
|
<div className="grid grid-cols-4 gap-0.5">
|
||||||
{buttons.map((button) => {
|
{buttons.map((button) => {
|
||||||
const id = Number(button.id);
|
const id = Number(button.id);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
function cx(...values) {
|
function cx(...values) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Color helpers
|
||||||
function hexToRgb(hex) {
|
function hexToRgb(hex) {
|
||||||
const raw = String(hex || '').trim();
|
const raw = String(hex || '').trim();
|
||||||
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
@@ -24,21 +28,29 @@ function rgba(rgb, alpha) {
|
|||||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component
|
||||||
export default function CardFrame({
|
export default function CardFrame({
|
||||||
title = '',
|
title = '',
|
||||||
meta = null,
|
meta = null,
|
||||||
actions = null,
|
actions = null,
|
||||||
accent = null,
|
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
className = '',
|
className = '',
|
||||||
headerClassName = '',
|
headerClassName = '',
|
||||||
bodyClassName = '',
|
bodyClassName = '',
|
||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
|
clipOverflow = true,
|
||||||
children,
|
children,
|
||||||
}) {
|
}) {
|
||||||
const showHeader = !hideHeader && (title || meta != null || actions);
|
const showHeader = !hideHeader && (title || meta != null || actions);
|
||||||
const accentRgb = hexToRgb(accent);
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.65) } : undefined;
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||||
const headerStyle = accentRgb
|
const headerStyle = accentRgb
|
||||||
? {
|
? {
|
||||||
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
@@ -48,28 +60,30 @@ export default function CardFrame({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cx(
|
className={cx(
|
||||||
'panel-section overflow-hidden border border-neutral-600/55 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_10px_24px_rgba(0,0,0,0.3)]',
|
'panel-section border border-neutral-500/60 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
|
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
|
||||||
fillHeight && 'flex h-full min-h-0 flex-col',
|
fillHeight && 'flex h-full min-h-0 flex-col',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={cardStyle}
|
style={cardStyle}
|
||||||
>
|
>
|
||||||
{showHeader ? (
|
{showHeader ? (
|
||||||
|
// Header row
|
||||||
<header
|
<header
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center justify-between gap-0.5 border-b border-neutral-600/45 bg-gradient-to-r from-neutral-900 via-neutral-800 to-neutral-700 px-0.5 py-0.5',
|
'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
|
||||||
headerClassName,
|
headerClassName,
|
||||||
)}
|
)}
|
||||||
style={headerStyle}
|
style={headerStyle}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-0.5">
|
<div className="flex min-w-0 items-center gap-0.5">
|
||||||
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-100">{title}</p> : null}
|
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
|
||||||
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-300">{meta}</span> : null}
|
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
|
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
|
||||||
</header>
|
</header>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={cx('', fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
|
<div className={cx(fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function ChatPanel({
|
|||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title={title}
|
title={title}
|
||||||
accent="#22c55e"
|
|
||||||
hideHeader={!title}
|
hideHeader={!title}
|
||||||
fillHeight={fillHeight}
|
fillHeight={fillHeight}
|
||||||
bodyClassName="space-y-0.5 text-base"
|
bodyClassName="space-y-0.5 text-base"
|
||||||
|
|||||||
@@ -8,11 +8,40 @@ const MOBILE_DISMISS_MS = 10000;
|
|||||||
const MAX_FONT_PX = 28;
|
const MAX_FONT_PX = 28;
|
||||||
const MIN_FONT_PX = 14;
|
const MIN_FONT_PX = 14;
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
||||||
const goalText = useSessionSelector((state) => {
|
const goalText = useSessionSelector((state) => {
|
||||||
const text = state.session?.globalObjective?.text;
|
const text = state.session?.globalObjective?.text;
|
||||||
return text ? String(text).trim() : '';
|
return text ? String(text).trim() : '';
|
||||||
});
|
});
|
||||||
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
||||||
@@ -72,7 +101,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
const containerClass = useMemo(
|
const containerClass = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
'panel-section flex w-full items-center justify-center',
|
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
isMobile ? 'rounded-none' : 'rounded',
|
isMobile ? 'rounded-none' : 'rounded',
|
||||||
'px-1 py-1 text-center font-semibold tracking-tight',
|
'px-1 py-1 text-center font-semibold tracking-tight',
|
||||||
className,
|
className,
|
||||||
@@ -81,6 +110,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
.join(' '),
|
.join(' '),
|
||||||
[className, isMobile],
|
[className, isMobile],
|
||||||
);
|
);
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const frameStyle = accentRgb
|
||||||
|
? {
|
||||||
|
borderColor: rgba(accentRgb, 0.35),
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!goalText || !visible) return null;
|
if (!goalText || !visible) return null;
|
||||||
|
|
||||||
@@ -94,7 +130,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={containerClass}
|
className={containerClass}
|
||||||
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
||||||
{...dismissProps}
|
{...dismissProps}
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export default function HomeAssistantControls() {
|
|||||||
|
|
||||||
if (!ha?.enabled) {
|
if (!ha?.enabled) {
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Room Controls" accent="#14b8a6" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||||
<p className="text-slate-500">Not configured on the server.</p>
|
<p className="text-slate-500">Not configured on the server.</p>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
@@ -264,7 +264,7 @@ export default function HomeAssistantControls() {
|
|||||||
|
|
||||||
if (entities.length === 0) {
|
if (entities.length === 0) {
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Room Controls" accent="#14b8a6" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||||
<p className="text-slate-500">No lights or switches configured.</p>
|
<p className="text-slate-500">No lights or switches configured.</p>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
@@ -290,7 +290,7 @@ export default function HomeAssistantControls() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Room Controls" accent="#14b8a6" actions={actions} bodyClassName="space-y-0.5 text-base">
|
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||||
{controlsLocked ? (
|
{controlsLocked ? (
|
||||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||||
{lockState === 'off'
|
{lockState === 'off'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function LogPanel() {
|
|||||||
const logs = useSessionSelector((state) => state.logs);
|
const logs = useSessionSelector((state) => state.logs);
|
||||||
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
|
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Server logs" accent="#f97316" bodyClassName="space-y-0.5 text-base">
|
<CardFrame title="Server logs" bodyClassName="space-y-0.5 text-base">
|
||||||
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p>No logs yet.</p>
|
<p>No logs yet.</p>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function OverseerPreferencePanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Overseer vote" accent="#10b981" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
|
<CardFrame title="Overseer vote" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
|
||||||
<label
|
<label
|
||||||
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
|
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
|
||||||
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
|
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function RawUserPilePanel({
|
|||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title={!hideHeader ? 'Users' : ''}
|
title={!hideHeader ? 'Users' : ''}
|
||||||
accent="#94a3b8"
|
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
fillHeight={fillHeight}
|
fillHeight={fillHeight}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Replay Sources" accent="#06b6d4" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
|
<CardFrame title="Replay Sources" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
|
||||||
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
||||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function TopDownMapPanel() {
|
|||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame hideHeader accent="#0067f7">
|
<CardFrame hideHeader>
|
||||||
<div className="aspect-square w-full">
|
<div className="aspect-square w-full">
|
||||||
<TopDownMap sensors={sensors} />
|
<TopDownMap sensors={sensors} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function RoomCameraPanel({
|
|||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title="Room cameras"
|
title="Room cameras"
|
||||||
accent="#38bdf8"
|
|
||||||
actions={actions}
|
actions={actions}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
bodyClassName="space-y-0.5 text-base"
|
bodyClassName="space-y-0.5 text-base"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title={title} accent="#f59e0b" bodyClassName="space-y-0.5 text-sm">
|
<CardFrame title={title} bodyClassName="space-y-0.5 text-sm">
|
||||||
{rosterItems.length === 0 ? (
|
{rosterItems.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
|
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
const TabsContext = createContext(null);
|
const TabsContext = createContext(null);
|
||||||
|
|
||||||
@@ -33,6 +34,28 @@ function classNames(...parts) {
|
|||||||
return parts.filter(Boolean).join(' ');
|
return parts.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
|
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
|
||||||
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
|
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
|
||||||
const [tabOrder, setTabOrder] = useState([]);
|
const [tabOrder, setTabOrder] = useState([]);
|
||||||
@@ -95,7 +118,32 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TabList({ children, className = '' }) {
|
export function TabList({ children, className = '' }) {
|
||||||
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const frameStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||||
|
const headerStyle = accentRgb
|
||||||
|
? {
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'panel-section overflow-hidden border border-neutral-500/60 bg-neutral-900/95 p-0.5 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={frameStyle}
|
||||||
|
>
|
||||||
|
<div className="flex gap-0.5" style={headerStyle}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
|
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||||
import { useDockIr } from '../../hooks/useDockIr.js';
|
import { useDockIr } from '../../hooks/useDockIr.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function formatMetric(value, fallback = '--') {
|
function formatMetric(value, fallback = '--') {
|
||||||
if (value == null || value === '') return fallback;
|
if (value == null || value === '') return fallback;
|
||||||
@@ -39,7 +40,7 @@ export default function TelemetryPanel() {
|
|||||||
}, [activeDriverId, roverId, selfSocketId, users]);
|
}, [activeDriverId, roverId, selfSocketId, users]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
<CardFrame hideHeader clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
|
||||||
{/* <div className="text-sm text-slate-400">
|
{/* <div className="text-sm text-slate-400">
|
||||||
<span>{connected ? 'online' : 'offline'}</span>
|
<span>{connected ? 'online' : 'offline'}</span>
|
||||||
<span> · role {session?.role || 'unknown'}</span>
|
<span> · role {session?.role || 'unknown'}</span>
|
||||||
@@ -60,7 +61,7 @@ export default function TelemetryPanel() {
|
|||||||
{rawSnippet && (
|
{rawSnippet && (
|
||||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function NicknameEntryPanel({ compact = false }) {
|
|||||||
|
|
||||||
export function LinkButtonsPanel() {
|
export function LinkButtonsPanel() {
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Socials" accent="#3030f5" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
|
<CardFrame title="Socials" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
|
||||||
<SocialButtonsGrid className="flex-1 min-h-0" />
|
<SocialButtonsGrid className="flex-1 min-h-0" />
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ export default function VipAudioUploadCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Audio Controls" accent="#a78bfa">
|
<CardFrame title="Audio Controls">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<section className="surface">
|
<section className="surface">
|
||||||
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
|
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
|
|||||||
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Identity key" accent="#60a5fa" className={wrapClass}>
|
<CardFrame title="Identity key" className={wrapClass}>
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
|||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title="Lift Controls"
|
title="Lift Controls"
|
||||||
accent="#06b6d4"
|
|
||||||
className={`relative ${wrapClass}`}
|
className={`relative ${wrapClass}`}
|
||||||
bodyClassName="text-sm text-slate-200"
|
bodyClassName="text-sm text-slate-200"
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function VipNeatoCard({
|
|||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title="Neato Controls"
|
title="Neato Controls"
|
||||||
accent="#22c55e"
|
|
||||||
className={wrapClass}
|
className={wrapClass}
|
||||||
bodyClassName="text-sm text-slate-200"
|
bodyClassName="text-sm text-slate-200"
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function VipPrivateRoverAccessCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Private rover access requests" accent="#f43f5e" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
<CardFrame title="Private rover access requests" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
{requestableRovers.length === 0 ? (
|
{requestableRovers.length === 0 ? (
|
||||||
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
|
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function VipProfileImageCard({ isVerified = false, fullWidth = fa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Chat profile image URL" accent="#f59e0b" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
<CardFrame title="Chat profile image URL" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||||
<form className={innerFlowClass} onSubmit={handleSave}>
|
<form className={innerFlowClass} onSubmit={handleSave}>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Verified users can set a custom avatar for chat and Discord bridge messages.
|
Verified users can set a custom avatar for chat and Discord bridge messages.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function VipVerificationCard({
|
|||||||
|
|
||||||
if (pendingRequestId) {
|
if (pendingRequestId) {
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Verification" accent="#eab308" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
<CardFrame title="Verification" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||||
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
@@ -70,7 +70,7 @@ export default function VipVerificationCard({
|
|||||||
|
|
||||||
if (requestFlowStep === 0) {
|
if (requestFlowStep === 0) {
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Verification" accent="#eab308" className={wrapClass}>
|
<CardFrame title="Verification" className={wrapClass}>
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
||||||
Request Verification
|
Request Verification
|
||||||
@@ -81,7 +81,7 @@ export default function VipVerificationCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Request verification" accent="#eab308" className={wrapClass}>
|
<CardFrame title="Request verification" className={wrapClass}>
|
||||||
<form onSubmit={handleRequestSubmit}>
|
<form onSubmit={handleRequestSubmit}>
|
||||||
<div className={innerFlowClass}>
|
<div className={innerFlowClass}>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
|
|||||||
Reference in New Issue
Block a user