From a59bd805ae99a0a21f507f7306d2f3fa4c0269dd Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 23 May 2025 15:01:48 -0400 Subject: [PATCH] more components updated with missing translations --- src/components/BatteryStatus.tsx | 10 +- src/components/Form/FormMultiSelect.tsx | 61 ++++++------ .../PageComponents/Connect/Serial.tsx | 6 +- src/components/Sidebar.tsx | 8 +- src/components/ThemeSwitcher.tsx | 15 ++- src/components/UI/Footer.tsx | 31 +++--- .../generic/Filter/FilterControl.tsx | 4 +- src/core/hooks/usePositionFlags.ts | 99 ++++++++++++------- src/i18n/locales/en.json | 24 ++++- src/pages/Channels.tsx | 10 +- 10 files changed, 165 insertions(+), 103 deletions(-) diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx index cf2d2523..e0c9e189 100644 --- a/src/components/BatteryStatus.tsx +++ b/src/components/BatteryStatus.tsx @@ -32,25 +32,25 @@ const getBatteryStates = ( condition: (level) => level > 100, Icon: PlugZapIcon, className: "text-gray-500", - text: () => t("common_batteryStatus.pluggedIn"), + text: () => t("common_batteryStatus_pluggedIn"), }, { condition: (level) => level > 80, Icon: BatteryFullIcon, className: "text-green-500", - text: (level) => t("common_batteryStatus.charging", { level }), + text: (level) => t("common_batteryStatus_charging", { level }), }, { condition: (level) => level > 20, Icon: BatteryMediumIcon, className: "text-yellow-500", - text: (level) => t("common_batteryStatus.charging", { level }), + text: (level) => t("common_batteryStatus_charging", { level }), }, { condition: () => true, Icon: BatteryLowIcon, className: "text-red-500", - text: (level) => t("common_batteryStatus.charging", { level }), + text: (level) => t("common_batteryStatus_charging", { level }), }, ]; }; @@ -91,7 +91,7 @@ const BatteryStatus: React.FC = ({ deviceMetrics }) => { title={voltageTitle} > - + {statusText} diff --git a/src/components/Form/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx index 6eb444eb..215e6d43 100644 --- a/src/components/Form/FormMultiSelect.tsx +++ b/src/components/Form/FormMultiSelect.tsx @@ -3,6 +3,8 @@ import type { GenericFormElementProps, } from "@components/Form/DynamicForm.tsx"; import type { FieldValues } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts"; import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx"; export interface MultiSelectFieldProps extends BaseFormBuilderProps { @@ -12,51 +14,50 @@ export interface MultiSelectFieldProps extends BaseFormBuilderProps { isChecked: (name: string) => boolean; value: string[]; properties: BaseFormBuilderProps["properties"] & { - enumValue: { - [s: string]: string | number; - }; + enumValue: + | { [s: string]: string | number } + | typeof FLAGS_CONFIG; formatEnumName?: boolean; }; } -const formatEnumDisplay = (name: string): string => { - return name - .replace(/_/g, " ") - .toLowerCase() - .split(" ") - .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) - .join(" "); -}; - export function MultiSelectInput({ field, }: GenericFormElementProps>) { - const { enumValue, formatEnumName, ...remainingProperties } = - field.properties; + const { t } = useTranslation(); + const { enumValue, ...remainingProperties } = field.properties; - const valueToKeyMap: Record = {}; - const optionsEnumValues: [string, number][] = []; + const isNewConfigStructure = + typeof Object.values(enumValue)[0] === "object" && + Object.values(enumValue)[0] !== null && + "i18nKey" in Object.values(enumValue)[0]; - if (enumValue) { - Object.entries(enumValue).forEach(([key, val]) => { - if (typeof val === "number" && key !== "UNSET") { - valueToKeyMap[val.toString()] = key; - optionsEnumValues.push([key, val as number]); + const optionsToRender = Object.entries(enumValue).map( + ([key, configOrValue]) => { + if (isNewConfigStructure) { + const config = + configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG]; + return { + key, + display: t(config.i18nKey), + value: config.value, + }; } - }); - } + return { key, display: key, value: configOrValue as number }; + }, + ); return ( - {optionsEnumValues.map(([name, value]) => ( + {optionsToRender.map((option) => ( field.onValueChange(name)} + key={option.key} + name={option.key} + value={option.value.toString()} + checked={field.isChecked(option.key)} + onCheckedChange={() => field.onValueChange(option.key)} > - {formatEnumName ? formatEnumDisplay(name) : name} + {option.display} ))} diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index 720bb377..0367d837 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -69,7 +69,7 @@ export const Serial = ( // No need to setConnectionInProgress(false) here as closeDialog() unmounts. }} > - {t("serialConnection.deviceIdentifier", { + {t("serialConnection_deviceIdentifier", { index, vendorId: vendor, productId: product, @@ -79,7 +79,7 @@ export const Serial = ( })} {serialPorts.length === 0 && ( - {t("serialConnection.noDevicesPaired")} + {t("serialConnection_noDevicesPaired")} )} @@ -96,7 +96,7 @@ export const Serial = ( }); }} > - {t("serialConnection.newDeviceButton")} + {t("serialConnection_newDeviceButton")} ); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d1f1b495..51f48324 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -97,7 +97,7 @@ export const Sidebar = ({ children }: SidebarProps) => { }, { name: t("navigation_title_map"), icon: MapIcon, page: "map" }, { - name: t("navigation_title_radioConfig"), + name: t("navigation_title_config"), icon: SettingsIcon, page: "config", }, @@ -236,7 +236,7 @@ export const Sidebar = ({ children }: SidebarProps) => { className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> - {t("sidebar_deviceInfo.volts", { + {t("sidebar_deviceInfo_volts", { voltage: myNode.deviceMetrics?.voltage?.toPrecision(3) ?? t("common_unknown_short"), })} @@ -248,7 +248,7 @@ export const Sidebar = ({ children }: SidebarProps) => { className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> - {t("sidebar_deviceInfo.firmwareVersion", { + {t("sidebar_deviceInfo_firmwareVersion", { version: myMetadata?.firmwareVersion ?? t("common_unknown_short"), })} @@ -266,7 +266,7 @@ export const Sidebar = ({ children }: SidebarProps) => { > diff --git a/src/components/UI/Footer.tsx b/src/components/UI/Footer.tsx index 08b03e5a..607344b3 100644 --- a/src/components/UI/Footer.tsx +++ b/src/components/UI/Footer.tsx @@ -1,4 +1,5 @@ import { cn } from "@core/utils/cn.ts"; +import { Trans, useTranslation } from "react-i18next"; type FooterProps = { className?: string; @@ -14,19 +15,23 @@ const Footer = ({ className, ...props }: FooterProps) => { {...props} >

- - Powered by ▲ Vercel - {" "} - | Meshtastic® is a registered trademark of Meshtastic LLC. |{" "} - - Legal Information - + , + , + ]} + />

); diff --git a/src/components/generic/Filter/FilterControl.tsx b/src/components/generic/Filter/FilterControl.tsx index 24ae547d..819e104d 100644 --- a/src/components/generic/Filter/FilterControl.tsx +++ b/src/components/generic/Filter/FilterControl.tsx @@ -278,7 +278,7 @@ export function FilterControl({ <> {t("filter_control_slider_batteryLevel_labelText", { value: localFilterState.batteryLevel[0] === 101 - ? t("common_batteryStatus.pluggedIn") + ? t("common_batteryStatus_pluggedIn") : localFilterState.batteryLevel[0], })} {localFilterState.batteryLevel[0] !== @@ -286,7 +286,7 @@ export function FilterControl({ <> {" – "} {localFilterState.batteryLevel[1] === 101 - ? t("common_batteryStatus.pluggedIn") + ? t("common_batteryStatus_pluggedIn") : localFilterState.batteryLevel[1]} )} diff --git a/src/core/hooks/usePositionFlags.ts b/src/core/hooks/usePositionFlags.ts index 95459372..17d282dd 100644 --- a/src/core/hooks/usePositionFlags.ts +++ b/src/core/hooks/usePositionFlags.ts @@ -1,27 +1,44 @@ import { useCallback, useMemo, useState } from "react"; -const FLAGS = { - UNSET: 0, - Altitude: 1, - "Altitude is Mean Sea Level": 2, - "Altitude Geoidal Seperation": 4, - "Dilution of precision (DOP) PDOP used by default": 8, - "If DOP is set, use HDOP / VDOP values instead of PDOP": 16, - "Number of satellites": 32, - "Sequence number": 64, - Timestamp: 128, - "Vehicle heading": 256, - "Vehicle speed": 512, +export const FLAGS_CONFIG = { + UNSET: { value: 0, i18nKey: "position_flag_unset" }, + ALTITUDE: { value: 1, i18nKey: "position_flag_altitude" }, + ALTITUDE_MSL: { value: 2, i18nKey: "position_flag_altitude_msl" }, + ALTITUDE_GEOIDAL_SEPARATION: { + value: 4, + i18nKey: "position_flag_altitude_geoidal_separation", + }, + DOP: { + value: 8, + i18nKey: "position_flag_dop", + }, + HDOP_VDOP: { + value: 16, + i18nKey: "position_flag_hdop_vdop", + }, + NUM_SATELLITES: { + value: 32, + i18nKey: "position_flag_num_satellites", + }, + SEQUENCE_NUMBER: { + value: 64, + i18nKey: "position_flag_sequence_number", + }, + TIMESTAMP: { value: 128, i18nKey: "position_flag_timestamp" }, + VEHICLE_HEADING: { + value: 256, + i18nKey: "position_flag_vehicle_heading", + }, + VEHICLE_SPEED: { value: 512, i18nKey: "position_flag_vehicle_speed" }, } as const; -export type FlagName = keyof typeof FLAGS; -type FlagsObject = typeof FLAGS; +export type FlagName = keyof typeof FLAGS_CONFIG; type UsePositionFlagsProps = { decode: (value: number) => FlagName[]; encode: (flagNames: FlagName[]) => number; hasFlag: (value: number, flagName: FlagName) => boolean; - getAllFlags: () => FlagsObject; + getAllFlags: () => typeof FLAGS_CONFIG; isValidValue: (value: number) => boolean; flagsValue: number; activeFlags: FlagName[]; @@ -34,41 +51,52 @@ type UsePositionFlagsProps = { export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { const [flagsValue, setFlagsValue] = useState(initialValue); + const FLAGS_BITMASKS = useMemo(() => { + return Object.fromEntries( + Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]), + ) as { [K in FlagName]: typeof FLAGS_CONFIG[K]["value"] }; + }, []); + const utils = useMemo(() => { const decode = (value: number): FlagName[] => { - if (value === 0) return ["UNSET"]; + if (value === FLAGS_CONFIG.UNSET.value) return ["UNSET"]; const activeFlags: FlagName[] = []; - for (const [name, flagValue] of Object.entries(FLAGS)) { - if (flagValue !== 0 && (value & flagValue) === flagValue) { - activeFlags.push(name as FlagName); + for (const key in FLAGS_CONFIG) { + const flagName = key as FlagName; + const flagConfig = FLAGS_CONFIG[flagName]; + if ( + flagConfig.value !== 0 && + (value & flagConfig.value) === flagConfig.value + ) { + activeFlags.push(flagName); } } return activeFlags; }; const encode = (flagNames: FlagName[]): number => { - if (flagNames.includes("UNSET")) { - return 0; + if (flagNames.includes("UNSET") && flagNames.length === 1) { + return FLAGS_CONFIG.UNSET.value; } return flagNames.reduce((acc, name) => { - const value = FLAGS[name]; - return acc | value; + if (name === "UNSET") return acc; + return acc | FLAGS_CONFIG[name].value; }, 0); }; const hasFlag = (value: number, flagName: FlagName): boolean => { - const flagValue = FLAGS[flagName]; - return (value & flagValue) === flagValue; + return (value & FLAGS_CONFIG[flagName].value) === + FLAGS_CONFIG[flagName].value; }; - const getAllFlags = (): FlagsObject => { - return FLAGS; + const getAllFlags = (): typeof FLAGS_CONFIG => { + return FLAGS_CONFIG; }; const isValidValue = (value: number): boolean => { - const maxValue = Object.values(FLAGS) - .filter((val) => val !== 0) // Exclude UNSET (0) from the calculation + const maxValue = Object.values(FLAGS_BITMASKS) + .filter((val) => val !== 0) .reduce((acc, val) => acc | val, 0); return Number.isInteger(value) && value >= 0 && value <= maxValue; }; @@ -80,16 +108,17 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { getAllFlags, isValidValue, }; - }, []); + }, [FLAGS_BITMASKS]); const toggleFlag = useCallback((flagName: FlagName) => { - const flagValue = FLAGS[flagName]; - setFlagsValue((prev) => prev ^ flagValue); + setFlagsValue((prev) => prev ^ FLAGS_CONFIG[flagName].value); }, []); const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { - const flagValue = FLAGS[flagName]; - setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue)); + const currentFlagValue = FLAGS_CONFIG[flagName].value; + setFlagsValue((prev) => + enabled ? prev | currentFlagValue : prev & ~currentFlagValue + ); }, []); const setFlags = useCallback( @@ -103,7 +132,7 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { ); const clearFlags = useCallback(() => { - setFlagsValue(0); + setFlagsValue(FLAGS_CONFIG.UNSET.value); }, []); const activeFlags = utils.decode(flagsValue); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bc35fa30..f8e00809 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1,6 +1,6 @@ { "common_title": "Meshtastic Web", - "common_app": "Meshtastic", + "common_header": "Meshtastic", "common_description": "Meshtastic Web Client", "common_button_close": "Close", "common_button_ok": "OK", @@ -31,6 +31,7 @@ "navigation_title": "Navigation", "navigation_title_messages": "Messages", "navigation_title_map": "Map", + "navigation_title_config": "Config", "navigation_title_radioConfig": "Radio Config", "navigation_title_moduleConfig": "Module Config", "navigation_title_channels": "Channels", @@ -583,5 +584,24 @@ "filter_control_toggle_favorites_label": "Favorites", "filter_control_toggle_hide_label": "Hide", "filter_control_toggle_showOnly_label": "Show Only", - "filter_control_toggle_viaMqtt_label": "Connected via MQTT" + "filter_control_toggle_viaMqtt_label": "Connected via MQTT", + + "position_flag_altitude": "Altitude", + "position_flag_altitude_geoidal_separation": "Altitude Geoidal Separation", + "position_flag_altitude_msl": "Altitude is Mean Sea Level", + "position_flag_dop": "Dilution of precision (DOP) PDOP used by default", + "position_flag_hdop_vdop": "If DOP is set, use HDOP / VDOP values instead of PDOP", + "position_flag_num_satellites": "Number of satellites", + "position_flag_sequence_number": "Sequence number", + "position_flag_timestamp": "Timestamp", + "position_flag_unset": "Unset", + "position_flag_vehicle_heading": "Vehicle heading", + "position_flag_vehicle_speed": "Vehicle speed", + + "theme_preference_dark": "Dark", + "theme_preference_light": "Light", + "theme_preference_system": "System", + "theme_switcher_aria_change_theme": "Change current theme", + + "footer_text": "Powered by <0>▲ Vercel | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information" } diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx index fb576462..0178fd32 100644 --- a/src/pages/Channels.tsx +++ b/src/pages/Channels.tsx @@ -11,7 +11,7 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Types } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core"; import i18next from "i18next"; -import { ImportIcon, QrCodeIcon } from "lucide-react"; +import { QrCodeIcon, UploadIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -43,14 +43,14 @@ const ChannelsPage = () => { : t("common_loading")} actions={[ { - key: "search", - icon: ImportIcon, + key: "import", + icon: UploadIcon, onClick() { setDialogOpen("import", true); }, }, { - key: "import", + key: "qr", icon: QrCodeIcon, onClick() { setDialogOpen("QR", true); @@ -59,7 +59,7 @@ const ChannelsPage = () => { ]} > - + {allChannels.map((channel) => (