big overhaul for server and webui feature matching, things default to disabled and disappear from UI when disabled.

This commit is contained in:
legop3
2026-07-05 14:42:43 -04:00
parent 29bf4cc5d2
commit 40e8adf15a
34 changed files with 592 additions and 211 deletions
@@ -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');
+13
View File
@@ -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'>
+13
View File
@@ -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]">
+20 -5
View File
@@ -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
+14 -1
View File
@@ -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}`}>
+17 -2
View File
@@ -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>
);
+15
View File
@@ -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"
/>
);
}