improvements and bug fixes

This commit is contained in:
legop3
2026-06-29 20:19:24 -04:00
parent 02df826eb4
commit 79be3e1583
28 changed files with 164 additions and 233 deletions
@@ -61,9 +61,8 @@ export default function AdminPanelContent() {
const isAdmin =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
session?.role === 'lockdown';
const isLockdownAdmin = session?.role === 'lockdown';
const currentMode = session?.mode ?? 'open';
@@ -10,7 +10,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -151,7 +150,7 @@ function chatRowClass(message, variant = 'message') {
return 'flex flex-col gap-0.5 rounded-md border border-emerald-500/40 bg-emerald-950 px-0.5 py-0.5 text-sm text-neutral-100';
}
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
message.role === 'admin' || message.role === 'lockdown';
return `flex flex-col gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 text-sm text-neutral-100 ${
isAdmin
? 'border border-amber-400/30'
@@ -8,8 +8,8 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
import SocialButton from '../SocialButton/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx';
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
const LOCKDOWN_ROLES = new Set(['lockdown']);
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
function getModeDetails(mode = 'admin') {
@@ -11,7 +11,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
+123 -34
View File
@@ -1,7 +1,7 @@
// Room Camera Panel
// Purpose: Defines the Room Camera Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
@@ -18,6 +18,7 @@ function EmptyState() {
}
const ORIENTATIONS = ['horizontal', 'vertical'];
const CAMERA_VISIBILITY_ROOT_MARGIN = '0px';
function normalizeOrientation(value, fallback) {
if (ORIENTATIONS.includes(value)) {
@@ -26,6 +27,82 @@ function normalizeOrientation(value, fallback) {
return fallback;
}
function useCameraPanelSubscriptionGate() {
const panelRef = useRef(null);
const [isPanelVisible, setIsPanelVisible] = useState(false);
useEffect(() => {
/*
Browser visibility can only be measured after React has mounted a real DOM
node. Starting closed avoids a short subscribe/unsubscribe burst for room
camera panels that mount below the fold.
*/
const panel = panelRef.current;
if (!panel || typeof window === 'undefined' || typeof document === 'undefined') {
return undefined;
}
let intersectsViewport = true;
const publishVisibility = () => {
/*
Server upload bandwidth is only saved when the socket subscription is
actually disabled. Combining viewport intersection with page visibility
means a scrolled-away panel and a hidden browser tab both stop receiving
room-camera frame uploads from socketGateway.js.
*/
setIsPanelVisible(intersectsViewport && document.visibilityState !== 'hidden');
};
const handlePageVisibilityChange = () => {
/*
IntersectionObserver is about layout visibility, while the Page
Visibility API is about whether the tab itself can be seen. The latter
matters here because a background tab can still keep a mounted panel and
socket alive unless we explicitly close the subscription gate.
*/
publishVisibility();
};
document.addEventListener('visibilitychange', handlePageVisibilityChange);
if (typeof IntersectionObserver !== 'function') {
/*
Without IntersectionObserver we cannot cheaply know whether the panel is
clipped by a scroll container. Fall back to page visibility so browsers
without the observer still behave correctly, just without scroll-based
bandwidth savings.
*/
publishVisibility();
return () => {
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
};
}
const observer = new IntersectionObserver(
([entry]) => {
/*
A zero root margin keeps the bandwidth gate strict: the browser only
subscribes once the panel intersects the viewport. If the first frame
feels too delayed in real use, this constant can be widened later.
*/
intersectsViewport = Boolean(entry?.isIntersecting);
publishVisibility();
},
{ root: null, rootMargin: CAMERA_VISIBILITY_ROOT_MARGIN, threshold: 0 },
);
observer.observe(panel);
return () => {
observer.disconnect();
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
};
}, []);
return { panelRef, isPanelVisible };
}
export default function RoomCameraPanel({
defaultOrientation = 'horizontal',
orientation: forcedOrientation,
@@ -34,7 +111,12 @@ export default function RoomCameraPanel({
panelId = null,
}) {
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const cameraIds = useMemo(
() => cameras.map((camera) => camera.id),
[cameras],
);
const { panelRef, isPanelVisible } = useCameraPanelSubscriptionGate();
const feedMap = useRoomCameraSnapshots(cameraIds, { enabled: isPanelVisible });
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() =>
normalizeOrientation(
@@ -42,17 +124,19 @@ export default function RoomCameraPanel({
'horizontal',
),
);
useEffect(() => {
if (!panelId) return;
const stored = orientationSettings?.[panelId];
if (!stored) return;
setOrientation(normalizeOrientation(stored, 'horizontal'));
// only respond to changes for this panel id
}, [panelId, orientationSettings?.[panelId]]);
const storedOrientation = panelId ? orientationSettings?.[panelId] : null;
const effectiveOrientation = forcedOrientation
? normalizeOrientation(forcedOrientation, 'horizontal')
: orientation;
: normalizeOrientation(
/*
Settings are external state, so derive from them during render instead
of mirroring them into local state from an effect. Local state remains
useful as the immediate value after clicking the layout toggle, while
stored settings win once the settings provider has loaded or saved.
*/
storedOrientation || orientation,
'horizontal',
);
const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
@@ -64,7 +148,11 @@ export default function RoomCameraPanel({
};
if (cameras.length === 0) {
return <EmptyState />;
return (
<div ref={panelRef}>
<EmptyState />
</div>
);
}
const actions = showLayoutToggle ? (
@@ -86,27 +174,28 @@ export default function RoomCameraPanel({
) : null;
return (
<CardFrame
title="Room cameras"
actions={actions}
hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base"
>
<div className={containerClass}>
{cameras.map((camera) => {
const feed = feedMap[camera.id] || null;
return (
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
{/* <header className="space-y-0.5">
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
</header> */}
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
</article>
);
})}
</div>
</CardFrame>
<div ref={panelRef}>
<CardFrame
title="Room cameras"
actions={actions}
hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base"
>
<div className={containerClass}>
{cameras.map((camera) => {
const feed = feedMap[camera.id] || null;
return (
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
{/* <header className="space-y-0.5">
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
</header> */}
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
</article>
);
})}
</div>
</CardFrame>
</div>
);
}
@@ -29,7 +29,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -66,7 +65,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
const adminCapable = useMemo(
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
() => role === 'admin' || role === 'lockdown',
[role],
);
const hasDeadlines = useMemo(
+2 -55
View File
@@ -4,14 +4,12 @@
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { selectFrameForDisplay } from '../../context/telemetryViews.js';
import { useDockIr } from '../../hooks/useDockIr.js';
import CardFrame from '../CardFrame/index.jsx';
export default function TelemetryPanel() {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const frame = useVisualTelemetrySelector(roverId, selectFrameForDisplay);
const sensors = frame?.sensors || {};
const dockIr = useDockIr(sensors);
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
@@ -35,7 +33,7 @@ export default function TelemetryPanel() {
) : (
<>
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
<SensorDetails sensors={sensors} dockState={dockIr} />
<SensorDetails sensors={sensors} />
</>
)}
{rawSnippet && (
@@ -75,7 +73,7 @@ function Metric({ label, value }) {
);
}
function SensorDetails({ sensors, dockState }) {
function SensorDetails({ sensors }) {
const bumps = sensors?.bumpsAndWheelDrops || {};
const over = sensors?.wheelOvercurrents || {};
@@ -104,11 +102,6 @@ function SensorDetails({ sensors, dockState }) {
return (
<div className="grid gap-0.5 md:grid-cols-2">
<DetailCard title="Dock IR">
<DockMiniStatus sensors={sensors} />
<DockDebug state={dockState} />
</DetailCard>
<DetailCard title="Bumps & drops">
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
@@ -190,49 +183,3 @@ function formatNumber(value, unit) {
if (value == null || Number.isNaN(value)) return '--';
return unit ? `${value} ${unit}` : value;
}
function DockMiniStatus({ sensors }) {
const left = sensors?.infraredCharacterLeft;
const right = sensors?.infraredCharacterRight;
const omni = sensors?.infraredCharacterOmni;
const badge = (code) => (
<span className={`rounded px-1 py-0.25 text-[0.7rem] font-semibold ${code ? 'bg-emerald-600 text-white' : 'bg-slate-700 text-slate-300'}`}>
{code || '--'}
</span>
);
return (
<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-0.5">
<span className="text-slate-400">O</span>
{badge(omni)}
</div>
<div className="flex items-center gap-0.5">
<span className="text-slate-400">R</span>
{badge(right)}
</div>
</div>
);
}
function DockDebug({ state }) {
if (!state) return null;
const label = (entry) => {
const parts = [];
if (entry?.red) parts.push('R');
if (entry?.green) parts.push('G');
if (entry?.force) parts.push('F');
return parts.length ? parts.join('') : 'none';
};
const age = (entry) => (entry?.age != null ? `${entry.age}ms` : '--');
return (
<div className="text-[0.7rem] text-slate-400">
<div>{`Left: ${state.left.code ?? '--'} (${label(state.left)}) · age ${age(state.left)}`}</div>
<div>{`Omni: ${state.omni.code ?? '--'} (${label(state.omni)}) · age ${age(state.omni)}`}</div>
<div>{`Right: ${state.right.code ?? '--'} (${label(state.right)}) · age ${age(state.right)}`}</div>
</div>
);
}
+2 -3
View File
@@ -30,7 +30,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -120,7 +119,7 @@ export default function UserListPanel({
) : (
sorted.map((user) => {
const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
user.role === 'admin' || user.role === 'lockdown';
return (
<div
key={user.socketId}
@@ -229,7 +228,7 @@ export default function UserListPanel({
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId);
const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
user.role === 'admin' || user.role === 'lockdown';
const highlightClass = isCurrent
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
: isNext