This commit is contained in:
legop3
2026-09-08 23:11:03 -04:00
parent 038ae0f45a
commit d3fd3946e6
22 changed files with 792 additions and 177 deletions
@@ -0,0 +1,34 @@
// Desktop Accessories Expansion
// Purpose: Places generic rover controls on a collapsible surface centered along the video's left wall.
// Scope: Owns desktop positioning and persisted visibility while reusing the layout-independent control renderer.
import RoverAccessoryControls from '../../../RoverAccessoryControls/index.jsx';
import AccessoriesToggle from '../../../RoverAccessoryControls/AccessoriesToggle.jsx';
import useRoverAccessories from '../../../RoverAccessoryControls/useRoverAccessories.js';
import usePodVisibility from './usePodVisibility.js';
export default function AccessoriesExpansion({ roverId }) {
const { hasAccessories } = useRoverAccessories(roverId);
const [open, setOpen] = usePodVisibility('accessories', false);
// Do not leave an invisible anchor or reserved HUD area on rovers whose
// peripherals provide only standardized controls or no controls at all.
if (!hasAccessories) return null;
return (
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 flex items-center">
<AccessoriesToggle
label="Accessories"
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
onClick={() => setOpen(!open)}
className={`pointer-events-auto !h-28 rounded-l-none ${open ? 'rounded-r-none' : ''}`}
/>
{open ? (
<div className="pointer-events-auto h-[70%] min-h-48 max-h-[32rem] w-72 overflow-hidden rounded-r-xl border-2 border-l-0 border-cyan-300/70 bg-slate-950/95 shadow-2xl">
{/* This is exactly the renderer mounted by AuxColumn. The desktop
wrapper changes available dimensions, never control behavior. */}
<RoverAccessoryControls roverId={roverId} className="h-full" />
</div>
) : null}
</div>
);
}
@@ -4,6 +4,7 @@ import TopLeftPod from './TopLeftPod.jsx';
import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx';
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
export default function CornerPods({ roverId }) {
@@ -17,6 +18,9 @@ export default function CornerPods({ roverId }) {
{/* The mobile layouts already provide large touch controls around the video.
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
{/* Generic accessory controls stay on the same left side at every
breakpoint, but mobile owns their placement inside AuxColumn. */}
{showPhysicalControlPods ? <AccessoriesExpansion roverId={roverId} /> : null}
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted
on mobile and determines its own camera-control visibility from layout context. */}
<BottomRightPod roverId={roverId} />
@@ -1,7 +1,7 @@
// Aux Column
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
@@ -15,11 +15,14 @@ import { AUX_ZERO } from './constants.js';
import VacuumControls from './VacuumControls.jsx';
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import RoverAccessoryControls from '../RoverAccessoryControls/index.jsx';
import AccessoriesToggle from '../RoverAccessoryControls/AccessoriesToggle.jsx';
import useRoverAccessories from '../RoverAccessoryControls/useRoverAccessories.js';
const CAMERA_TILT_STEP_DEGREES = 0.5;
const CAMERA_TILT_PRECISION_STEP_DEGREES = 0.1;
function AuxColumnContent() {
function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
const roverId = useControlSelector((control) => control.state.roverId);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
const camera = useControlSelector((control) => control.state.camera);
@@ -119,11 +122,20 @@ function AuxColumnContent() {
return (
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
<VacuumControls
disabled={vacuumDisabled}
onPress={handleAuxPress}
onRelease={handleAuxRelease}
/>
<div className={`mobile-touch-control grid min-h-0 gap-0.5 ${accessoriesAvailable ? 'grid-cols-[minmax(0,1fr)_2rem]' : 'grid-cols-1'}`}>
<VacuumControls
disabled={vacuumDisabled}
onPress={handleAuxPress}
onRelease={handleAuxRelease}
/>
{accessoriesAvailable ? (
<AccessoriesToggle
label="Accessories"
ariaLabel="Show accessory controls"
onClick={onShowAccessories}
/>
) : null}
</div>
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
{cameraEnabled ? (
<VerticalCameraTilt
@@ -180,10 +192,47 @@ function AuxColumnContent() {
);
}
export default function AuxColumn({ layout, className = '' }) {
function RoverAuxColumn({ roverId, layout, className }) {
const [showAccessories, setShowAccessories] = useState(false);
const { hasAccessories } = useRoverAccessories(roverId);
return (
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
<AuxColumnContent />
{showAccessories && hasAccessories ? (
<div className="mobile-touch-control relative h-full min-h-0 overflow-hidden rounded-xl border-2 border-cyan-300/70 bg-slate-950/95">
{/* The compact return tab overlays only the first heading corner. It
does not reserve an otherwise empty rail down the full column. */}
<AccessoriesToggle
label="Aux"
ariaLabel="Return to auxiliary controls"
compact
onClick={() => setShowAccessories(false)}
className="absolute right-1 top-1 z-10"
/>
<RoverAccessoryControls roverId={roverId} className="h-full pr-1" />
</div>
) : (
<AuxColumnContent
accessoriesAvailable={hasAccessories}
onShowAccessories={() => setShowAccessories(true)}
/>
)}
</div>
);
}
export default function AuxColumn({ layout, className = '' }) {
const roverId = useControlSelector((control) => control.state.roverId);
// Keying the stateful view by assignment makes every newly selected rover
// start in the familiar Aux view. It also guarantees that an Accessories
// view cannot remain open after changing to a rover without accessories.
return (
<RoverAuxColumn
key={roverId || 'no-rover'}
roverId={roverId}
layout={layout}
className={className}
/>
);
}
@@ -0,0 +1,32 @@
// Accessories Vertical Toggle
// Purpose: Gives mobile and desktop the same edge-mounted control for changing accessory visibility.
// Scope: Owns only visual treatment and activation; each parent decides its position and destination.
import { FaPuzzlePiece } from 'react-icons/fa';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
export default function AccessoriesToggle({
label,
ariaLabel,
onClick,
compact = false,
className = '',
}) {
return (
<button
type="button"
aria-label={ariaLabel || label}
onClick={() => {
triggerTouchHaptic('button');
onClick();
}}
className={`mobile-touch-control flex shrink-0 items-center justify-center rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-sm font-semibold text-cyan-50 shadow-md transition hover:brightness-110 active:scale-[0.98] active:brightness-125 ${compact ? 'h-14 w-7' : 'h-full w-8'} ${className}`.trim()}
>
{/* Vertical writing keeps the launcher readable in the narrow wall space
without rotating the glyph itself away from its natural orientation. */}
<span className="flex items-center gap-1 [writing-mode:vertical-rl] rotate-180">
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
<span>{label}</span>
</span>
</button>
);
}
@@ -0,0 +1,259 @@
// Accessory Control Field
// Purpose: Maps one firmware-advertised generic control to a large rover-control surface.
// Scope: Owns browser-local values and input semantics; transport and device-specific behavior stay outside this file.
import { useCallback, useEffect, useRef, useState } from 'react';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
function integerBound(value, fallback) {
return Number.isInteger(value) ? value : fallback;
}
function clampInteger(value, minimum, maximum) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return minimum;
return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
}
function trimUnicode(value, maximumLength) {
// Array.from counts Unicode code points instead of UTF-16 code units. That
// mirrors Go's rune-count validation for emoji and other non-BMP characters.
return Array.from(String(value ?? '')).slice(0, maximumLength).join('');
}
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2.5 py-2 text-slate-50 shadow-md';
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
const minimum = integerBound(control.min, 0);
const maximum = integerBound(control.max, minimum);
const value = Number.isInteger(storedValue)
? clampInteger(storedValue, minimum, maximum)
: minimum;
const updateValue = (event) => {
const next = clampInteger(event.target.value, minimum, maximum);
send(peripheralId, control.id, next);
};
return (
<label className={`${CARD_CLASS} block border-emerald-300/70 bg-emerald-900`}>
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>{control.name}</span>
<span className="font-mono text-emerald-100">{value}</span>
</span>
<input
type="range"
min={minimum}
max={maximum}
step="1"
value={value}
disabled={disabled}
onChange={updateValue}
className={`mobile-touch-control mt-2 h-8 w-full cursor-pointer accent-emerald-300 ${DISABLED_CLASS}`}
/>
<span className="flex justify-between text-xs text-emerald-100/80" aria-hidden="true">
<span>{minimum}</span>
<span>{maximum}</span>
</span>
</label>
);
}
function ToggleControl({ peripheralId, control, disabled, send, value }) {
const enabled = value === true;
const toggle = () => {
if (disabled) return;
const next = !enabled;
send(peripheralId, control.id, next);
triggerTouchHaptic('button');
};
return (
<button
type="button"
aria-pressed={enabled}
disabled={disabled}
onClick={toggle}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-between gap-2 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
>
<span>{control.name}</span>
<span className="text-sm">{enabled ? 'On' : 'Off'}</span>
</button>
);
}
function MomentaryControl({ peripheralId, control, disabled, send, value }) {
const pressed = value === true;
const pressedRef = useRef(false);
const pointerIdRef = useRef(null);
const release = useCallback(() => {
if (!pressedRef.current) return;
pressedRef.current = false;
pointerIdRef.current = null;
// Always pair a successful press with false. In particular, this cleanup
// path runs when a touch is cancelled or the Accessories view is replaced.
send(peripheralId, control.id, false);
}, [control.id, peripheralId, send]);
const press = useCallback(() => {
if (disabled || pressedRef.current) return;
pressedRef.current = true;
send(peripheralId, control.id, true);
}, [control.id, disabled, peripheralId, send]);
useEffect(() => release, [release]);
useEffect(() => {
// Browsers do not guarantee pointer-up after a held button becomes
// disabled. Proactively emit the neutral edge at the permission boundary.
if (disabled) release();
}, [disabled, release]);
return (
<button
type="button"
aria-pressed={pressed}
disabled={disabled}
onPointerDown={(event) => {
if (disabled || pointerIdRef.current != null) return;
event.preventDefault();
pointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture?.(event.pointerId);
press();
}}
onPointerUp={(event) => {
if (pointerIdRef.current !== event.pointerId) return;
release();
triggerTouchHaptic('button');
}}
onPointerCancel={release}
onLostPointerCapture={release}
onKeyDown={(event) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
press();
}
}}
onKeyUp={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
release();
triggerTouchHaptic('button');
}
}}
onContextMenu={(event) => event.preventDefault()}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
>
{control.name}
</button>
);
}
function NumberControl({ peripheralId, control, disabled, send, value: storedValue }) {
const minimum = integerBound(control.min, 0);
const maximum = integerBound(control.max, minimum);
const initialValue = Number.isInteger(storedValue)
? clampInteger(storedValue, minimum, maximum)
: minimum;
const [value, setValue] = useState(String(initialValue));
const lastSentRef = useRef(initialValue);
const commit = () => {
const next = clampInteger(value, minimum, maximum);
setValue(String(next));
if (disabled || next === lastSentRef.current) return;
lastSentRef.current = next;
send(peripheralId, control.id, next);
triggerTouchHaptic('button');
};
return (
<label className={`${CARD_CLASS} block border-indigo-300/70 bg-indigo-900`}>
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>{control.name}</span>
<span className="text-xs text-indigo-100/80">{minimum}{maximum}</span>
</span>
<input
type="number"
inputMode="numeric"
min={minimum}
max={maximum}
step="1"
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
event.currentTarget.blur();
}
}}
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-indigo-200/70 bg-indigo-950 px-3 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
/>
</label>
);
}
function TextControl({ peripheralId, control, disabled, send, value: storedValue }) {
const maximumLength = Math.max(1, integerBound(control.maxLength, 1));
const initialValue = typeof storedValue === 'string'
? trimUnicode(storedValue, maximumLength)
: '';
const [value, setValue] = useState(initialValue);
const lastSentRef = useRef(initialValue);
const commit = () => {
const next = trimUnicode(value, maximumLength);
setValue(next);
if (disabled || next === lastSentRef.current) return;
lastSentRef.current = next;
send(peripheralId, control.id, next);
triggerTouchHaptic('button');
};
return (
<label className={`${CARD_CLASS} block border-sky-300/70 bg-sky-900`}>
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>{control.name}</span>
<span className="text-xs text-sky-100/80">{Array.from(value).length}/{maximumLength}</span>
</span>
<input
type="text"
value={value}
disabled={disabled}
onChange={(event) => setValue(trimUnicode(event.target.value, maximumLength))}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
event.currentTarget.blur();
}
}}
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-sky-200/70 bg-sky-950 px-3 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
/>
</label>
);
}
export default function AccessoryControlField(props) {
switch (props.control?.type) {
case 'slider':
return <SliderControl {...props} />;
case 'button':
return props.control.mode === 'momentary'
? <MomentaryControl {...props} />
: <ToggleControl {...props} />;
case 'number':
return <NumberControl {...props} />;
case 'text':
return <TextControl {...props} />;
default:
// roverd validates the four-type contract before publishing metadata.
// Returning nothing remains a defensive boundary for stale servers.
return null;
}
}
@@ -0,0 +1,54 @@
// Rover Accessory Controls
// Purpose: Renders every generic control advertised by a selected rover as one ordered control surface.
// Scope: Reusable content only; mobile and desktop parents own placement, expansion, and visibility.
import { useCallback } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import AccessoryControlField from './AccessoryControlField.jsx';
import useRoverAccessories from './useRoverAccessories.js';
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
export default function RoverAccessoryControls({ roverId, className = '' }) {
const { peripherals } = useRoverAccessories(roverId);
const canControl = useCanControlRover(roverId);
const { setPeripheralControl } = useControlActions();
const values = useControlSelector(
(control) => control.state.peripheralValues?.[String(roverId)] || EMPTY_ACCESSORY_VALUES,
);
const send = useCallback(
(peripheralId, controlId, value) => setPeripheralControl(peripheralId, controlId, value),
[setPeripheralControl],
);
if (peripherals.length === 0) return null;
return (
<div
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain p-1.5 text-slate-100 ${className}`.trim()}
aria-label="Rover accessories"
>
{peripherals.map((peripheral) => (
<section key={peripheral.id} className="mb-2 last:mb-0">
{/* The firmware's array order is authoritative. Mapping directly over
it keeps physical authoring order intact across every UI host. */}
<h3 className="mb-1.5 border-b border-cyan-300/40 px-1 pr-8 pb-1 text-sm font-semibold text-cyan-100">
{peripheral.name}
</h3>
<div className="flex flex-col gap-1.5">
{peripheral.controls.map((control) => (
<AccessoryControlField
key={control.id}
peripheralId={peripheral.id}
control={control}
value={values[peripheral.id]?.[control.id]}
disabled={!roverId || !canControl}
send={send}
/>
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,29 @@
// Rover Accessories Selector
// Purpose: Provides the ordered generic-control inventory advertised by one rover.
// Scope: Selects public roster metadata only; placement and visibility remain parent-UI decisions.
import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function useRoverAccessories(roverId) {
const rosterEntry = useSessionSelector((state) => {
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
});
const advertisedPeripherals = rosterEntry?.peripherals;
const peripherals = useMemo(() => {
if (!Array.isArray(advertisedPeripherals)) return [];
// A peripheral with no generic controls may still provide a standardized
// camera, headlight, or laser backend. Those roles use their established
// HUD controls and must not create an empty Accessories surface.
return advertisedPeripherals.filter(
(peripheral) => Array.isArray(peripheral?.controls) && peripheral.controls.length > 0,
);
}, [advertisedPeripherals]);
return {
peripherals,
hasAccessories: peripherals.length > 0,
};
}
+26
View File
@@ -66,6 +66,7 @@ const CONTROL_ACTION_NAMES = [
'sendSong',
'startHorn',
'stopHorn',
'setPeripheralControl',
'setMicPttActive',
];
@@ -724,6 +725,29 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
}, []);
const setPeripheralControl = useCallback(
(peripheralId, controlId, value) => {
const sent = pipeline.sendPeripheralControl(peripheralId, controlId, value);
if (sent) {
// The generic protocol is currently command-only. Recording the value
// here lets every renderer instance share the browser's latest intent
// without pretending that it is device-reported telemetry.
dispatch({
type: 'control/set-peripheral-value',
payload: {
roverId: pipeline.roverId,
peripheralId,
controlId,
value,
},
});
recordControlIntent();
}
return sent;
},
[pipeline, recordControlIntent],
);
const actionImplementations = useMemo(
() => ({
setMode,
@@ -751,6 +775,7 @@ export function ControlSystemProvider({ children }) {
sendSong,
startHorn,
stopHorn,
setPeripheralControl,
setMicPttActive,
}),
[
@@ -779,6 +804,7 @@ export function ControlSystemProvider({ children }) {
sendSong,
startHorn,
stopHorn,
setPeripheralControl,
setMicPttActive,
],
);
+26
View File
@@ -44,6 +44,11 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.horn;
}, [rosterEntry]);
const peripherals = useMemo(
() => (Array.isArray(rosterEntry?.peripherals) ? rosterEntry.peripherals : []),
[rosterEntry],
);
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
const emitCommand = useCallback(
@@ -208,6 +213,22 @@ export function useCommandPipeline(options = {}) {
[emitCommand, roverId],
);
const sendPeripheralControl = useCallback(
(peripheralId, controlId, value) => {
if (!roverId || !peripheralId || !controlId) return null;
const peripheral = { id: peripheralId, control: controlId, value };
// Peripheral commands deliberately use the same command envelope as all
// other rover actuation. This keeps turn authorization, acknowledgements,
// and rover WebSocket routing in the server's existing command boundary.
emitCommand({
type: 'peripheral',
data: { peripheral },
});
return peripheral;
},
[emitCommand, roverId],
);
const sendSong = useCallback(
(notes = [], options = {}) => {
if (!roverId) return null;
@@ -244,6 +265,7 @@ export function useCommandPipeline(options = {}) {
laser,
laserState,
horn,
peripherals,
emitCommand,
enableSensorStream,
sendDriveDirect,
@@ -253,6 +275,7 @@ export function useCommandPipeline(options = {}) {
sendHeadlight,
sendLaser,
sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps,
}),
@@ -265,6 +288,7 @@ export function useCommandPipeline(options = {}) {
laser,
laserState,
horn,
peripherals,
emitCommand,
enableSensorStream,
sendDriveDirect,
@@ -274,6 +298,8 @@ export function useCommandPipeline(options = {}) {
sendHeadlight,
sendLaser,
sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps,
],
);
+25
View File
@@ -69,6 +69,10 @@ export const initialControlState = {
macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP,
inputs: {},
// Generic accessory controls do not currently report state back from roverd.
// Keep the last browser-issued value per rover/device/control so closing a
// drawer or changing responsive layouts does not make the UI lie about it.
peripheralValues: {},
};
export function controlReducer(state, action) {
@@ -226,6 +230,27 @@ export function controlReducer(state, action) {
active: Boolean(action.payload),
},
};
case 'control/set-peripheral-value': {
const roverId = String(action.payload?.roverId || '');
const peripheralId = String(action.payload?.peripheralId || '');
const controlId = String(action.payload?.controlId || '');
if (!roverId || !peripheralId || !controlId) return state;
const roverValues = state.peripheralValues?.[roverId] || {};
const peripheralValues = roverValues[peripheralId] || {};
return {
...state,
peripheralValues: {
...(state.peripheralValues || {}),
[roverId]: {
...roverValues,
[peripheralId]: {
...peripheralValues,
[controlId]: action.payload.value,
},
},
},
};
}
default:
return state;
}