* chore: remove unused logo SVG files * feat: add interactive fixed position picker with map interface - Created new FixedPositionPicker component with clickable map for setting device coordinates - Added form field type for fixed position picker that appears when fixedPosition toggle is enabled - Implemented position request functionality to retrieve current device location * feat: display altitude unit based on user's display settings - Added dynamic altitude unit (Meters/Feet) that respects the user's imperial/metric display preference - Updated altitude field description to show the appropriate unit instead of hardcoded "Meters" * refactor: replace any type with MapLayerMouseEvent in map click handler * refactor: improve accessibility and code quality in FixedPositionPicker - Replace hardcoded IDs with useId() hook for proper accessibility - Use Number.isNaN() instead of isNaN() for more reliable type checking - Add radix parameter to parseInt() and remove unnecessary fragment wrapper * refactor: simplify fixed position picker integration - Removed dedicated FixedPositionPicker form field type in favor of toggle's additionalContent prop - Moved FixedPositionPicker to render conditionally within toggle field instead of as separate dynamic field - Streamlined form field types by eliminating FixedPositionPickerFieldProps * style: format code with consistent line breaks and import ordering * refactor: simplify fixed position picker container styling * feat: disable fixed position toggle when GPS is enabled * refactor: use ComponentRef instead of ElementRef in Switch component * refactor: replace interactive map picker with inline coordinate fields for fixed position - Removed FixedPositionPicker component with map interface - Added latitude, longitude, and altitude fields directly to position form - Moved coordinate validation into PositionValidationSchema with proper min/max bounds - Updated translation strings to include coordinate ranges and improved altitude description - Coordinates now sent via setFixedPosition admin message on form submit when fixedPosition is enabled * refactor: simplify toggle field by removing additionalContent prop and unused field spreading - Removed additionalContent prop and its JSDoc documentation from ToggleFieldProps - Removed rendering of additionalContent below toggle switch - Cleaned up Controller render function by removing unused rest spread operator - Renamed field destructuring to controllerField for clarity * refactor: improve fixed position handling and add position broadcast request - Restructure onSubmit to save config before sending admin message - Add position broadcast request after setting fixed position to immediately update display - Add comprehensive debug logging throughout submission flow - Extract coordinate exclusion logic earlier in submission process for clarity - Add 1 second delay before requesting position broadcast to allow fixed position processing * feat: add max length constraint to latitude and longitude fields - Set fieldLength.max to 10 for both latitude and longitude inputs - Prevents excessive decimal precision while maintaining 7 decimal places (±1.1cm accuracy)
342 lines
9.7 KiB
TypeScript
342 lines
9.7 KiB
TypeScript
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
|
|
import { toBinary } from "@bufbuild/protobuf";
|
|
import { PageLayout } from "@components/PageLayout.tsx";
|
|
import { Sidebar } from "@components/Sidebar.tsx";
|
|
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
|
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
|
import { useToast } from "@core/hooks/useToast.ts";
|
|
import { useDevice } from "@core/stores";
|
|
import { cn } from "@core/utils/cn.ts";
|
|
import { Protobuf } from "@meshtastic/core";
|
|
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
|
|
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
|
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
import {
|
|
LayersIcon,
|
|
RadioTowerIcon,
|
|
RefreshCwIcon,
|
|
RouterIcon,
|
|
SaveIcon,
|
|
SaveOff,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { RadioConfig } from "./RadioConfig.tsx";
|
|
|
|
const ConfigPage = () => {
|
|
const {
|
|
getAllConfigChanges,
|
|
getAllModuleConfigChanges,
|
|
getAllChannelChanges,
|
|
getAllQueuedAdminMessages,
|
|
connection,
|
|
clearAllChanges,
|
|
setConfig,
|
|
setModuleConfig,
|
|
addChannel,
|
|
getConfigChangeCount,
|
|
getModuleConfigChangeCount,
|
|
getChannelChangeCount,
|
|
getAdminMessageChangeCount,
|
|
} = useDevice();
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
|
|
const unsubRef = useRef<(() => void) | null>(null);
|
|
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
|
|
const { toast } = useToast();
|
|
const navigate = useNavigate();
|
|
const routerState = useRouterState();
|
|
const { t } = useTranslation("config");
|
|
|
|
const configChangeCount = getConfigChangeCount();
|
|
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
|
const channelChangeCount = getChannelChangeCount();
|
|
const adminMessageChangeCount = getAdminMessageChangeCount();
|
|
|
|
const sections = useMemo(
|
|
() => [
|
|
{
|
|
key: "radio",
|
|
route: radioRoute,
|
|
label: t("navigation.radioConfig"),
|
|
icon: RadioTowerIcon,
|
|
changeCount: configChangeCount,
|
|
component: RadioConfig,
|
|
},
|
|
{
|
|
key: "device",
|
|
route: deviceRoute,
|
|
label: t("navigation.deviceConfig"),
|
|
icon: RouterIcon,
|
|
changeCount: moduleConfigChangeCount,
|
|
component: DeviceConfig,
|
|
},
|
|
{
|
|
key: "module",
|
|
route: moduleRoute,
|
|
label: t("navigation.moduleConfig"),
|
|
icon: LayersIcon,
|
|
changeCount: channelChangeCount,
|
|
component: ModuleConfig,
|
|
},
|
|
],
|
|
[t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
|
|
);
|
|
|
|
const activeSection =
|
|
sections.find((section) =>
|
|
routerState.location.pathname.includes(`/settings/${section.key}`),
|
|
) ?? sections[0];
|
|
|
|
const onFormInit = useCallback(
|
|
<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
|
setFormMethods(methods as UseFormReturn);
|
|
|
|
setRhfState({
|
|
// Assume defailt on init, changes will be caught by subscription
|
|
isDirty: false,
|
|
isValid: true,
|
|
});
|
|
|
|
// Unsubscribe from previous subscriptions & subscribe to form changes
|
|
unsubRef.current?.();
|
|
unsubRef.current = methods.subscribe({
|
|
formState: { isDirty: true, isValid: true },
|
|
callback: ({ isValid, isDirty }) => {
|
|
setRhfState({
|
|
isDirty: isDirty ?? false,
|
|
isValid: isValid ?? true,
|
|
});
|
|
},
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => unsubRef.current?.();
|
|
}, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
const channelChanges = getAllChannelChanges();
|
|
const configChanges = getAllConfigChanges();
|
|
const moduleConfigChanges = getAllModuleConfigChanges();
|
|
const adminMessages = getAllQueuedAdminMessages();
|
|
|
|
await Promise.all(
|
|
channelChanges.map((channel) =>
|
|
connection?.setChannel(channel).then(() => {
|
|
toast({
|
|
title: t("toast.savedChannel.title", {
|
|
ns: "ui",
|
|
channelName: channel.settings?.name,
|
|
}),
|
|
});
|
|
}),
|
|
),
|
|
);
|
|
|
|
await Promise.all(
|
|
configChanges.map((newConfig) =>
|
|
connection?.setConfig(newConfig).then(() => {
|
|
toast({
|
|
title: t("toast.saveSuccess.title"),
|
|
description: t("toast.saveSuccess.description", {
|
|
case: newConfig.payloadVariant.case,
|
|
}),
|
|
});
|
|
}),
|
|
),
|
|
);
|
|
|
|
await Promise.all(
|
|
moduleConfigChanges.map((newModuleConfig) =>
|
|
connection?.setModuleConfig(newModuleConfig).then(() =>
|
|
toast({
|
|
title: t("toast.saveSuccess.title"),
|
|
description: t("toast.saveSuccess.description", {
|
|
case: newModuleConfig.payloadVariant.case,
|
|
}),
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (configChanges.length > 0 || moduleConfigChanges.length > 0) {
|
|
await connection?.commitEditSettings();
|
|
}
|
|
|
|
// Send queued admin messages after configs are committed
|
|
if (adminMessages.length > 0) {
|
|
await Promise.all(
|
|
adminMessages.map((message) =>
|
|
connection?.sendPacket(
|
|
toBinary(Protobuf.Admin.AdminMessageSchema, message),
|
|
Protobuf.Portnums.PortNum.ADMIN_APP,
|
|
"self",
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
channelChanges.forEach((newChannel) => {
|
|
addChannel(newChannel);
|
|
});
|
|
configChanges.forEach((newConfig) => {
|
|
setConfig(newConfig);
|
|
});
|
|
moduleConfigChanges.forEach((newModuleConfig) => {
|
|
setModuleConfig(newModuleConfig);
|
|
});
|
|
|
|
clearAllChanges();
|
|
|
|
if (formMethods) {
|
|
formMethods.reset(formMethods.getValues(), {
|
|
keepDirty: false,
|
|
keepErrors: false,
|
|
keepTouched: false,
|
|
keepValues: true,
|
|
});
|
|
|
|
formMethods.trigger();
|
|
}
|
|
} catch (_error) {
|
|
toast({
|
|
title: t("toast.configSaveError.title"),
|
|
description: t("toast.configSaveError.description"),
|
|
});
|
|
} finally {
|
|
setIsSaving(false);
|
|
toast({
|
|
title: t("toast.saveAllSuccess.title"),
|
|
description: t("toast.saveAllSuccess.description"),
|
|
});
|
|
}
|
|
}, [
|
|
toast,
|
|
t,
|
|
getAllConfigChanges,
|
|
connection,
|
|
getAllModuleConfigChanges,
|
|
getAllChannelChanges,
|
|
getAllQueuedAdminMessages,
|
|
formMethods,
|
|
addChannel,
|
|
setConfig,
|
|
setModuleConfig,
|
|
clearAllChanges,
|
|
]);
|
|
|
|
const handleReset = useCallback(() => {
|
|
if (formMethods) {
|
|
formMethods.reset();
|
|
}
|
|
clearAllChanges();
|
|
}, [formMethods, clearAllChanges]);
|
|
|
|
const leftSidebar = useMemo(
|
|
() => (
|
|
<Sidebar>
|
|
<SidebarSection label={t("sidebar.label")} className="py-2 px-0">
|
|
{sections.map((section) => (
|
|
<SidebarButton
|
|
key={section.key}
|
|
label={section.label}
|
|
active={activeSection?.key === section.key}
|
|
onClick={() => navigate({ to: section.route.to })}
|
|
Icon={section.icon}
|
|
isDirty={section.changeCount > 0}
|
|
count={section.changeCount}
|
|
/>
|
|
))}
|
|
</SidebarSection>
|
|
</Sidebar>
|
|
),
|
|
[sections, activeSection?.key, navigate, t],
|
|
);
|
|
|
|
const hasDrafts =
|
|
getConfigChangeCount() > 0 ||
|
|
getModuleConfigChangeCount() > 0 ||
|
|
getChannelChangeCount() > 0 ||
|
|
adminMessageChangeCount > 0;
|
|
const hasPending = hasDrafts || rhfState.isDirty;
|
|
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
|
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
|
|
|
const actions = useMemo(
|
|
() => [
|
|
{
|
|
key: "unsavedChanges",
|
|
label: t("common:formValidation.unsavedChanges"),
|
|
onClick: () => {},
|
|
className: cn([
|
|
"bg-blue-500 text-slate-900 hover:bg-initial",
|
|
"transition-colors duration-200",
|
|
buttonOpacity,
|
|
"transition-opacity",
|
|
]),
|
|
},
|
|
{
|
|
key: "reset",
|
|
icon: RefreshCwIcon,
|
|
label: t("common:button.reset"),
|
|
onClick: handleReset,
|
|
className: cn([
|
|
buttonOpacity,
|
|
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
|
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
|
|
]),
|
|
},
|
|
{
|
|
key: "save",
|
|
icon: !hasPending ? SaveOff : SaveIcon,
|
|
isLoading: isSaving,
|
|
disabled: saveDisabled,
|
|
iconClasses:
|
|
!rhfState.isValid && hasPending
|
|
? "text-red-400 cursor-not-allowed"
|
|
: "cursor-pointer",
|
|
className: cn([
|
|
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
|
"hover:dark:bg-slate-300 hover:dark:text-black",
|
|
"disabled:hover:cursor-not-allowed cursor-pointer",
|
|
]),
|
|
onClick: handleSave,
|
|
label: t("common:button.save"),
|
|
},
|
|
],
|
|
[
|
|
isSaving,
|
|
hasPending,
|
|
rhfState.isValid,
|
|
saveDisabled,
|
|
buttonOpacity,
|
|
handleReset,
|
|
handleSave,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const ActiveComponent = activeSection?.component;
|
|
|
|
return (
|
|
<PageLayout
|
|
contentClassName="overflow-auto"
|
|
leftBar={leftSidebar}
|
|
label={activeSection?.label ?? ""}
|
|
actions={actions}
|
|
>
|
|
{ActiveComponent && <ActiveComponent onFormInit={onFormInit} />}
|
|
</PageLayout>
|
|
);
|
|
};
|
|
|
|
export default ConfigPage;
|