Feat(config): Align settings menu to match android/ios (#906)

* feat: aligned settings menu to match android/ios

* updated sidebar text size.

* Update packages/web/public/i18n/locales/en/config.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/public/i18n/locales/en/config.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/components/PageComponents/Settings/User.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* linting/formatting fixes

* fixed formatting issue

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-10-24 13:31:22 -04:00
committed by GitHub
co-authored by Copilot
parent ff02b1455d
commit 0cf677c8d5
45 changed files with 1116 additions and 777 deletions
@@ -0,0 +1,113 @@
import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx";
import { Device } from "@components/PageComponents/Settings/Device/index.tsx";
import { Display } from "@components/PageComponents/Settings/Display.tsx";
import { Network } from "@components/PageComponents/Settings/Network/index.tsx";
import { Position } from "@components/PageComponents/Settings/Position.tsx";
import { Power } from "@components/PageComponents/Settings/Power.tsx";
import { User } from "@components/PageComponents/Settings/User.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice, type ValidConfigType } from "@core/stores";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface ConfigProps {
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
type TabItem = {
case: ValidConfigType | "user";
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const { hasConfigChange, hasUserChange } = useDevice();
const { t } = useTranslation("config");
const tabs: TabItem[] = [
{
case: "user",
label: t("page.tabUser"),
element: User,
},
{
case: "device",
label: t("page.tabDevice"),
element: Device,
},
{
case: "position",
label: t("page.tabPosition"),
element: Position,
},
{
case: "power",
label: t("page.tabPower"),
element: Power,
},
{
case: "network",
label: t("page.tabNetwork"),
element: Network,
},
{
case: "display",
label: t("page.tabDisplay"),
element: Display,
},
{
case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
] as const;
const flags = useMemo(
() =>
new Map(
tabs.map((tab) => [
tab.case,
tab.case === "user"
? hasUserChange()
: hasConfigChange(tab.case as ValidConfigType),
]),
),
[tabs, hasConfigChange, hasUserChange],
);
return (
<Tabs defaultValue={t("page.tabUser")}>
<TabsList className="w-full dark:bg-slate-700">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
@@ -0,0 +1,132 @@
import { AmbientLighting } from "@components/PageComponents/ModuleConfig/AmbientLighting.tsx";
import { Audio } from "@components/PageComponents/ModuleConfig/Audio.tsx";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.tsx";
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.tsx";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.tsx";
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.tsx";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice, type ValidModuleConfigType } from "@core/stores";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface ConfigProps {
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
type TabItem = {
case: ValidModuleConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const { hasModuleConfigChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const tabs: TabItem[] = [
{
case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
] as const;
const flags = useMemo(
() =>
new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])),
[tabs, hasModuleConfigChange],
);
return (
<Tabs defaultValue={t("page.tabMqtt")}>
<TabsList className="w-full dark:bg-slate-800">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
@@ -0,0 +1,81 @@
import { Channels } from "@app/components/PageComponents/Channels/Channels";
import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx";
import { Security } from "@components/PageComponents/Settings/Security/Security.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice, type ValidConfigType } from "@core/stores";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface ConfigProps {
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
type TabItem = {
case: ValidConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const RadioConfig = ({ onFormInit }: ConfigProps) => {
const { hasConfigChange } = useDevice();
const { t } = useTranslation("config");
const tabs: TabItem[] = [
{
case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{
case: "channels",
label: t("page.tabChannels"),
element: Channels,
},
{
case: "security",
label: t("page.tabSecurity"),
element: Security,
},
] as const;
const flags = useMemo(
() => new Map(tabs.map((tab) => [tab.case, hasConfigChange(tab.case)])),
[tabs, hasConfigChange],
);
return (
<Tabs defaultValue={t("page.tabLora")}>
<TabsList className="w-full dark:bg-slate-700">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
+320
View File
@@ -0,0 +1,320 @@
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
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 { 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,
connection,
clearAllChanges,
setConfig,
setModuleConfig,
addChannel,
getConfigChangeCount,
getModuleConfigChangeCount,
getChannelChangeCount,
} = 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 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();
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();
}
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,
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;
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 onFormInit={onFormInit} />
</PageLayout>
);
};
export default ConfigPage;