mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
guh
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webui</title>
|
<title>webui</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BdPhVyWy.js"></script>
|
<script type="module" crossorigin src="/assets/index-NmzNrOYX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C4V7xiyW.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C4V7xiyW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+4
-1
@@ -8,6 +8,7 @@ import MobileControls, {
|
|||||||
MobileLandscapeControlColumn,
|
MobileLandscapeControlColumn,
|
||||||
} from './components/MobileControls.jsx';
|
} from './components/MobileControls.jsx';
|
||||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
||||||
|
import { SettingsProvider } from './settings/index.js';
|
||||||
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
||||||
import LogPanel from './components/LogPanel.jsx';
|
import LogPanel from './components/LogPanel.jsx';
|
||||||
import AuthPanel from './components/AuthPanel.jsx';
|
import AuthPanel from './components/AuthPanel.jsx';
|
||||||
@@ -114,13 +115,15 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-slate-50">
|
<div className="min-h-screen bg-black text-slate-50">
|
||||||
|
<SettingsProvider>
|
||||||
<ControlSystemProvider>
|
<ControlSystemProvider>
|
||||||
<KeyboardInputManager />
|
<KeyboardInputManager />
|
||||||
<GamepadInputManager />
|
<GamepadInputManager />
|
||||||
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main>
|
<main className="flex w-full flex-col gap-1 text-base">{renderedLayout}</main>
|
||||||
<AlertFeed />
|
<AlertFeed />
|
||||||
<ModeGateOverlay />
|
<ModeGateOverlay />
|
||||||
</ControlSystemProvider>
|
</ControlSystemProvider>
|
||||||
|
</SettingsProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import { INPUT_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
|
|
||||||
|
const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<label className="block rounded border border-white/5 p-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||||
|
<span className="font-semibold text-slate-100">{label}</span>
|
||||||
|
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
className="mt-1 w-full accent-emerald-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InputSettings() {
|
||||||
|
const { value, save } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||||
|
const gamepad = value.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad;
|
||||||
|
const mobile = value.mobile ?? INPUT_SETTINGS_DEFAULTS.mobile;
|
||||||
|
|
||||||
|
const updateGamepad = (patch) => {
|
||||||
|
save((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gamepad: { ...(prev.gamepad ?? INPUT_SETTINGS_DEFAULTS.gamepad), ...patch },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMobile = (patch) => {
|
||||||
|
save((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mobile: { ...(prev.mobile ?? INPUT_SETTINGS_DEFAULTS.mobile), ...patch },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-sm bg-[#1d232b] p-1 text-sm text-slate-100">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Input tuning</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Gamepad</p>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<SliderField
|
||||||
|
label="Drive deadzone"
|
||||||
|
description="Ignore small stick movements"
|
||||||
|
min={0}
|
||||||
|
max={0.5}
|
||||||
|
step={0.01}
|
||||||
|
value={gamepad.driveDeadzone}
|
||||||
|
onChange={(driveDeadzone) => updateGamepad({ driveDeadzone })}
|
||||||
|
/>
|
||||||
|
<SliderField
|
||||||
|
label="Camera deadzone"
|
||||||
|
description="Tilt stick sensitivity"
|
||||||
|
min={0}
|
||||||
|
max={0.5}
|
||||||
|
step={0.01}
|
||||||
|
value={gamepad.cameraDeadzone}
|
||||||
|
onChange={(cameraDeadzone) => updateGamepad({ cameraDeadzone })}
|
||||||
|
/>
|
||||||
|
<SliderField
|
||||||
|
label="Servo step"
|
||||||
|
description="Degrees per camera tick"
|
||||||
|
min={0.5}
|
||||||
|
max={6}
|
||||||
|
step={0.25}
|
||||||
|
value={gamepad.servoStep}
|
||||||
|
onChange={(servoStep) => updateGamepad({ servoStep })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Mobile joystick</p>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<SliderField
|
||||||
|
label="Joystick radius"
|
||||||
|
description="Drag distance needed for full speed"
|
||||||
|
min={50}
|
||||||
|
max={140}
|
||||||
|
step={5}
|
||||||
|
value={mobile.joystickRadius}
|
||||||
|
onChange={(joystickRadius) => updateMobile({ joystickRadius })}
|
||||||
|
/>
|
||||||
|
<SliderField
|
||||||
|
label="Smoothing"
|
||||||
|
description="Lower values feel more direct"
|
||||||
|
min={0}
|
||||||
|
max={0.6}
|
||||||
|
step={0.02}
|
||||||
|
value={mobile.joystickSmoothing}
|
||||||
|
onChange={(joystickSmoothing) => updateMobile({ joystickSmoothing })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
|
|||||||
import { controlReducer, initialControlState } from './controlReducer.js';
|
import { controlReducer, initialControlState } from './controlReducer.js';
|
||||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||||
import { useCommandPipeline } from './commandPipeline.js';
|
import { useCommandPipeline } from './commandPipeline.js';
|
||||||
import { loadControlSettings, saveControlSettings } from './persistence.js';
|
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
||||||
import { DEFAULT_KEYMAP } from './constants.js';
|
|
||||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
|
||||||
const ControlSystemContext = createContext(null);
|
const ControlSystemContext = createContext(null);
|
||||||
|
|
||||||
@@ -25,16 +25,23 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const pipeline = useCommandPipeline();
|
const pipeline = useCommandPipeline();
|
||||||
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
||||||
const servoAngleRef = useRef(initialControlState.camera.angle);
|
const servoAngleRef = useRef(initialControlState.camera.angle);
|
||||||
|
const {
|
||||||
|
value: controlSettings,
|
||||||
|
save: saveControlSettings,
|
||||||
|
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
|
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
|
||||||
}, [pipeline.roverId]);
|
}, [pipeline.roverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: 'control/settings/loading' });
|
if (controlSettings?.keymap) {
|
||||||
const data = loadControlSettings();
|
dispatch({ type: 'control/set-keymap', payload: controlSettings.keymap });
|
||||||
dispatch({ type: 'control/settings/loaded', payload: data });
|
}
|
||||||
}, []);
|
if (controlSettings?.macros) {
|
||||||
|
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
|
||||||
|
}
|
||||||
|
}, [controlSettings?.keymap, controlSettings?.macros]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const config = pipeline.servoConfig;
|
const config = pipeline.servoConfig;
|
||||||
@@ -98,23 +105,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
[pipeline],
|
[pipeline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistSettings = useCallback(
|
|
||||||
(partial) => {
|
|
||||||
const merged = { ...(state.settings.data ?? {}), ...(partial ?? {}) };
|
|
||||||
const success = saveControlSettings(merged);
|
|
||||||
if (success) {
|
|
||||||
dispatch({ type: 'control/settings/loaded', payload: merged });
|
|
||||||
} else {
|
|
||||||
dispatch({
|
|
||||||
type: 'control/settings/error',
|
|
||||||
payload: new Error('Failed to save control settings'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
},
|
|
||||||
[state.settings.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateKeyBinding = useCallback(
|
const updateKeyBinding = useCallback(
|
||||||
(bindingId, keyValue) => {
|
(bindingId, keyValue) => {
|
||||||
if (!bindingId) return false;
|
if (!bindingId) return false;
|
||||||
@@ -123,17 +113,23 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const next = cloneKeymap(state.keymap);
|
const next = cloneKeymap(state.keymap);
|
||||||
next[bindingId] = [canonical];
|
next[bindingId] = [canonical];
|
||||||
dispatch({ type: 'control/set-keymap', payload: next });
|
dispatch({ type: 'control/set-keymap', payload: next });
|
||||||
persistSettings({ keymap: next });
|
saveControlSettings((current) => ({
|
||||||
|
...(current ?? {}),
|
||||||
|
keymap: next,
|
||||||
|
}));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[state.keymap, persistSettings],
|
[state.keymap, saveControlSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetKeyBindings = useCallback(() => {
|
const resetKeyBindings = useCallback(() => {
|
||||||
const defaults = cloneKeymap(DEFAULT_KEYMAP);
|
const defaults = cloneKeymap(DEFAULT_KEYMAP);
|
||||||
dispatch({ type: 'control/set-keymap', payload: defaults });
|
dispatch({ type: 'control/set-keymap', payload: defaults });
|
||||||
persistSettings({ keymap: defaults });
|
saveControlSettings((current) => ({
|
||||||
}, [persistSettings]);
|
...(current ?? {}),
|
||||||
|
keymap: defaults,
|
||||||
|
}));
|
||||||
|
}, [saveControlSettings]);
|
||||||
|
|
||||||
const setServoAngle = useCallback(
|
const setServoAngle = useCallback(
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -218,12 +214,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reloadSettings = useCallback(() => {
|
|
||||||
dispatch({ type: 'control/settings/loading' });
|
|
||||||
const next = loadControlSettings();
|
|
||||||
dispatch({ type: 'control/settings/loaded', payload: next });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -243,8 +233,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
reloadSettings,
|
|
||||||
persistSettings,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -263,8 +251,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
reloadSettings,
|
|
||||||
persistSettings,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,3 @@ export const DEFAULT_MACROS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CONTROL_SETTINGS_COOKIE = 'roverControlSettings';
|
|
||||||
export const CONTROL_SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
||||||
|
|||||||
@@ -30,33 +30,8 @@ export const initialControlState = {
|
|||||||
macros: DEFAULT_MACROS,
|
macros: DEFAULT_MACROS,
|
||||||
keymap: DEFAULT_KEYMAP,
|
keymap: DEFAULT_KEYMAP,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
settings: {
|
|
||||||
status: 'idle',
|
|
||||||
error: null,
|
|
||||||
lastLoadedAt: null,
|
|
||||||
lastSavedAt: null,
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function mergeSettings(state, payload) {
|
|
||||||
const nextSettings = {
|
|
||||||
status: 'ready',
|
|
||||||
error: null,
|
|
||||||
lastLoadedAt: Date.now(),
|
|
||||||
lastSavedAt: state.settings.lastSavedAt,
|
|
||||||
data: payload ?? state.settings.data,
|
|
||||||
};
|
|
||||||
const nextState = { ...state, settings: nextSettings };
|
|
||||||
if (payload?.keymap) {
|
|
||||||
nextState.keymap = { ...state.keymap, ...payload.keymap };
|
|
||||||
}
|
|
||||||
if (payload?.macros) {
|
|
||||||
nextState.macros = payload.macros;
|
|
||||||
}
|
|
||||||
return nextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function controlReducer(state, action) {
|
export function controlReducer(state, action) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'control/set-rover':
|
case 'control/set-rover':
|
||||||
@@ -124,26 +99,6 @@ export function controlReducer(state, action) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'control/settings/loading':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
settings: {
|
|
||||||
...state.settings,
|
|
||||||
status: 'loading',
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'control/settings/loaded':
|
|
||||||
return mergeSettings(state, action.payload);
|
|
||||||
case 'control/settings/error':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
settings: {
|
|
||||||
...state.settings,
|
|
||||||
status: 'error',
|
|
||||||
error: action.payload,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'control/set-keymap':
|
case 'control/set-keymap':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { CONTROL_SETTINGS_COOKIE, CONTROL_SETTINGS_MAX_AGE } from './constants.js';
|
|
||||||
|
|
||||||
function parseCookieValue(raw) {
|
|
||||||
try {
|
|
||||||
const decoded = decodeURIComponent(raw ?? '');
|
|
||||||
return JSON.parse(decoded);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse control settings cookie', error); // eslint-disable-line no-console
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadControlSettings() {
|
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
const cookiePrefix = `${CONTROL_SETTINGS_COOKIE}=`;
|
|
||||||
const entry = document.cookie
|
|
||||||
.split(';')
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.find((part) => part.startsWith(cookiePrefix));
|
|
||||||
if (!entry) return null;
|
|
||||||
const raw = entry.substring(cookiePrefix.length);
|
|
||||||
return parseCookieValue(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveControlSettings(settings) {
|
|
||||||
if (typeof document === 'undefined') return false;
|
|
||||||
try {
|
|
||||||
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
|
|
||||||
const cookie = `${CONTROL_SETTINGS_COOKIE}=${serialized}; path=/; max-age=${CONTROL_SETTINGS_MAX_AGE}; samesite=strict`;
|
|
||||||
document.cookie = cookie;
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to write control settings cookie', error); // eslint-disable-line no-console
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { loadSettings, saveSettings } from './persistence.js';
|
||||||
|
|
||||||
|
const SettingsContext = createContext(null);
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }) {
|
||||||
|
const [state, setState] = useState(() => ({ status: 'loading', data: {} }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loaded = loadSettings();
|
||||||
|
setState({ status: 'ready', data: loaded ?? {} });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
const loaded = loadSettings();
|
||||||
|
setState({ status: 'ready', data: loaded ?? {} });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveAll = useCallback((nextData) => {
|
||||||
|
const success = saveSettings(nextData ?? {});
|
||||||
|
if (success) {
|
||||||
|
setState({ status: 'ready', data: nextData ?? {} });
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({ ...prev, status: 'error' }));
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setNamespace = useCallback((namespace, updater) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const current = prev.data[namespace] || {};
|
||||||
|
const nextValue = typeof updater === 'function' ? updater(current) : { ...current, ...(updater ?? {}) };
|
||||||
|
const nextData = { ...prev.data, [namespace]: nextValue };
|
||||||
|
const success = saveSettings(nextData);
|
||||||
|
if (!success) {
|
||||||
|
return { ...prev, status: 'error' };
|
||||||
|
}
|
||||||
|
return { status: 'ready', data: nextData };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
data: state.data,
|
||||||
|
status: state.status,
|
||||||
|
reload,
|
||||||
|
saveAll,
|
||||||
|
setNamespace,
|
||||||
|
}),
|
||||||
|
[state, reload, saveAll, setNamespace],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SettingsContext.Provider value={contextValue}>{children}</SettingsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const ctx = useContext(SettingsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSettings must be used within SettingsProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsNamespace(namespace, defaults = {}) {
|
||||||
|
const { data, status, setNamespace } = useSettings();
|
||||||
|
const value = data[namespace] ?? defaults ?? {};
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
(update) => {
|
||||||
|
setNamespace(namespace, (current) => {
|
||||||
|
const base = current ?? {};
|
||||||
|
if (typeof update === 'function') {
|
||||||
|
return update(base);
|
||||||
|
}
|
||||||
|
return { ...base, ...(update ?? {}) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[namespace, setNamespace],
|
||||||
|
);
|
||||||
|
|
||||||
|
const replace = useCallback(
|
||||||
|
(nextValue) => {
|
||||||
|
setNamespace(namespace, () => nextValue ?? {});
|
||||||
|
},
|
||||||
|
[namespace, setNamespace],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
const base = typeof defaults === 'object' ? { ...defaults } : defaults;
|
||||||
|
setNamespace(namespace, () => base ?? {});
|
||||||
|
}, [defaults, namespace, setNamespace]);
|
||||||
|
|
||||||
|
return { value, status, save, replace, reset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const SETTINGS_COOKIE = 'roverSettings';
|
||||||
|
export const SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SettingsProvider, useSettings, useSettingsNamespace } from './SettingsProvider.jsx';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const INPUT_SETTINGS_DEFAULTS = {
|
||||||
|
gamepad: {
|
||||||
|
driveDeadzone: 0.2,
|
||||||
|
cameraDeadzone: 0.25,
|
||||||
|
servoStep: 2,
|
||||||
|
auxReverseScale: 0.55,
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
joystickRadius: 80,
|
||||||
|
joystickSmoothing: 0.15,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { SETTINGS_COOKIE, SETTINGS_MAX_AGE } from './constants.js';
|
||||||
|
|
||||||
|
function parseCookieValue(raw) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(raw ?? '');
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse settings cookie', error); // eslint-disable-line no-console
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSettings() {
|
||||||
|
if (typeof document === 'undefined') return {};
|
||||||
|
const cookiePrefix = `${SETTINGS_COOKIE}=`;
|
||||||
|
const entry = document.cookie
|
||||||
|
.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(cookiePrefix));
|
||||||
|
if (!entry) return {};
|
||||||
|
const raw = entry.substring(cookiePrefix.length);
|
||||||
|
return parseCookieValue(raw) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(settings) {
|
||||||
|
if (typeof document === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
|
||||||
|
const cookie = `${SETTINGS_COOKIE}=${serialized}; path=/; max-age=${SETTINGS_MAX_AGE}; samesite=strict`;
|
||||||
|
document.cookie = cookie;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to write settings cookie', error); // eslint-disable-line no-console
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user