mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
big overhaul for server and webui feature matching, things default to disabled and disappear from UI when disabled.
This commit is contained in:
@@ -5,6 +5,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function clampRgbChannel(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
@@ -247,6 +249,18 @@ function Leaderboard({ players, ownPlayer }) {
|
||||
);
|
||||
}
|
||||
export default function BarcodeGamesPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'barcodeGames'));
|
||||
|
||||
/*
|
||||
Barcode games depend on the optional scanner station. The panel owns the
|
||||
feature gate so disabled installs do not show empty game voting controls.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <BarcodeGamesPanelContent />;
|
||||
}
|
||||
|
||||
function BarcodeGamesPanelContent() {
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeSignatureRef = useRef('');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const FLASH_MS = 420;
|
||||
const REWARD_FLASH_MS = 1200;
|
||||
@@ -20,6 +21,18 @@ const BUTTON_TONES = {
|
||||
};
|
||||
|
||||
export default function ButtonBoxPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'buttonBox'));
|
||||
|
||||
/*
|
||||
The physical button box should vanish by owning its own feature gate. Routes
|
||||
can keep mounting this component without leaking empty reward panels.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <ButtonBoxPanelContent />;
|
||||
}
|
||||
|
||||
function ButtonBoxPanelContent() {
|
||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||
const socket = useSocket();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const COLOR_SWATCHES = Object.freeze([
|
||||
{ id: 'white', label: 'White', hex: '#ffffff', action: 'white' },
|
||||
@@ -169,6 +170,19 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
|
||||
}
|
||||
|
||||
export default function HomeAssistantControls() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'homeAssistant'));
|
||||
|
||||
/*
|
||||
Feature existence is owned here, not by each layout that happens to mount
|
||||
room controls. Disabled integrations render nothing; enabled integrations
|
||||
can still show offline/configuration states inside the panel.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <HomeAssistantControlsContent />;
|
||||
}
|
||||
|
||||
function HomeAssistantControlsContent() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||
|
||||
@@ -195,9 +195,12 @@ function TurnsOverlay({
|
||||
Video switched to preview mode to save bandwidth.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto mt-0.5">
|
||||
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
|
||||
</div>
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord while you wait!"
|
||||
layout="inline"
|
||||
className="pointer-events-auto mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -11,8 +11,21 @@ import {
|
||||
normalizeBinaryPayload,
|
||||
normalizeKinectStatus,
|
||||
} from './utils.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
export default function KinectPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'kinect'));
|
||||
|
||||
/*
|
||||
Kinect is optional local hardware. Keep the feature gate inside the panel so
|
||||
disabled installs do not need special cases in every Activities layout.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <KinectPanelContent />;
|
||||
}
|
||||
|
||||
function KinectPanelContent() {
|
||||
const socket = useSocket();
|
||||
const status = useSessionSelector((state) => normalizeKinectStatus(state.session?.kinect));
|
||||
const [activeView, setActiveView] = useState('3d');
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function badgeClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||
@@ -21,6 +22,18 @@ function positionLabel(value) {
|
||||
}
|
||||
|
||||
export default function LiftCard() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'lift'));
|
||||
|
||||
/*
|
||||
Lift is an optional Home Assistant-backed hardware feature. The card owns
|
||||
that existence check so disabled installs do not need layout-level guards.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <LiftCardContent />;
|
||||
}
|
||||
|
||||
function LiftCardContent() {
|
||||
/*
|
||||
The lift card is a complete Activities-tab feature, so it reads the shared
|
||||
lift state and socket actions directly. Keeping that wiring inside the card
|
||||
|
||||
@@ -78,9 +78,7 @@ export default function ModeGateOverlay() {
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<div className="w-full justify-center items-center">
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!"/>
|
||||
</div>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!" />
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function normalizeState(value) {
|
||||
return String(value || '').trim();
|
||||
@@ -41,6 +42,18 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
|
||||
}
|
||||
|
||||
export default function NeatoCard() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'neato'));
|
||||
|
||||
/*
|
||||
Neato support is optional hardware surfaced through Home Assistant. Keeping
|
||||
the feature gate in this card avoids scattered checks in each route layout.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <NeatoCardContent />;
|
||||
}
|
||||
|
||||
function NeatoCardContent() {
|
||||
/*
|
||||
Neato is a standalone public activity card. It owns its session selector and
|
||||
command actions so callers do not need to know the socket event names or
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { getSocialById } from '../../lib/socials.js';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
return (
|
||||
@@ -45,6 +47,25 @@ function MobileQuickstart() {
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordQuickstartCard() {
|
||||
const discord = useSessionSelector((state) => getSocialById(state, 'discord'));
|
||||
|
||||
/*
|
||||
The section has explanatory text around the actual button, so SocialButton's
|
||||
own null return is not enough here. Keep the whole Discord prompt self-
|
||||
contained so layouts do not need a separate social feature check.
|
||||
*/
|
||||
if (!discord?.url) return null;
|
||||
|
||||
return (
|
||||
<div className="surface p-0.5">
|
||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||
<SocialButton id="discord" label="Join Discord" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuickstartOverlay({
|
||||
visible,
|
||||
layout,
|
||||
@@ -85,11 +106,7 @@ export default function QuickstartOverlay({
|
||||
<p className="text-sm font-semibold text-slate-200">Nicknames are assigned randomly by default, you can change yours here.</p>
|
||||
<NicknameForm compact />
|
||||
</div>
|
||||
<div className="surface p-0.5">
|
||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||
<SocialButton id="discord" label="Join Discord" />
|
||||
</div>
|
||||
<DiscordQuickstartCard />
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5 border-t border-slate-700 px-0.5 py-0.35 text-[0.8rem]">
|
||||
|
||||
@@ -187,6 +187,25 @@ function DriveDockPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function QueueReplayLinksRow() {
|
||||
/*
|
||||
Flex ratios match the old grid proportions when the Links panel exists. If
|
||||
LinkButtonsPanel returns null because socials are disabled, flex naturally
|
||||
removes that item instead of preserving an empty grid column.
|
||||
*/
|
||||
return (
|
||||
<div className={`flex ${themeGapClass}`}>
|
||||
<div className="min-w-0 basis-0 grow-[1]">
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
</div>
|
||||
<LinkButtonsPanel className="min-w-0 basis-0 grow-[0.75]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
const [activeTab, setActiveTab] = useState('telemetry');
|
||||
const chatDockRef = useRef(null);
|
||||
@@ -354,11 +373,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<TopDownMapPanel />
|
||||
<DriveDockPanel />
|
||||
</div>
|
||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]`}>
|
||||
<RoverQueuesPanel />
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
<LinkButtonsPanel />
|
||||
</div>
|
||||
<QueueReplayLinksRow />
|
||||
{/*
|
||||
This row gets an explicit measured height because the target
|
||||
behavior depends on the row's live viewport position during
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
@@ -103,7 +104,19 @@ function useCameraPanelSubscriptionGate() {
|
||||
return { panelRef, isPanelVisible };
|
||||
}
|
||||
|
||||
export default function RoomCameraPanel({
|
||||
export default function RoomCameraPanel(props) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'roomCameras'));
|
||||
|
||||
/*
|
||||
Room-camera visibility belongs with the room-camera panel. This keeps every
|
||||
route free to mount the panel without duplicating the server feature rule.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <RoomCameraPanelContent {...props} />;
|
||||
}
|
||||
|
||||
function RoomCameraPanelContent({
|
||||
defaultOrientation = 'horizontal',
|
||||
orientation: forcedOrientation,
|
||||
hideLayoutToggle = false,
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
// Purpose: Defines the Social Buttons Grid 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 { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
|
||||
export default function SocialButtonsGrid({ className = '' }) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
|
||||
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
|
||||
|
||||
if (!socials.length) return null;
|
||||
/*
|
||||
The Links panel owns its feature visibility. Even if the server config has
|
||||
link entries, `socials.enabled: false` makes the server advertise the
|
||||
feature as disabled, and this component disappears completely.
|
||||
*/
|
||||
if (!enabled || !socials.length) return null;
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
@@ -18,9 +19,23 @@ export function NicknameEntryPanel({ compact = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkButtonsPanel() {
|
||||
export function LinkButtonsPanel({ className = '' }) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
|
||||
|
||||
/*
|
||||
This component is the actual Links panel shell. SocialButtonsGrid already
|
||||
hides the buttons when socials are disabled, but the shell must also hide
|
||||
itself so the UI does not leave an empty "Links!" card behind.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<CardFrame title="Links!" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
|
||||
<CardFrame
|
||||
title="Links!"
|
||||
fillHeight
|
||||
className={className}
|
||||
bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base"
|
||||
>
|
||||
<SocialButtonsGrid className="flex-1 min-h-0" />
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Feature Helpers
|
||||
// Purpose: Centralizes client-side reads of server-advertised optional features.
|
||||
// Scope: Keeps layout/components from each inventing their own "is this feature configured?" rule.
|
||||
export function isFeatureEnabled(state, featureName) {
|
||||
/*
|
||||
The server owns feature detection because only it can reliably know whether
|
||||
config-driven hardware integrations exist. React should treat missing flags
|
||||
as disabled so old or partial session payloads fail closed and hide extras.
|
||||
*/
|
||||
return Boolean(state?.session?.features?.[featureName]);
|
||||
}
|
||||
|
||||
export function anyFeatureEnabled(state, featureNames = []) {
|
||||
return featureNames.some((featureName) => isFeatureEnabled(state, featureName));
|
||||
}
|
||||
@@ -5,15 +5,11 @@ import RoomCameraPanel from '../../../components/RoomCameraPanel/index.jsx';
|
||||
|
||||
export default function SecondaryRow() {
|
||||
return (
|
||||
<section className="min-h-0">
|
||||
<div className="surface min-h-[14rem] overflow-hidden">
|
||||
<RoomCameraPanel
|
||||
defaultOrientation="horizontal"
|
||||
hideLayoutToggle
|
||||
hideHeader
|
||||
panelId="spectator-secondary"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<RoomCameraPanel
|
||||
defaultOrientation="horizontal"
|
||||
hideLayoutToggle
|
||||
hideHeader
|
||||
panelId="spectator-secondary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user