Browse Source

more components updated with missing translations

pull/627/head
Dan Ditomaso 1 year ago
parent
commit
a59bd805ae
  1. 10
      src/components/BatteryStatus.tsx
  2. 61
      src/components/Form/FormMultiSelect.tsx
  3. 6
      src/components/PageComponents/Connect/Serial.tsx
  4. 8
      src/components/Sidebar.tsx
  5. 15
      src/components/ThemeSwitcher.tsx
  6. 31
      src/components/UI/Footer.tsx
  7. 4
      src/components/generic/Filter/FilterControl.tsx
  8. 99
      src/core/hooks/usePositionFlags.ts
  9. 24
      src/i18n/locales/en.json
  10. 10
      src/pages/Channels.tsx

10
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<BatteryStatusProps> = ({ deviceMetrics }) => {
title={voltageTitle}
>
<BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label={t("common_batteryStatus.title")}>
<Subtle aria-label={t("common_batteryStatus_title")}>
{statusText}
</Subtle>
</div>

61
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<T> extends BaseFormBuilderProps<T> {
@ -12,51 +14,50 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
isChecked: (name: string) => boolean;
value: string[];
properties: BaseFormBuilderProps<T>["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<T extends FieldValues>({
field,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
const { t } = useTranslation();
const { enumValue, ...remainingProperties } = field.properties;
const valueToKeyMap: Record<string, string> = {};
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 (
<MultiSelect {...remainingProperties}>
{optionsEnumValues.map(([name, value]) => (
{optionsToRender.map((option) => (
<MultiSelectItem
key={name}
name={name}
value={value.toString()}
checked={field.isChecked(name)}
onCheckedChange={() => 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}
</MultiSelectItem>
))}
</MultiSelect>

6
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 && (
<Mono className="m-auto select-none">
{t("serialConnection.noDevicesPaired")}
{t("serialConnection_noDevicesPaired")}
</Mono>
)}
</div>
@ -96,7 +96,7 @@ export const Serial = (
});
}}
>
<span>{t("serialConnection.newDeviceButton")}</span>
<span>{t("serialConnection_newDeviceButton")}</span>
</Button>
</fieldset>
);

8
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"
/>
<Subtle>
{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"
/>
<Subtle>
{t("sidebar_deviceInfo.firmwareVersion", {
{t("sidebar_deviceInfo_firmwareVersion", {
version: myMetadata?.firmwareVersion ??
t("common_unknown_short"),
})}
@ -266,7 +266,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
>
<button
type="button"
aria-label={t("sidebar_deviceInfo.button_editDeviceName")}
aria-label={t("sidebar_deviceInfo_button_editDeviceName")}
className="p-1 rounded transition-colors cursor-pointer hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>

15
src/components/ThemeSwitcher.tsx

@ -1,6 +1,7 @@
import { useTheme } from "../core/hooks/useTheme.ts";
import { cn } from "../core/utils/cn.ts";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
type ThemePreference = "light" | "dark" | "system";
@ -10,6 +11,7 @@ export default function ThemeSwitcher({
className?: string;
}) {
const { preference, setPreference } = useTheme();
const { t } = useTranslation();
const themeIcons = {
light: <Sun className="size-6" />,
@ -24,7 +26,13 @@ export default function ThemeSwitcher({
setPreference(nextPreference);
};
const [firstCharOfPreference = "", ...restOfPreference] = preference;
const preferenceDisplayMap: Record<ThemePreference, string> = {
light: t("theme_preference_light"),
dark: t("theme_preference_dark"),
system: t("theme_preference_system"),
};
const currentDisplayPreference = preferenceDisplayMap[preference];
return (
<button
@ -34,14 +42,13 @@ export default function ThemeSwitcher({
className,
)}
onClick={toggleTheme}
aria-description="Change current theme"
aria-description={t("theme_switcher_aria_change_theme")}
>
<span
data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg"
>
{firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")}
{currentDisplayPreference}
</span>
{themeIcons[preference]}
</button>

31
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}
>
<p>
<a
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline text-link"
>
Powered by Vercel
</a>{" "}
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
<a
href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
>
Legal Information
</a>
<Trans
i18nKey="footer_text"
components={[
<a
key="vercel"
rel="noopener noreferrer"
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline text-link"
/>,
<a
key="legal"
rel="noopener noreferrer"
href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
/>,
]}
/>
</p>
</footer>
);

4
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]}
</>
)}

99
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<number>(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);

24
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</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>"
}

10
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 = () => {
]}
>
<Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800 ">
<TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => (
<TabsTrigger
key={channel.index}

Loading…
Cancel
Save