Browse Source

feat: added internationalization lib, added english labels

pull/627/head
Dan Ditomaso 1 year ago
parent
commit
ee60edea5d
  1. 495
      deno.lock
  2. 5
      package.json
  3. 33
      src/components/BatteryStatus.tsx
  4. 58
      src/components/CommandPalette/index.tsx
  5. 13
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  6. 25
      src/components/Dialog/DeviceNameDialog.tsx
  7. 22
      src/components/Dialog/ImportDialog.tsx
  8. 21
      src/components/Dialog/LocationResponseDialog.tsx
  9. 67
      src/components/Dialog/NewDeviceDialog.tsx
  10. 96
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  11. 36
      src/components/Dialog/PKIBackupDialog.tsx
  12. 26
      src/components/Dialog/PkiRegenerateDialog.tsx
  13. 20
      src/components/Dialog/QRDialog.tsx
  14. 11
      src/components/Dialog/RebootDialog.tsx
  15. 29
      src/components/Dialog/RebootOTADialog.tsx
  16. 27
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  17. 14
      src/components/Dialog/RemoveNodeDialog.tsx
  18. 13
      src/components/Dialog/ShutdownDialog.tsx
  19. 13
      src/components/Dialog/TracerouteResponseDialog.tsx
  20. 25
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx
  21. 5
      src/components/KeyBackupReminder.tsx
  22. 156
      src/components/PageComponents/Channel.tsx
  23. 33
      src/components/PageComponents/Config/Bluetooth.tsx
  24. 52
      src/components/PageComponents/Config/Device/index.tsx
  25. 58
      src/components/PageComponents/Config/Display.tsx
  26. 92
      src/components/PageComponents/Config/LoRa.tsx
  27. 71
      src/components/PageComponents/Config/Network/index.tsx
  28. 78
      src/components/PageComponents/Config/Position.tsx
  29. 71
      src/components/PageComponents/Config/Power.tsx
  30. 133
      src/components/PageComponents/Config/Security/Security.tsx
  31. 6
      src/components/PageComponents/Connect/BLE.tsx
  32. 48
      src/components/PageComponents/Connect/HTTP.tsx
  33. 23
      src/components/PageComponents/Connect/Serial.tsx
  34. 76
      src/components/PageComponents/Map/NodeDetail.tsx
  35. 10
      src/components/PageComponents/Messages/ChannelChat.tsx
  36. 10
      src/components/PageComponents/Messages/MessageActionsMenu.tsx
  37. 22
      src/components/PageComponents/Messages/TraceRoute.tsx
  38. 2
      src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  39. 53
      src/components/Sidebar.tsx
  40. 22
      src/components/UI/Input.tsx
  41. 86
      src/components/generic/Filter/FilterControl.tsx
  42. 28
      src/i18n.ts
  43. 576
      src/i18n/locales/en.json
  44. 5
      src/index.tsx
  45. 15
      src/pages/Channels.tsx
  46. 20
      src/pages/Config/DeviceConfig.tsx
  47. 28
      src/pages/Config/ModuleConfig.tsx
  48. 36
      src/pages/Config/index.tsx
  49. 34
      src/pages/Dashboard/index.tsx
  50. 47
      src/pages/Messages.tsx
  51. 77
      src/pages/Nodes.tsx
  52. 45
      vite.config.ts

495
deno.lock

File diff suppressed because it is too large

5
package.json

@ -64,6 +64,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0", "crypto-random-string": "^5.0.0",
"i18next": "^25.2.0",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -73,9 +76,11 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.2", "react-hook-form": "^7.56.2",
"react-i18next": "^15.5.1",
"react-map-gl": "8.0.4", "react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0", "react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4", "rfc4648": "^1.5.4",
"vite-plugin-i18n-ally": "^6.0.1",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.24.3", "zod": "^3.24.3",
"zustand": "5.0.4" "zustand": "5.0.4"

33
src/components/BatteryStatus.tsx

@ -5,6 +5,7 @@ import {
BatteryMediumIcon, BatteryMediumIcon,
PlugZapIcon, PlugZapIcon,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics { interface DeviceMetrics {
@ -23,34 +24,41 @@ interface BatteryStateConfig {
text: (level: number) => string; text: (level: number) => string;
} }
const batteryStates: BatteryStateConfig[] = [ const getBatteryStates = (
t: (key: string, options?: object) => string,
): BatteryStateConfig[] => {
return [
{ {
condition: (level) => level > 100, condition: (level) => level > 100,
Icon: PlugZapIcon, Icon: PlugZapIcon,
className: "text-gray-500", className: "text-gray-500",
text: () => "Plugged in", text: () => t("common.batteryStatus.pluggedIn"),
}, },
{ {
condition: (level) => level > 80, condition: (level) => level > 80,
Icon: BatteryFullIcon, Icon: BatteryFullIcon,
className: "text-green-500", className: "text-green-500",
text: (level) => `${level}% charging`, text: (level) => t("common.batteryStatus.charging", { level }),
}, },
{ {
condition: (level) => level > 20, condition: (level) => level > 20,
Icon: BatteryMediumIcon, Icon: BatteryMediumIcon,
className: "text-yellow-500", className: "text-yellow-500",
text: (level) => `${level}% charging`, text: (level) => t("common.batteryStatus.charging", { level }),
}, },
{ {
condition: () => true, condition: () => true,
Icon: BatteryLowIcon, Icon: BatteryLowIcon,
className: "text-red-500", className: "text-red-500",
text: (level) => `${level}% charging`, text: (level) => t("common.batteryStatus.charging", { level }),
}, },
]; ];
};
const getBatteryState = (level: number) => { const getBatteryState = (
level: number,
batteryStates: BatteryStateConfig[],
) => {
return batteryStates.find((state) => state.condition(level)); return batteryStates.find((state) => state.condition(level));
}; };
@ -62,15 +70,20 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
return null; return null;
} }
const { t } = useTranslation();
const batteryStates = getBatteryStates(t);
const { batteryLevel, voltage } = deviceMetrics; const { batteryLevel, voltage } = deviceMetrics;
const currentState = getBatteryState(batteryLevel) ?? const currentState = getBatteryState(batteryLevel, batteryStates) ??
batteryStates[batteryStates.length - 1]; batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon; const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className; const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel); const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`; const voltageTitle = `${voltage?.toPrecision(3) ?? t("common.unknown")} ${
t("common_unit_volts")
}`;
return ( return (
<div <div
@ -78,7 +91,7 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
title={voltageTitle} title={voltageTitle}
> >
<BatteryIcon size={22} className={iconClassName} /> <BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label="Battery"> <Subtle aria-label={t("common.batteryStatus.title")}>
{statusText} {statusText}
</Subtle> </Subtle>
</div> </div>

58
src/components/CommandPalette/index.tsx

@ -33,6 +33,7 @@ import {
import { useEffect } from "react"; import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { useTranslation } from "react-i18next";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
export interface Group { export interface Group {
@ -65,28 +66,29 @@ export const CommandPalette = () => {
const { pinnedItems, togglePinnedItem } = usePinnedItems({ const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups", storageName: "pinnedCommandMenuGroups",
}); });
const { t } = useTranslation();
const groups: Group[] = [ const groups: Group[] = [
{ {
label: "Goto", label: t("command_palette_goto_label"),
icon: LinkIcon, icon: LinkIcon,
commands: [ commands: [
{ {
label: "Messages", label: t("command_palette_goto_command_messages"),
icon: MessageSquareIcon, icon: MessageSquareIcon,
action() { action() {
setActivePage("messages"); setActivePage("messages");
}, },
}, },
{ {
label: "Map", label: t("command_palette_goto_command_map"),
icon: MapIcon, icon: MapIcon,
action() { action() {
setActivePage("map"); setActivePage("map");
}, },
}, },
{ {
label: "Config", label: t("command_palette_goto_command_config"),
icon: SettingsIcon, icon: SettingsIcon,
action() { action() {
setActivePage("config"); setActivePage("config");
@ -94,14 +96,14 @@ export const CommandPalette = () => {
tags: ["settings"], tags: ["settings"],
}, },
{ {
label: "Channels", label: t("command_palette_goto_command_channels"),
icon: LayersIcon, icon: LayersIcon,
action() { action() {
setActivePage("channels"); setActivePage("channels");
}, },
}, },
{ {
label: "Nodes", label: t("command_palette_goto_command_nodes"),
icon: UsersIcon, icon: UsersIcon,
action() { action() {
setActivePage("nodes"); setActivePage("nodes");
@ -110,19 +112,19 @@ export const CommandPalette = () => {
], ],
}, },
{ {
label: "Manage", label: t("command_palette_manage_label"),
icon: SmartphoneIcon, icon: SmartphoneIcon,
commands: [ commands: [
{ {
label: "Switch Node", label: t("command_palette_manage_command_switch_node"),
icon: ArrowLeftRightIcon, icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({ subItems: getDevices().map((device) => ({
label: getNode(device.hardware.myNodeNum)?.user?.longName ?? label: getNode(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(), t("common.unknown"), // Or a more specific key for node name
icon: ( icon: (
<Avatar <Avatar
text={getNode(device.hardware.myNodeNum)?.user?.shortName ?? text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()} t("common.unknown")} // Or a more specific key
/> />
), ),
action() { action() {
@ -131,7 +133,7 @@ export const CommandPalette = () => {
})), })),
}, },
{ {
label: "Connect New Node", label: t("command_palette_manage_command_connect_new_node"),
icon: PlusIcon, icon: PlusIcon,
action() { action() {
setConnectDialogOpen(true); setConnectDialogOpen(true);
@ -140,22 +142,22 @@ export const CommandPalette = () => {
], ],
}, },
{ {
label: "Contextual", label: t("command_palette_contextual_label"),
icon: BoxSelectIcon, icon: BoxSelectIcon,
commands: [ commands: [
{ {
label: "QR Code", label: t("command_palette_contextual_command_qr_code"),
icon: QrCodeIcon, icon: QrCodeIcon,
subItems: [ subItems: [
{ {
label: "Generator", label: t("command_palette_contextual_command_qr_generator"),
icon: <QrCodeIcon size={16} />, icon: <QrCodeIcon size={16} />,
action() { action() {
setDialogOpen("QR", true); setDialogOpen("QR", true);
}, },
}, },
{ {
label: "Import", label: t("command_palette_contextual_command_qr_import"),
icon: <QrCodeIcon size={16} />, icon: <QrCodeIcon size={16} />,
action() { action() {
setDialogOpen("import", true); setDialogOpen("import", true);
@ -164,42 +166,42 @@ export const CommandPalette = () => {
], ],
}, },
{ {
label: "Schedule Shutdown", label: t("command_palette_contextual_command_schedule_shutdown"),
icon: PowerIcon, icon: PowerIcon,
action() { action() {
setDialogOpen("shutdown", true); setDialogOpen("shutdown", true);
}, },
}, },
{ {
label: "Schedule Reboot", label: t("command_palette_contextual_command_schedule_reboot"),
icon: RefreshCwIcon, icon: RefreshCwIcon,
action() { action() {
setDialogOpen("reboot", true); setDialogOpen("reboot", true);
}, },
}, },
{ {
label: "Reboot To OTA Mode", label: t("command_palette_contextual_command_reboot_to_ota_mode"),
icon: RefreshCwIcon, icon: RefreshCwIcon,
action() { action() {
setDialogOpen("rebootOTA", true); setDialogOpen("rebootOTA", true);
}, },
}, },
{ {
label: "Reset Nodes", label: t("command_palette_contextual_command_reset_nodes"),
icon: TrashIcon, icon: TrashIcon,
action() { action() {
connection?.resetNodes(); connection?.resetNodes();
}, },
}, },
{ {
label: "Factory Reset Device", label: t("command_palette_contextual_command_factory_reset_device"),
icon: FactoryIcon, icon: FactoryIcon,
action() { action() {
connection?.factoryResetDevice(); connection?.factoryResetDevice();
}, },
}, },
{ {
label: "Factory Reset Config", label: t("command_palette_contextual_command_factory_reset_config"),
icon: FactoryIcon, icon: FactoryIcon,
action() { action() {
connection?.factoryResetConfig(); connection?.factoryResetConfig();
@ -208,18 +210,18 @@ export const CommandPalette = () => {
], ],
}, },
{ {
label: "Debug", label: t("command_palette_debug_label"),
icon: BugIcon, icon: BugIcon,
commands: [ commands: [
{ {
label: "Reconfigure", label: t("command_palette_debug_command_reconfigure"),
icon: RefreshCwIcon, icon: RefreshCwIcon,
action() { action() {
void connection?.configure(); void connection?.configure();
}, },
}, },
{ {
label: "Clear All Stored Message", label: t("command_palette_debug_command_clear_all_stored_messages"),
icon: EraserIcon, icon: EraserIcon,
action() { action() {
setDialogOpen("deleteMessages", true); setDialogOpen("deleteMessages", true);
@ -252,9 +254,9 @@ export const CommandPalette = () => {
open={commandPaletteOpen} open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen} onOpenChange={setCommandPaletteOpen}
> >
<CommandInput placeholder="Type a command or search..." /> <CommandInput placeholder={t("command_palette_input_placeholder")} />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>{t("command_palette_empty_message")}</CommandEmpty>
{sortedGroups.map((group) => ( {sortedGroups.map((group) => (
<CommandGroup <CommandGroup
key={group.label} key={group.label}
@ -268,8 +270,8 @@ export const CommandPalette = () => {
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100", "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)} )}
aria-description={pinnedItems.includes(group.label) aria-description={pinnedItems.includes(group.label)
? "Unpin command group" ? t("command_palette_unpin_group_aria_label")
: "Pin command group"} : t("command_palette_pin_group_aria_label")}
> >
<span <span
data-label data-label

13
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -10,6 +10,7 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react"; import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export interface DeleteMessagesDialogProps { export interface DeleteMessagesDialogProps {
open: boolean; open: boolean;
@ -20,6 +21,7 @@ export const DeleteMessagesDialog = ({
open, open,
onOpenChange, onOpenChange,
}: DeleteMessagesDialogProps) => { }: DeleteMessagesDialogProps) => {
const { t } = useTranslation();
const { deleteAllMessages } = useMessageStore(); const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => { const handleCloseDialog = () => {
onOpenChange(false); onOpenChange(false);
@ -32,19 +34,19 @@ export const DeleteMessagesDialog = ({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-warning" /> <AlertTriangleIcon className="h-5 w-5 text-warning" />
Clear All Messages {t("dialog_deleteMessages_title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
This action will clear all message history. This cannot be undone. {t("dialog_deleteMessages_description")}
Are you sure you want to continue?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<Button <Button
variant="outline" variant="outline"
onClick={handleCloseDialog} onClick={handleCloseDialog}
name="dismiss"
> >
Dismiss {t("dialog_button_dismiss")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -52,8 +54,9 @@ export const DeleteMessagesDialog = ({
deleteAllMessages(); deleteAllMessages();
handleCloseDialog(); handleCloseDialog();
}} }}
name="clearMessages"
> >
Clear Messages {t("dialog_button_clearMessages")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

25
src/components/Dialog/DeviceNameDialog.tsx

@ -14,6 +14,7 @@ import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx"; import { GenericInput } from "@components/Form/FormInput.tsx";
import { useTranslation } from "react-i18next";
import { validateMaxByteLength } from "@core/utils/string.ts"; import { validateMaxByteLength } from "@core/utils/string.ts";
export interface User { export interface User {
@ -32,6 +33,7 @@ export const DeviceNameDialog = ({
open, open,
onOpenChange, onOpenChange,
}: DeviceNameDialogProps) => { }: DeviceNameDialogProps) => {
const { t } = useTranslation();
const { hardware, getNode, connection } = useDevice(); const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum); const myNode = getNode(hardware.myNodeNum);
@ -74,14 +76,16 @@ export const DeviceNameDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Change Device Name</DialogTitle> <DialogTitle>{t("dialog_deviceName_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
The Device will restart once the config is saved. {t("dialog_deviceName_description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4"> <form onSubmit={onSubmit} className="flex flex-col gap-4">
<div> <div>
<Label htmlFor="longName">Long Name</Label> <Label htmlFor="longName">
{t("dialog_deviceName_label_longName")}
</Label>
<GenericInput <GenericInput
control={control} control={control}
field={{ field={{
@ -100,7 +104,9 @@ export const DeviceNameDialog = ({
/> />
</div> </div>
<div> <div>
<Label htmlFor="shortName">Short Name</Label> <Label htmlFor="shortName">
{t("dialog_deviceName_label_shortName")}
</Label>
<GenericInput <GenericInput
control={control} control={control}
field={{ field={{
@ -119,10 +125,15 @@ export const DeviceNameDialog = ({
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="destructive" onClick={handleReset}> <Button
Reset type="button"
variant="destructive"
name="reset"
onClick={handleReset}
>
{t("dialog_button_reset")}
</Button> </Button>
<Button type="submit">Save</Button> <Button type="submit" name="save">{t("common_button_save")}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>

22
src/components/Dialog/ImportDialog.tsx

@ -17,6 +17,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js"; import { toByteArray } from "base64-js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface ImportDialogProps { export interface ImportDialogProps {
open: boolean; open: boolean;
@ -28,6 +29,7 @@ export const ImportDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ImportDialogProps) => { }: ImportDialogProps) => {
const { t } = useTranslation();
const [importDialogInput, setImportDialogInput] = useState<string>(""); const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>(); const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false); const [validUrl, setValidUrl] = useState<boolean>(false);
@ -44,7 +46,7 @@ export const ImportDialog = ({
channelsUrl.pathname !== "/e/") || channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash !channelsUrl.hash
) { ) {
throw "Invalid Meshtastic URL"; throw t("dialog_import_error_invalidUrl");
} }
const encodedChannelConfig = channelsUrl.hash.substring(1); const encodedChannelConfig = channelsUrl.hash.substring(1);
@ -99,13 +101,13 @@ export const ImportDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Import Channel Set</DialogTitle> <DialogTitle>{t("dialog_import_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
The current LoRa configuration will be overridden. {t("dialog_import_description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>Channel Set/QR Code URL</Label> <Label>{t("dialog_import_label_channelSetUrl")}</Label>
<Input <Input
value={importDialogInput} value={importDialogInput}
suffix={validUrl ? "✅" : "❌"} suffix={validUrl ? "✅" : "❌"}
@ -117,7 +119,7 @@ export const ImportDialog = ({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex w-full gap-2"> <div className="flex w-full gap-2">
<div className="w-36"> <div className="w-36">
<Label>Use Preset?</Label> <Label>{t("dialog_import_label_usePreset")}</Label>
<Switch <Switch
disabled disabled
checked={channelSet?.loraConfig?.usePreset ?? true} checked={channelSet?.loraConfig?.usePreset ?? true}
@ -144,7 +146,7 @@ export const ImportDialog = ({
} }
<span className="text-md block font-medium text-text-primary"> <span className="text-md block font-medium text-text-primary">
Channels: {t("dialog_import_label_channels")}
</span> </span>
<div className="flex w-40 flex-col gap-1"> <div className="flex w-40 flex-col gap-1">
{channelSet?.settings.map((channel) => ( {channelSet?.settings.map((channel) => (
@ -152,7 +154,9 @@ export const ImportDialog = ({
<Label> <Label>
{channel.name.length {channel.name.length
? channel.name ? channel.name
: `Channel: ${channel.id}`} : `${
t("dialog_import_label_channelPrefix")
}${channel.id}`}
</Label> </Label>
<Checkbox key={channel.id} /> <Checkbox key={channel.id} />
</div> </div>
@ -162,8 +166,8 @@ export const ImportDialog = ({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={apply} disabled={!validUrl}> <Button onClick={apply} disabled={!validUrl} name="apply">
Apply {t("dialog_button_apply")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

21
src/components/Dialog/LocationResponseDialog.tsx

@ -9,6 +9,7 @@ import {
} from "../UI/Dialog.tsx"; } from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core"; import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps { export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined; location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
@ -21,26 +22,32 @@ export const LocationResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: LocationResponseDialogProps) => { }: LocationResponseDialogProps) => {
const { t } = useTranslation();
const { getNode } = useDevice(); const { getNode } = useDevice();
const from = getNode(location?.from ?? 0); const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : t("common.unknown"));
const shortName = from?.user?.shortName ?? const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); (from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("common.unknown"));
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle> <DialogTitle>
{t("dialog_locationResponse_titlePrefix")}
{longName} ({shortName})
</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
<div className="ml-5 flex"> <div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> <span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p> <p>
Coordinates:{" "} {t("dialog_locationResponse_label_coordinates")}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${ href={`https://www.openstreetmap.org/?mlat=${
@ -53,7 +60,11 @@ export const LocationResponseDialog = ({
{location?.data.longitudeI / 1e7} {location?.data.longitudeI / 1e7}
</a> </a>
</p> </p>
<p>Altitude: {location?.data.altitude}m</p> <p>
{t("dialog_locationResponse_label_altitude")}
{location?.data.altitude}
{t("dialog_locationResponse_unit_meter")}
</p>
</span> </span>
</div> </div>
</DialogDescription> </DialogDescription>

67
src/components/Dialog/NewDeviceDialog.tsx

@ -1,7 +1,7 @@
import { import {
type BrowserFeature, type BrowserFeature,
useBrowserFeatureDetection, useBrowserFeatureDetection,
} from "../../core/hooks/useBrowserFeatureDetection.ts"; } from "@core/hooks/useBrowserFeatureDetection.ts";
import { BLE } from "@components/PageComponents/Connect/BLE.tsx"; import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx"; import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx"; import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
@ -20,8 +20,9 @@ import {
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx"; import { Link } from "../UI/Typography/Link.tsx";
import { Fragment } from "react/jsx-runtime";
export interface TabElementProps { export interface TabElementProps {
closeDialog: () => void; closeDialog: () => void;
@ -51,12 +52,18 @@ const links: { [key: string]: string } = {
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
}; };
const listFormatter = new Intl.ListFormat("en", { const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
const { t, i18n } = useTranslation();
const listFormatter = useMemo(
() =>
new Intl.ListFormat(i18n.language, {
style: "long", style: "long",
type: "disjunction", type: "disjunction",
}); }),
[i18n.language],
);
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null; if (missingFeatures.length === 0) return null;
const browserFeatures = missingFeatures.filter( const browserFeatures = missingFeatures.filter(
@ -74,10 +81,12 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
</Link> </Link>
); );
} }
return <Fragment key={part.value}>{part.value}</Fragment>; return <span key={part.value}>{part.value}</span>;
}); });
}; };
const featureNodes = formatFeatureList(browserFeatures);
return ( return (
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md"> <Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
@ -85,20 +94,28 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm text-white"> <p className="text-sm text-white">
{browserFeatures.length > 0 && ( {browserFeatures.length > 0 && (
<> <Trans
This connection type requires{" "} i18nKey="dialog_newDeviceDialog_errorMessage_requiresFeatures"
{formatFeatureList(browserFeatures)}. Please use a supported components={{
browser, like Chrome or Edge. "0": <>{featureNodes}</>,
</> }}
/>
)} )}
{browserFeatures.length > 0 && needsSecureContext && " "}
{needsSecureContext && ( {needsSecureContext && (
<> <Trans
{browserFeatures.length > 0 && " Additionally, it"} i18nKey={browserFeatures.length > 0
{browserFeatures.length === 0 && "This application"} requires a ? "dialog_newDeviceDialog_errorMessage_additionallyRequiresSecureContext"
{" "} : "dialog_newDeviceDialog_errorMessage_requiresSecureContext"}
<Link href={links["Secure Context"]}>secure context</Link>. components={{
Please connect using HTTPS or localhost. "0": (
</> <Link
href={links["Secure Context"]}
className="underline hover:text-slate-200"
/>
),
}}
/>
)} )}
</p> </p>
</div> </div>
@ -111,22 +128,23 @@ export const NewDeviceDialog = ({
open, open,
onOpenChange, onOpenChange,
}: NewDeviceProps) => { }: NewDeviceProps) => {
const { t } = useTranslation();
const { unsupported } = useBrowserFeatureDetection(); const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [ const tabs: TabManifest[] = [
{ {
label: "HTTP", label: t("dialog_newDeviceDialog_tabLabelHttp"),
element: HTTP, element: HTTP,
isDisabled: false, isDisabled: false,
}, },
{ {
label: "Bluetooth", label: t("dialog_newDeviceDialog_tabLabelBluetooth"),
element: BLE, element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") || isDisabled: unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"), unsupported.includes("Secure Context"),
}, },
{ {
label: "Serial", label: t("dialog_newDeviceDialog_tabLabelSerial"),
element: Serial, element: Serial,
isDisabled: unsupported.includes("Web Serial") || isDisabled: unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"), unsupported.includes("Secure Context"),
@ -138,9 +156,9 @@ export const NewDeviceDialog = ({
<DialogContent aria-describedby={undefined}> <DialogContent aria-describedby={undefined}>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Connect New Device</DialogTitle> <DialogTitle>{t("dialog_newDeviceDialog_title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="HTTP"> <Tabs defaultValue={t("dialog_newDeviceDialog_tabLabelHttp")}>
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger key={tab.label} value={tab.label}> <TabsTrigger key={tab.label} value={tab.label}>
@ -151,7 +169,8 @@ export const NewDeviceDialog = ({
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}> <TabsContent key={tab.label} value={tab.label}>
<fieldset disabled={tab.isDisabled}> <fieldset disabled={tab.isDisabled}>
{(tab.label !== "HTTP" && tab.isDisabled) {(tab.label !== t("dialog_newDeviceDialog_tabLabelHttp") &&
tab.isDisabled)
? <ErrorMessage missingFeatures={unsupported} /> ? <ErrorMessage missingFeatures={unsupported} />
: null} : null}
<tab.element <tab.element

96
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -48,6 +48,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@components/UI/Tooltip.tsx"; } from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx"; import { Separator } from "@components/UI/Seperator.tsx";
import { useTranslation } from "react-i18next";
export interface NodeDetailsDialogProps { export interface NodeDetailsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined; node: Protobuf.Mesh.NodeInfo | undefined;
@ -60,6 +61,7 @@ export const NodeDetailsDialog = ({
open, open,
onOpenChange, onOpenChange,
}: NodeDetailsDialogProps) => { }: NodeDetailsDialogProps) => {
const { t } = useTranslation();
const { setDialogOpen, connection, setActivePage } = useDevice(); const { setDialogOpen, connection, setActivePage } = useDevice();
const { setNodeNumToBeRemoved } = useAppStore(); const { setNodeNumToBeRemoved } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore(); const { setChatType, setActiveChat } = useMessageStore();
@ -90,11 +92,11 @@ export const NodeDetailsDialog = ({
if (!node) return; if (!node) return;
toast({ toast({
title: "Requesting position, please wait...", title: t("toast_requestingPosition"),
}); });
connection?.requestPosition(node.num).then(() => connection?.requestPosition(node.num).then(() =>
toast({ toast({
title: "Position request sent.", title: t("toast_positionRequestSent"),
}) })
); );
onOpenChange(false); onOpenChange(false);
@ -104,11 +106,11 @@ export const NodeDetailsDialog = ({
if (!node) return; if (!node) return;
toast({ toast({
title: "Sending Traceroute, please wait...", title: t("toast_sendingTraceroute"),
}); });
connection?.traceRoute(node.num).then(() => connection?.traceRoute(node.num).then(() =>
toast({ toast({
title: "Traceroute sent.", title: t("toast_tracerouteSent"),
}) })
); );
onOpenChange(false); onOpenChange(false);
@ -139,25 +141,25 @@ export const NodeDetailsDialog = ({
const deviceMetricsMap = [ const deviceMetricsMap = [
{ {
key: "airUtilTx", key: "airUtilTx",
label: "Air TX utilization", label: t("dialog_nodeDetails_label_airTxUtilization"),
value: node.deviceMetrics?.airUtilTx, value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "channelUtilization", key: "channelUtilization",
label: "Channel utilization", label: t("dialog_nodeDetails_label_channelUtilization"),
value: node.deviceMetrics?.channelUtilization, value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "batteryLevel", key: "batteryLevel",
label: "Battery level", label: t("dialog_nodeDetails_label_batteryLevel"),
value: node.deviceMetrics?.batteryLevel, value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "voltage", key: "voltage",
label: "Voltage", label: t("dialog_nodeDetails_label_voltage"),
value: node.deviceMetrics?.voltage, value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`, format: (val: number) => `${val.toFixed(2)}V`,
}, },
@ -169,20 +171,29 @@ export const NodeDetailsDialog = ({
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Node Details for {node.user?.longName ?? "UNKNOWN"} ( {t("dialog_nodeDetails_titlePrefix")}
{node.user?.shortName ?? "UNK"}) {node.user?.longName ?? t("common.unknown")} (
{node.user?.shortName ?? t("common.unknown")})
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<div className="w-full"> <div className="w-full">
<div className="flex flex-row flex-wrap space-y-1"> <div className="flex flex-row flex-wrap space-y-1">
<Button className="mr-1" onClick={handleDirectMessage}> <Button
className="mr-1"
name="message"
onClick={handleDirectMessage}
>
<MessageSquareIcon className="mr-2" /> <MessageSquareIcon className="mr-2" />
Message {t("dialog_nodeDetails_button_message")}
</Button> </Button>
<Button className="mr-1" onClick={handleTraceroute}> <Button
className="mr-1"
name="traceRoute"
onClick={handleTraceroute}
>
<WaypointsIcon className="mr-2" /> <WaypointsIcon className="mr-2" />
Trace Route {t("dialog_nodeDetails_button_traceRoute")}
</Button> </Button>
<Button className="mr-1" onClick={handleToggleFavorite}> <Button className="mr-1" onClick={handleToggleFavorite}>
<StarIcon <StarIcon
@ -209,7 +220,9 @@ export const NodeDetailsDialog = ({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{isIgnoredState ? "Unignore node" : "Ignore node"} {isIgnoredState
? t("dialog_nodeDetails_tooltip_unignoreNode")
: t("dialog_nodeDetails_tooltip_ignoreNode")}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> <TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -227,7 +240,7 @@ export const NodeDetailsDialog = ({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Remove node {t("dialog_nodeDetails_tooltip_removeNode")}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> <TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -239,23 +252,30 @@ export const NodeDetailsDialog = ({
<div className="flex flex-col flex-wrap space-x-1 space-y-1"> <div className="flex flex-col flex-wrap space-x-1 space-y-1">
<div className="flex flex-row space-x-2"> <div className="flex flex-row space-x-2">
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg"> <div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
<p className="text-lg font-semibold">Details:</p> <p className="text-lg font-semibold">
<p>Node Number: {node.num}</p> {t("dialog_nodeDetails_label_details")}
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p> </p>
<p>{t("dialog_nodeDetails_label_nodeNumber")}{node.num}</p>
<p>
{t("dialog_nodeDetails_label_nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
</p>
<p> <p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[ {t("dialog_nodeDetails_label_role")}
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0 node.user?.role ?? 0
].replace(/_/g, " ")} ].replace(/_/g, " ")}
</p> </p>
<p> <p>
Last Heard: {node.lastHeard === 0 {t("dialog_nodeDetails_label_lastHeard")}
? "Never" {node.lastHeard === 0
? t("nodes_table_lastHeardStatus_never")
: <TimeAgo timestamp={node.lastHeard * 1000} />} : <TimeAgo timestamp={node.lastHeard * 1000} />}
</p> </p>
<p> <p>
Hardware:{" "} {t("dialog_nodeDetails_label_hardware")}
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? {(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
"Unknown") t("common.unknown"))
.replace(/_/g, " ")} .replace(/_/g, " ")}
</p> </p>
</div> </div>
@ -269,7 +289,9 @@ export const NodeDetailsDialog = ({
<div> <div>
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Position:</p> <p className="text-lg font-semibold">
{t("dialog_nodeDetails_label_position")}
</p>
{node.position {node.position
? ( ? (
@ -277,7 +299,7 @@ export const NodeDetailsDialog = ({
{node.position.latitudeI && {node.position.latitudeI &&
node.position.longitudeI && ( node.position.longitudeI && (
<p> <p>
Coordinates:{" "} {t("dialog_locationResponse_label_coordinates")}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${ href={`https://www.openstreetmap.org/?mlat=${
@ -292,21 +314,29 @@ export const NodeDetailsDialog = ({
</p> </p>
)} )}
{node.position.altitude && ( {node.position.altitude && (
<p>Altitude: {node.position.altitude}m</p> <p>
{t("dialog_locationResponse_label_altitude")}
{node.position.altitude}
{t("dialog_locationResponse_unit_meter")}
</p>
)} )}
</> </>
) )
: <p>Unknown</p>} : <p>{t("common.unknown")}</p>}
<Button onClick={handleRequestPosition} className="mt-2"> <Button
onClick={handleRequestPosition}
name="requestPosition"
className="mt-2"
>
<MapPinnedIcon className="mr-2" /> <MapPinnedIcon className="mr-2" />
Request Position {t("dialog_nodeDetails_button_requestPosition")}
</Button> </Button>
</div> </div>
{node.deviceMetrics && ( {node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100"> <p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Device Metrics: {t("dialog_nodeDetails_label_deviceMetrics")}
</p> </p>
{deviceMetricsMap.map( {deviceMetricsMap.map(
(metric) => (metric) =>
@ -318,7 +348,7 @@ export const NodeDetailsDialog = ({
)} )}
{node.deviceMetrics.uptimeSeconds && ( {node.deviceMetrics.uptimeSeconds && (
<p> <p>
Uptime:{" "} {t("dialog_nodeDetails_label_uptime")}
<Uptime seconds={node.deviceMetrics.uptimeSeconds} /> <Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p> </p>
)} )}
@ -331,7 +361,7 @@ export const NodeDetailsDialog = ({
<AccordionItem className="AccordionItem" value="item-1"> <AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger> <AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> <p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics: {t("dialog_nodeDetails_label_allRawMetrics")}
</p> </p>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="overflow-x-scroll"> <AccordionContent className="overflow-x-scroll">

36
src/components/Dialog/PKIBackupDialog.tsx

@ -12,6 +12,7 @@ import {
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react"; import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
export interface PkiBackupDialogProps { export interface PkiBackupDialogProps {
open: boolean; open: boolean;
@ -22,6 +23,7 @@ export const PkiBackupDialog = ({
open, open,
onOpenChange, onOpenChange,
}: PkiBackupDialogProps) => { }: PkiBackupDialogProps) => {
const { t } = useTranslation();
const { config, setDialogOpen } = useDevice(); const { config, setDialogOpen } = useDevice();
const privateKey = config.security?.privateKey; const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey; const publicKey = config.security?.publicKey;
@ -46,7 +48,7 @@ export const PkiBackupDialog = ({
printWindow.document.write(` printWindow.document.write(`
<html> <html>
<head> <head>
<title>=== MESHTASTIC KEYS ===</title> <title>${t("dialog_pkiBackup_print_header")}</title>
<style> <style>
body { font-family: Arial, sans-serif; padding: 20px; } body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; } h1 { font-size: 18px; }
@ -54,14 +56,14 @@ export const PkiBackupDialog = ({
</style> </style>
</head> </head>
<body> <body>
<h1>=== MESHTASTIC KEYS ===</h1> <h1>${t("dialog_pkiBackup_print_header")}</h1>
<br> <br>
<h2>Public Key:</h2> <h2>${t("dialog_pkiBackup_print_label_publicKey")}</h2>
<p>${decodeKeyData(publicKey)}</p> <p>${decodeKeyData(publicKey)}</p>
<h2>Private Key:</h2> <h2>${t("dialog_pkiBackup_print_label_privateKey")}</h2>
<p>${decodeKeyData(privateKey)}</p> <p>${decodeKeyData(privateKey)}</p>
<br> <br>
<p>=== END OF KEYS ===</p> <p>${t("dialog_pkiBackup_print_footer")}</p>
</body> </body>
</html> </html>
`); `);
@ -69,7 +71,7 @@ export const PkiBackupDialog = ({
printWindow.print(); printWindow.print();
closeDialog(); closeDialog();
} }
}, [decodeKeyData, privateKey, publicKey, closeDialog]); }, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
const createDownloadKeyFile = React.useCallback(() => { const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return; if (!privateKey || !publicKey) return;
@ -78,12 +80,12 @@ export const PkiBackupDialog = ({
const decodedPublicKey = decodeKeyData(publicKey); const decodedPublicKey = decodeKeyData(publicKey);
const formattedContent = [ const formattedContent = [
"=== MESHTASTIC KEYS ===\n\n", `${t("dialog_pkiBackup_print_header")}\n\n`,
"Private Key:\n", `${t("dialog_pkiBackup_print_label_privateKey")}\n`,
decodedPrivateKey, decodedPrivateKey,
"\n\nPublic Key:\n", `\n\n${t("dialog_pkiBackup_print_label_publicKey")}\n`,
decodedPublicKey, decodedPublicKey,
"\n\n=== END OF KEYS ===", `\n\n${t("dialog_pkiBackup_print_footer")}`,
].join(""); ].join("");
const blob = new Blob([formattedContent], { type: "text/plain" }); const blob = new Blob([formattedContent], { type: "text/plain" });
@ -98,36 +100,36 @@ export const PkiBackupDialog = ({
document.body.removeChild(link); document.body.removeChild(link);
closeDialog(); closeDialog();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog]); }, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Backup Keys</DialogTitle> <DialogTitle>{t("dialog_pkiBackup_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Its important to backup your public and private keys and store your {t("dialog_pkiBackup_description_secureBackup")}
backup securely!
</DialogDescription> </DialogDescription>
<DialogDescription> <DialogDescription>
<span className="font-bold break-before-auto"> <span className="font-bold break-before-auto">
If you lose your keys, you will need to reset your device. {t("dialog_pkiBackup_description_loseKeysWarning")}
</span> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
<Button <Button
variant="default" variant="default"
name="download"
onClick={() => createDownloadKeyFile()} onClick={() => createDownloadKeyFile()}
className="" className=""
> >
<DownloadIcon size={20} className="mr-2" /> <DownloadIcon size={20} className="mr-2" />
Download {t("dialog_button_download")}
</Button> </Button>
<Button variant="default" onClick={() => renderPrintWindow()}> <Button variant="default" onClick={() => renderPrintWindow()}>
<PrinterIcon size={20} className="mr-2" /> <PrinterIcon size={20} className="mr-2" />
Print {t("dialog_button_print")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

26
src/components/Dialog/PkiRegenerateDialog.tsx

@ -8,6 +8,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { useTranslation } from "react-i18next";
export interface PkiRegenerateDialogProps { export interface PkiRegenerateDialogProps {
text: { text: {
@ -22,27 +23,38 @@ export interface PkiRegenerateDialogProps {
export const PkiRegenerateDialog = ({ export const PkiRegenerateDialog = ({
text = { text = {
title: "Regenerate Key Pair", title: "", // Default will be set by useTranslation
description: "Are you sure you want to regenerate key pair?", description: "", // Default will be set by useTranslation
button: "Regenerate", button: "", // Default will be set by useTranslation
}, },
open, open,
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: PkiRegenerateDialogProps) => { }: PkiRegenerateDialogProps) => {
const { t } = useTranslation();
const dialogText = {
title: text.title || t("dialog_pkiRegenerate_title_keyPair"),
description: text.description ||
t("dialog_pkiRegenerate_description_keyPair"),
button: text.button || t("dialog_pkiRegenerateDialog_buttonRegenerate"),
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>{text?.title}</DialogTitle> <DialogTitle>{dialogText.title}</DialogTitle>
<DialogDescription> <DialogDescription>
{text?.description} {dialogText.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}> <Button
{text?.button} variant="destructive"
name="regenerate"
onClick={() => onSubmit()}
>
{dialogText.button}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

20
src/components/Dialog/QRDialog.tsx

@ -16,6 +16,7 @@ import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react"; import { ClipboardIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
export interface QRDialogProps { export interface QRDialogProps {
open: boolean; open: boolean;
@ -30,6 +31,7 @@ export const QRDialog = ({
loraConfig, loraConfig,
channels, channels,
}: QRDialogProps) => { }: QRDialogProps) => {
const { t } = useTranslation();
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]); const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>(); const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
@ -65,9 +67,9 @@ export const QRDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Generate QR Code</DialogTitle> <DialogTitle>{t("dialog_qr_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
The current LoRa configuration will also be shared. {t("dialog_qr_description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
@ -79,8 +81,10 @@ export const QRDialog = ({
{channel.settings?.name.length {channel.settings?.name.length
? channel.settings.name ? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY : channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? "Primary" ? t("channel_name_primary")
: `Channel: ${channel.index}`} : `${
t("dialog_import_label_channelPrefix")
}${channel.index}`}
</Label> </Label>
<Checkbox <Checkbox
key={channel.index} key={channel.index}
@ -113,9 +117,10 @@ export const QRDialog = ({
? "focus:ring-green-800 bg-green-800 text-white" ? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`} }`}
name="addChannels"
onClick={() => setQrCodeAdd(true)} onClick={() => setQrCodeAdd(true)}
> >
Add Channels {t("dialog_qr_button_addChannels")}
</button> </button>
<button <button
type="button" type="button"
@ -124,14 +129,15 @@ export const QRDialog = ({
? "focus:ring-green-800 bg-green-800 text-white" ? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`} }`}
name="replaceChannels"
onClick={() => setQrCodeAdd(false)} onClick={() => setQrCodeAdd(false)}
> >
Replace Channels {t("dialog_qr_button_replaceChannels")}
</button> </button>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Label>Sharable URL</Label> <Label>{t("dialog_qr_label_sharableUrl")}</Label>
<Input <Input
value={qrCodeUrl} value={qrCodeUrl}
disabled disabled

11
src/components/Dialog/RebootDialog.tsx

@ -10,6 +10,7 @@ import {
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react"; import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
export interface RebootDialogProps { export interface RebootDialogProps {
@ -21,6 +22,7 @@ export const RebootDialog = ({
open, open,
onOpenChange, onOpenChange,
}: RebootDialogProps) => { }: RebootDialogProps) => {
const { t } = useTranslation();
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(5); const [time, setTime] = useState<number>(5);
@ -30,9 +32,11 @@ export const RebootDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Reboot</DialogTitle> <DialogTitle>
{t("command_palette_contextual_command_schedule_reboot")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Reboot the connected node after x minutes. {t("dialog_reboot_description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex gap-2 p-4"> <div className="flex gap-2 p-4">
@ -50,12 +54,13 @@ export const RebootDialog = ({
/> />
<Button <Button
className="w-24" className="w-24"
name="now"
onClick={() => { onClick={() => {
connection?.reboot(2).then(() => onOpenChange(false)); connection?.reboot(2).then(() => onOpenChange(false));
}} }}
> >
<RefreshCwIcon className="mr-2" size={16} /> <RefreshCwIcon className="mr-2" size={16} />
Now {t("dialog_button_now")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

29
src/components/Dialog/RebootOTADialog.tsx

@ -11,6 +11,7 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps { export interface RebootOTADialogProps {
open: boolean; open: boolean;
@ -22,6 +23,7 @@ const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ( export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps, { open, onOpenChange }: RebootOTADialogProps,
) => { ) => {
const { t } = useTranslation();
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false); const [isScheduled, setIsScheduled] = useState(false);
@ -73,10 +75,11 @@ export const RebootOTADialog = (
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle> <DialogTitle>
{t("command_palette_contextual_command_reboot_to_ota_mode")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) {t("dialog_rebootOta_description")}
mode.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -88,17 +91,27 @@ export const RebootOTADialog = (
className="dark:text-slate-900 appearance-none" className="dark:text-slate-900 appearance-none"
value={inputValue} value={inputValue}
onChange={handleSetTime} onChange={handleSetTime}
placeholder="Enter delay (sec)" placeholder={t("dialog_rebootOta_placeholder_enterDelay")}
/> />
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12"> <Button
onClick={() => handleRebootWithTimeout()}
name="scheduleReboot"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} /> <ClockIcon className="mr-2" size={18} />
{isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"} {isScheduled
? t("dialog_rebootOta_status_scheduled")
: t("command_palette_contextual_command_schedule_reboot")}
</Button> </Button>
</div> </div>
<Button variant="destructive" onClick={() => handleInstantReboot()}> <Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} /> <RefreshCwIcon className="mr-2" size={16} />
Reboot to OTA Mode Now {t("dialog_button_rebootOtaNow")}
</Button> </Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

27
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -9,6 +9,7 @@ import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react"; import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps { export interface RefreshKeysDialogProps {
@ -19,6 +20,7 @@ export interface RefreshKeysDialogProps {
export const RefreshKeysDialog = ( export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps, { open, onOpenChange }: RefreshKeysDialogProps,
) => { ) => {
const { t } = useTranslation();
const { activeChat } = useMessageStore(); const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice(); const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
@ -32,13 +34,16 @@ export const RefreshKeysDialog = (
const nodeWithError = getNode(nodeErrorNum.node); const nodeWithError = getNode(nodeErrorNum.node);
const text = { const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, title: `${t("dialog_refreshKeys_titlePrefix")}${
description: `Your node is unable to send a direct message to node: ${
nodeWithError?.user?.longName ?? "" nodeWithError?.user?.longName ?? ""
} (${ }`,
nodeWithError?.user?.shortName ?? "" description: `${t("dialog_refreshKeys_description_unableToSendDmPrefix")}${
}). This is due to the remote node's current public key does not match the previously stored key for this node.`, nodeWithError?.user?.longName ?? ""
} (${nodeWithError?.user?.shortName ?? ""})${
t("dialog_refreshKeys_description_keyMismatchReasonSuffix")
}`,
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
@ -60,22 +65,26 @@ export const RefreshKeysDialog = (
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<p className="font-bold mb-0.5">Accept New Keys</p> <p className="font-bold mb-0.5">
{t("dialog_refreshKeys_label_acceptNewKeys")}
</p>
<p> <p>
This will remove the node from device and request new keys. {t("dialog_refreshKeys_description_acceptNewKeys")}
</p> </p>
</div> </div>
<Button <Button
variant="default" variant="default"
name="requestNewKeys"
onClick={handleNodeRemove} onClick={handleNodeRemove}
> >
Request New Keys {t("dialog_button_requestNewKeys")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
name="dismiss"
onClick={handleCloseDialog} onClick={handleCloseDialog}
> >
Dismiss {t("dialog_button_dismiss")}
</Button> </Button>
</div> </div>
</li> </li>

14
src/components/Dialog/RemoveNodeDialog.tsx

@ -11,6 +11,7 @@ import {
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx"; import { Label } from "@components/UI/Label.tsx";
import { useTranslation } from "react-i18next";
export interface RemoveNodeDialogProps { export interface RemoveNodeDialogProps {
open: boolean; open: boolean;
@ -21,6 +22,7 @@ export const RemoveNodeDialog = ({
open, open,
onOpenChange, onOpenChange,
}: RemoveNodeDialogProps) => { }: RemoveNodeDialogProps) => {
const { t } = useTranslation();
const { connection, getNode, removeNode } = useDevice(); const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore(); const { nodeNumToBeRemoved } = useAppStore();
@ -35,9 +37,9 @@ export const RemoveNodeDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Remove Node?</DialogTitle> <DialogTitle>{t("dialog_removeNode_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to remove this Node? {t("dialog_removeNode_description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="gap-4"> <div className="gap-4">
@ -46,8 +48,12 @@ export const RemoveNodeDialog = ({
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}> <Button
Remove variant="destructive"
name="remove"
onClick={() => onSubmit()}
>
{t("dialog_button_remove")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

13
src/components/Dialog/ShutdownDialog.tsx

@ -10,6 +10,7 @@ import {
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, PowerIcon } from "lucide-react"; import { ClockIcon, PowerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
export interface ShutdownDialogProps { export interface ShutdownDialogProps {
@ -21,6 +22,7 @@ export const ShutdownDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ShutdownDialogProps) => { }: ShutdownDialogProps) => {
const { t } = useTranslation();
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(5); const [time, setTime] = useState<number>(5);
@ -30,9 +32,11 @@ export const ShutdownDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Shutdown</DialogTitle> <DialogTitle>
{t("command_palette_contextual_command_schedule_shutdown")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Turn off the connected node after x minutes. {t("dialog_shutdown_description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -41,7 +45,7 @@ export const ShutdownDialog = ({
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
suffix="Minutes" suffix={t("dialog_shutdown_suffix_minutes")}
/> />
<Button <Button
className="w-24" className="w-24"
@ -53,12 +57,13 @@ export const ShutdownDialog = ({
</Button> </Button>
<Button <Button
className="w-24" className="w-24"
name="now"
onClick={() => { onClick={() => {
connection?.shutdown(2).then(() => () => onOpenChange(false)); connection?.shutdown(2).then(() => () => onOpenChange(false));
}} }}
> >
<PowerIcon className="mr-2" size={16} /> <PowerIcon className="mr-2" size={16} />
Now {t("dialog_button_now")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

13
src/components/Dialog/TracerouteResponseDialog.tsx

@ -11,6 +11,7 @@ import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx"; import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import { useTranslation } from "react-i18next";
export interface TracerouteResponseDialogProps { export interface TracerouteResponseDialogProps {
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined; traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
@ -23,6 +24,7 @@ export const TracerouteResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: TracerouteResponseDialogProps) => { }: TracerouteResponseDialogProps) => {
const { t } = useTranslation();
const { getNode } = useDevice(); const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? []; const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? [];
@ -30,16 +32,21 @@ export const TracerouteResponseDialog = ({
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4); const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0); const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : t("common.unknown"));
const shortName = from?.user?.shortName ?? const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); (from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("common.unknown"));
const to = getNode(traceroute?.to ?? 0); const to = getNode(traceroute?.to ?? 0);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle> <DialogTitle>
{t("dialog_tracerouteResponse_titlePrefix")}
{longName} ({shortName})
</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
<TraceRoute <TraceRoute

25
src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx

@ -13,6 +13,7 @@ import { Button } from "@components/UI/Button.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useState } from "react"; import { useState } from "react";
import { eventBus } from "@core/utils/eventBus.ts"; import { eventBus } from "@core/utils/eventBus.ts";
import { useTranslation } from "react-i18next";
export interface RouterRoleDialogProps { export interface RouterRoleDialogProps {
open: boolean; open: boolean;
@ -22,6 +23,7 @@ export interface RouterRoleDialogProps {
export const UnsafeRolesDialog = ( export const UnsafeRolesDialog = (
{ open, onOpenChange }: RouterRoleDialogProps, { open, onOpenChange }: RouterRoleDialogProps,
) => { ) => {
const { t } = useTranslation();
const [confirmState, setConfirmState] = useState(false); const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice(); const { setDialogOpen } = useDevice();
@ -41,26 +43,27 @@ export const UnsafeRolesDialog = (
<DialogContent className="max-w-8 flex flex-col"> <DialogContent className="max-w-8 flex flex-col">
<DialogClose onClick={() => handleCloseDialog("dismiss")} /> <DialogClose onClick={() => handleCloseDialog("dismiss")} />
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>{t("dialog_unsafeRoles_title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="text-md"> <DialogDescription className="text-md">
I have read the{" "} {t("dialog_unsafeRoles_description_preamble")}
<Link href={deviceRoleLink} className=""> <Link href={deviceRoleLink} className="">
Device Role Documentation {t("dialog_unsafeRoles_link_deviceRoleDocumentation")}
</Link>{" "} </Link>
and the blog post about{" "} {t("dialog_unsafeRoles_description_conjunction")}
<Link href={choosingTheRightDeviceRoleLink}> <Link href={choosingTheRightDeviceRoleLink}>
Choosing The Right Device Role {t("dialog_unsafeRoles_link_choosingRightDeviceRole")}
</Link>{" "} </Link>
and understand the implications of changing the role. {t("dialog_unsafeRoles_description_postamble")}
</DialogDescription> </DialogDescription>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="routerRole" id="routerRole"
checked={confirmState} checked={confirmState}
onChange={() => setConfirmState(!confirmState)} onChange={() => setConfirmState(!confirmState)}
name="confirmUnderstanding"
> >
Yes, I know what I'm doing {t("dialog_unsafeRoles_checkbox_confirmUnderstanding")}
</Checkbox> </Checkbox>
</div> </div>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
@ -69,7 +72,7 @@ export const UnsafeRolesDialog = (
name="dismiss" name="dismiss"
onClick={() => handleCloseDialog("dismiss")} onClick={() => handleCloseDialog("dismiss")}
> >
Dismiss {t("dialog_button_dismiss")}
</Button> </Button>
<Button <Button
variant="default" variant="default"
@ -77,7 +80,7 @@ export const UnsafeRolesDialog = (
disabled={!confirmState} disabled={!confirmState}
onClick={() => handleCloseDialog("confirm")} onClick={() => handleCloseDialog("confirm")}
> >
Confirm {t("dialog_button_confirm")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

5
src/components/KeyBackupReminder.tsx

@ -1,12 +1,13 @@
import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx"; import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
export const KeyBackupReminder = () => { export const KeyBackupReminder = () => {
const { setDialogOpen } = useDevice(); const { setDialogOpen } = useDevice();
const { t } = useTranslation();
useBackupReminder({ useBackupReminder({
message: message: t("dialog_pkiBackup_message"),
"We recommend backing up your key data regularly. Would you like to back up now?",
onAccept: () => setDialogOpen("pkiBackup", true), onAccept: () => setDialogOpen("pkiBackup", true),
enabled: true, enabled: true,
}); });

156
src/components/PageComponents/Channel.tsx

@ -7,6 +7,7 @@ import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string"; import cryptoRandomString from "crypto-random-string";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx"; import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx";
export interface SettingsPanelProps { export interface SettingsPanelProps {
@ -15,6 +16,7 @@ export interface SettingsPanelProps {
export const Channel = ({ channel }: SettingsPanelProps) => { export const Channel = ({ channel }: SettingsPanelProps) => {
const { config, connection, addChannel } = useDevice(); const { config, connection, addChannel } = useDevice();
const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const [pass, setPass] = useState<string>( const [pass, setPass] = useState<string>(
@ -42,7 +44,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
}); });
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
toast({ toast({
title: `Saved Channel: ${channel.settings?.name}`, title: t("channel_form_toast_saved", {
channelName: channel.settings?.name,
}),
}); });
addChannel(channel); addChannel(channel);
}); });
@ -67,7 +71,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
const validatePass = (input: string, count: number) => { const validatePass = (input: string, count: number) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) { if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`); setValidationText(
t("channel_form_validation_psk_invalid", { bits: count * 8 }),
);
} else { } else {
setValidationText(undefined); setValidationText(undefined);
} }
@ -110,36 +116,37 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
}} }}
fieldGroups={[ fieldGroups={[
{ {
label: "Channel Settings", label: t("channel_form_field_group_settings_label"),
description: "Crypto, MQTT & misc settings", description: t("channel_form_field_group_settings_description"),
fields: [ fields: [
{ {
type: "select", type: "select",
name: "role", name: "role",
label: "Role", label: t("channel_form_field_role_label"),
disabled: channel.index === 0, disabled: channel.index === 0,
description: description: t("channel_form_field_role_description"),
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
properties: { properties: {
enumValue: channel.index === 0 enumValue: channel.index === 0
? { PRIMARY: 1 } ? { [t("channel_form_field_role_option_primary")]: 1 }
: { DISABLED: 0, SECONDARY: 2 }, : {
[t("channel_form_field_role_option_disabled")]: 0,
[t("channel_form_field_role_option_secondary")]: 2,
},
}, },
}, },
{ {
type: "passwordGenerator", type: "passwordGenerator",
name: "settings.psk", name: "settings.psk",
id: "channel-psk", id: "channel-psk",
label: "Pre-Shared Key", label: t("channel_form_field_psk_label"),
description: description: t("channel_form_field_psk_description"),
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
validationText: validationText, validationText: validationText,
devicePSKBitCount: bitCount ?? 0, devicePSKBitCount: bitCount ?? 0,
inputChange: inputChangeEvent, inputChange: inputChangeEvent,
selectChange: selectChangeEvent, selectChange: selectChangeEvent,
actionButtons: [ actionButtons: [
{ {
text: "Generate", text: t("channel_form_field_psk_generate_button"),
variant: "success", variant: "success",
onClick: preSharedClickEvent, onClick: preSharedClickEvent,
}, },
@ -154,57 +161,105 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{ {
type: "text", type: "text",
name: "settings.name", name: "settings.name",
label: "Name", label: t("channel_form_field_name_label"),
description: description: t("channel_form_field_name_description"),
"A unique name for the channel <12 bytes, leave blank for default",
}, },
{ {
type: "toggle", type: "toggle",
name: "settings.uplinkEnabled", name: "settings.uplinkEnabled",
label: "Uplink Enabled", label: t("channel_form_field_uplink_enabled_label"),
description: "Send messages from the local mesh to MQTT", description: t("channel_form_field_uplink_enabled_description"),
}, },
{ {
type: "toggle", type: "toggle",
name: "settings.downlinkEnabled", name: "settings.downlinkEnabled",
label: "Downlink Enabled", label: t("channel_form_field_downlink_enabled_label"),
description: "Send messages from MQTT to the local mesh", description: t(
"channel_form_field_downlink_enabled_description",
),
}, },
{ {
type: "select", type: "select",
name: "settings.moduleSettings.positionPrecision", name: "settings.moduleSettings.positionPrecision",
label: "Location", label: t("channel_form_field_position_precision_label"),
description: description: t(
"The precision of the location to share with the channel. Can be disabled.", "channel_form_field_position_precision_description",
),
properties: { properties: {
enumValue: config.display?.units === 0 enumValue: config.display?.units === 0
? { ? {
"Do not share location": 0, [t("channel_form_field_position_precision_option_none")]:
"Within 23 kilometers": 10, 0,
"Within 12 kilometers": 11, [
"Within 5.8 kilometers": 12, t("channel_form_field_position_precision_option_metric_km23")
"Within 2.9 kilometers": 13, ]: 10,
"Within 1.5 kilometers": 14, [
"Within 700 meters": 15, t("channel_form_field_position_precision_option_metric_km12")
"Within 350 meters": 16, ]: 11,
"Within 200 meters": 17, [
"Within 90 meters": 18, t("channel_form_field_position_precision_option_metric_km5_8")
"Within 50 meters": 19, ]: 12,
"Precise Location": 32, [
t("channel_form_field_position_precision_option_metric_km2_9")
]: 13,
[
t("channel_form_field_position_precision_option_metric_km1_5")
]: 14,
[
t("channel_form_field_position_precision_option_metric_m700")
]: 15,
[
t("channel_form_field_position_precision_option_metric_m350")
]: 16,
[
t("channel_form_field_position_precision_option_metric_m200")
]: 17,
[
t("channel_form_field_position_precision_option_metric_m90")
]: 18,
[
t("channel_form_field_position_precision_option_metric_m50")
]: 19,
[
t("channel_form_field_position_precision_option_precise")
]: 32,
} }
: { : {
"Do not share location": 0, [t("channel_form_field_position_precision_option_none")]:
"Within 15 miles": 10, 0,
"Within 7.3 miles": 11, [
"Within 3.6 miles": 12, t("channel_form_field_position_precision_option_imperial_mi15")
"Within 1.8 miles": 13, ]: 10,
"Within 0.9 miles": 14, [
"Within 0.5 miles": 15, t("channel_form_field_position_precision_option_imperial_mi7_3")
"Within 0.2 miles": 16, ]: 11,
"Within 600 feet": 17, [
"Within 300 feet": 18, t("channel_form_field_position_precision_option_imperial_mi3_6")
"Within 150 feet": 19, ]: 12,
"Precise Location": 32, [
t("channel_form_field_position_precision_option_imperial_mi1_8")
]: 13,
[
t("channel_form_field_position_precision_option_imperial_mi0_9")
]: 14,
[
t("channel_form_field_position_precision_option_imperial_mi0_5")
]: 15,
[
t("channel_form_field_position_precision_option_imperial_mi0_2")
]: 16,
[
t("channel_form_field_position_precision_option_imperial_ft600")
]: 17,
[
t("channel_form_field_position_precision_option_imperial_ft300")
]: 18,
[
t("channel_form_field_position_precision_option_imperial_ft150")
]: 19,
[
t("channel_form_field_position_precision_option_precise")
]: 32,
}, },
}, },
}, },
@ -214,10 +269,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
text={{ text={{
button: "Regenerate", button: t("dialog_pkiRegenerateDialog_buttonRegenerate"),
title: "Regenerate Pre-Shared Key?", title: t("dialog_pkiRegenerateDialog_title"),
description: description: t("dialog_pkiRegenerateDialog_description"),
"Are you sure you want to regenerate the pre-shared key?",
}} }}
open={preSharedDialogOpen} open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)} onOpenChange={() => setPreSharedDialogOpen(false)}

33
src/components/PageComponents/Config/Bluetooth.tsx

@ -5,6 +5,7 @@ import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
export const Bluetooth = () => { export const Bluetooth = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
@ -16,6 +17,7 @@ export const Bluetooth = () => {
removeError, removeError,
clearErrors, clearErrors,
} = useAppStore(); } = useAppStore();
const { t } = useTranslation();
const [bluetoothPin, setBluetoothPin] = useState( const [bluetoothPin, setBluetoothPin] = useState(
config?.bluetooth?.fixedPin.toString() ?? "", config?.bluetooth?.fixedPin.toString() ?? "",
@ -24,7 +26,7 @@ export const Bluetooth = () => {
const validateBluetoothPin = (pin: string) => { const validateBluetoothPin = (pin: string) => {
// if empty show error they need a pin set // if empty show error they need a pin set
if (pin === "") { if (pin === "") {
return addError("fixedPin", "Bluetooth Pin is required"); return addError("fixedPin", t("config_bluetooth_validation_pinRequired"));
} }
// clear any existing errors // clear any existing errors
@ -32,11 +34,17 @@ export const Bluetooth = () => {
// if it starts with 0 show error // if it starts with 0 show error
if (pin[0] === "0") { if (pin[0] === "0") {
return addError("fixedPin", "Bluetooth Pin cannot start with 0"); return addError(
"fixedPin",
t("config_bluetooth_validation_pinCannotStartWithZero"),
);
} }
// if it's not 6 digits show error // if it's not 6 digits show error
if (pin.length < 6) { if (pin.length < 6) {
return addError("fixedPin", "Pin must be 6 digits"); return addError(
"fixedPin",
t("config_bluetooth_validation_pinMustBeSixDigits"),
);
} }
removeError("fixedPin"); removeError("fixedPin");
@ -69,22 +77,21 @@ export const Bluetooth = () => {
defaultValues={config.bluetooth} defaultValues={config.bluetooth}
fieldGroups={[ fieldGroups={[
{ {
label: "Bluetooth Settings", label: t("config_bluetooth_groupLabel_bluetoothSettings"),
description: "Settings for the Bluetooth module ", description: t("config_bluetooth_groupDescription_bluetoothSettings"),
notes: notes: t("config_bluetooth_groupNotes_bluetoothWifiNote"),
"Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "enabled", name: "enabled",
label: "Enabled", label: t("config_bluetooth_fieldLabel_enabled"),
description: "Enable or disable Bluetooth", description: t("config_bluetooth_fieldDescription_enabled"),
}, },
{ {
type: "select", type: "select",
name: "mode", name: "mode",
label: "Pairing mode", label: t("config_bluetooth_fieldLabel_pairingMode"),
description: "Pin selection behaviour.", description: t("config_bluetooth_fieldDescription_pairingMode"),
selectChange: (e) => { selectChange: (e) => {
if (e !== "1") { if (e !== "1") {
setBluetoothPin(""); setBluetoothPin("");
@ -104,8 +111,8 @@ export const Bluetooth = () => {
{ {
type: "number", type: "number",
name: "fixedPin", name: "fixedPin",
label: "Pin", label: t("config_bluetooth_fieldLabel_pin"),
description: "Pin to use when pairing", description: t("config_bluetooth_fieldDescription_pin"),
validationText: hasFieldError("fixedPin") validationText: hasFieldError("fixedPin")
? getErrorMessage("fixedPin") ? getErrorMessage("fixedPin")
: "", : "",

52
src/components/PageComponents/Config/Device/index.tsx

@ -4,9 +4,11 @@ import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { useTranslation } from "react-i18next";
export const Device = () => { export const Device = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation();
const { validateRoleSelection } = useUnsafeRolesDialog(); const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => { const onSubmit = (data: DeviceValidation) => {
@ -25,14 +27,14 @@ export const Device = () => {
defaultValues={config.device} defaultValues={config.device}
fieldGroups={[ fieldGroups={[
{ {
label: "Device Settings", label: t("config_device_groupLabel_deviceSettings"),
description: "Settings for the device", description: t("config_device_groupDescription_deviceSettings"),
fields: [ fields: [
{ {
type: "select", type: "select",
name: "role", name: "role",
label: "Role", label: t("config_device_fieldLabel_role"),
description: "What role the device performs on the mesh", description: t("config_device_fieldDescription_role"),
validate: validateRoleSelection, validate: validateRoleSelection,
properties: { properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_Role, enumValue: Protobuf.Config.Config_DeviceConfig_Role,
@ -42,20 +44,20 @@ export const Device = () => {
{ {
type: "number", type: "number",
name: "buttonGpio", name: "buttonGpio",
label: "Button Pin", label: t("config_device_fieldLabel_buttonPin"),
description: "Button pin override", description: t("config_device_fieldDescription_buttonPin"),
}, },
{ {
type: "number", type: "number",
name: "buzzerGpio", name: "buzzerGpio",
label: "Buzzer Pin", label: t("config_device_fieldLabel_buzzerPin"),
description: "Buzzer pin override", description: t("config_device_fieldDescription_buzzerPin"),
}, },
{ {
type: "select", type: "select",
name: "rebroadcastMode", name: "rebroadcastMode",
label: "Rebroadcast Mode", label: t("config_device_fieldLabel_rebroadcastMode"),
description: "How to handle rebroadcasting", description: t("config_device_fieldDescription_rebroadcastMode"),
properties: { properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode, enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
formatEnumName: true, formatEnumName: true,
@ -64,29 +66,35 @@ export const Device = () => {
{ {
type: "number", type: "number",
name: "nodeInfoBroadcastSecs", name: "nodeInfoBroadcastSecs",
label: "Node Info Broadcast Interval", label: t("config_device_fieldLabel_nodeInfoBroadcastInterval"),
description: "How often to broadcast node info", description: t(
"config_device_fieldDescription_nodeInfoBroadcastInterval",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "toggle", type: "toggle",
name: "doubleTapAsButtonPress", name: "doubleTapAsButtonPress",
label: "Double Tap as Button Press", label: t("config_device_fieldLabel_doubleTapAsButtonPress"),
description: "Treat double tap as button press", description: t(
"config_device_fieldDescription_doubleTapAsButtonPress",
),
}, },
{ {
type: "toggle", type: "toggle",
name: "disableTripleClick", name: "disableTripleClick",
label: "Disable Triple Click", label: t("config_device_fieldLabel_disableTripleClick"),
description: "Disable triple click", description: t(
"config_device_fieldDescription_disableTripleClick",
),
}, },
{ {
type: "text", type: "text",
name: "tzdef", name: "tzdef",
label: "POSIX Timezone", label: t("config_device_fieldLabel_posixTimezone"),
description: "The POSIX timezone string for the device", description: t("config_device_fieldDescription_posixTimezone"),
properties: { properties: {
fieldLength: { fieldLength: {
max: 64, max: 64,
@ -98,8 +106,10 @@ export const Device = () => {
{ {
type: "toggle", type: "toggle",
name: "ledHeartbeatDisabled", name: "ledHeartbeatDisabled",
label: "LED Heartbeat Disabled", label: t("config_device_fieldLabel_ledHeartbeatDisabled"),
description: "Disable default blinking LED", description: t(
"config_device_fieldDescription_ledHeartbeatDisabled",
),
}, },
], ],
}, },

58
src/components/PageComponents/Config/Display.tsx

@ -1,11 +1,13 @@
import type { DisplayValidation } from "@app/validation/config/display.tsx"; import type { DisplayValidation } from "@app/validation/config/display.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const Display = () => { export const Display = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation();
const onSubmit = (data: DisplayValidation) => { const onSubmit = (data: DisplayValidation) => {
setWorkingConfig( setWorkingConfig(
@ -24,23 +26,23 @@ export const Display = () => {
defaultValues={config.display} defaultValues={config.display}
fieldGroups={[ fieldGroups={[
{ {
label: "Display Settings", label: t("config_display_groupLabel_displaySettings"),
description: "Settings for the device display", description: t("config_display_groupDescription_displaySettings"),
fields: [ fields: [
{ {
type: "number", type: "number",
name: "screenOnSecs", name: "screenOnSecs",
label: "Screen Timeout", label: t("config_display_fieldLabel_screenTimeout"),
description: "Turn off the display after this long", description: t("config_display_fieldDescription_screenTimeout"),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "select", type: "select",
name: "gpsFormat", name: "gpsFormat",
label: "GPS Display Units", label: t("config_display_fieldLabel_gpsDisplayUnits"),
description: "Coordinate display format", description: t("config_display_fieldDescription_gpsDisplayUnits"),
properties: { properties: {
enumValue: enumValue:
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat, Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
@ -49,35 +51,35 @@ export const Display = () => {
{ {
type: "number", type: "number",
name: "autoScreenCarouselSecs", name: "autoScreenCarouselSecs",
label: "Carousel Delay", label: t("config_display_fieldLabel_carouselDelay"),
description: "How fast to cycle through windows", description: t("config_display_fieldDescription_carouselDelay"),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "toggle", type: "toggle",
name: "compassNorthTop", name: "compassNorthTop",
label: "Compass North Top", label: t("config_display_fieldLabel_compassNorthTop"),
description: "Fix north to the top of compass", description: t("config_display_fieldDescription_compassNorthTop"),
}, },
{ {
type: "toggle", type: "toggle",
name: "use12hClock", name: "use12hClock",
label: "12-Hour Clock", label: t("config_display_fieldLabel_twelveHourClock"),
description: "Use 12-hour clock format", description: t("config_display_fieldDescription_twelveHourClock"),
}, },
{ {
type: "toggle", type: "toggle",
name: "flipScreen", name: "flipScreen",
label: "Flip Screen", label: t("config_display_fieldLabel_flipScreen"),
description: "Flip display 180 degrees", description: t("config_display_fieldDescription_flipScreen"),
}, },
{ {
type: "select", type: "select",
name: "units", name: "units",
label: "Display Units", label: t("config_display_fieldLabel_displayUnits"),
description: "Display metric or imperial units", description: t("config_display_fieldDescription_displayUnits"),
properties: { properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits, enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits,
formatEnumName: true, formatEnumName: true,
@ -86,8 +88,8 @@ export const Display = () => {
{ {
type: "select", type: "select",
name: "oled", name: "oled",
label: "OLED Type", label: t("config_display_fieldLabel_oledType"),
description: "Type of OLED screen attached to the device", description: t("config_display_fieldDescription_oledType"),
properties: { properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_OledType, enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
}, },
@ -95,8 +97,8 @@ export const Display = () => {
{ {
type: "select", type: "select",
name: "displaymode", name: "displaymode",
label: "Display Mode", label: t("config_display_fieldLabel_displayMode"),
description: "Screen layout variant", description: t("config_display_fieldDescription_displayMode"),
properties: { properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode, enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
formatEnumName: true, formatEnumName: true,
@ -105,14 +107,16 @@ export const Display = () => {
{ {
type: "toggle", type: "toggle",
name: "headingBold", name: "headingBold",
label: "Bold Heading", label: t("config_display_fieldLabel_boldHeading"),
description: "Bolden the heading text", description: t("config_display_fieldDescription_boldHeading"),
}, },
{ {
type: "toggle", type: "toggle",
name: "wakeOnTapOrMotion", name: "wakeOnTapOrMotion",
label: "Wake on Tap or Motion", label: t("config_display_fieldLabel_wakeOnTapOrMotion"),
description: "Wake the device on tap or motion", description: t(
"config_display_fieldDescription_wakeOnTapOrMotion",
),
}, },
], ],
}, },

92
src/components/PageComponents/Config/LoRa.tsx

@ -1,11 +1,13 @@
import type { LoRaValidation } from "@app/validation/config/lora.tsx"; import type { LoRaValidation } from "@app/validation/config/lora.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const LoRa = () => { export const LoRa = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation();
const onSubmit = (data: LoRaValidation) => { const onSubmit = (data: LoRaValidation) => {
setWorkingConfig( setWorkingConfig(
@ -24,14 +26,14 @@ export const LoRa = () => {
defaultValues={config.lora} defaultValues={config.lora}
fieldGroups={[ fieldGroups={[
{ {
label: "Mesh Settings", label: t("config_lora_groupLabel_meshSettings"),
description: "Settings for the LoRa mesh", description: t("config_lora_groupDescription_meshSettings"),
fields: [ fields: [
{ {
type: "select", type: "select",
name: "region", name: "region",
label: "Region", label: t("config_lora_fieldLabel_region"),
description: "Sets the region for your node", description: t("config_lora_fieldDescription_region"),
properties: { properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode, enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
}, },
@ -39,8 +41,8 @@ export const LoRa = () => {
{ {
type: "select", type: "select",
name: "hopLimit", name: "hopLimit",
label: "Hop Limit", label: t("config_lora_fieldLabel_hopLimit"),
description: "Maximum number of hops", description: t("config_lora_fieldDescription_hopLimit"),
properties: { properties: {
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 }, enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
}, },
@ -48,39 +50,38 @@ export const LoRa = () => {
{ {
type: "number", type: "number",
name: "channelNum", name: "channelNum",
label: "Frequency Slot", label: t("config_lora_fieldLabel_frequencySlot"),
description: "LoRa frequency channel number", description: t("config_lora_fieldDescription_frequencySlot"),
}, },
{ {
type: "toggle", type: "toggle",
name: "ignoreMqtt", name: "ignoreMqtt",
label: "Ignore MQTT", label: t("config_lora_fieldLabel_ignoreMqtt"),
description: "Don't forward MQTT messages over the mesh", description: t("config_lora_fieldDescription_ignoreMqtt"),
}, },
{ {
type: "toggle", type: "toggle",
name: "configOkToMqtt", name: "configOkToMqtt",
label: "OK to MQTT", label: t("config_lora_fieldLabel_okToMqtt"),
description: description: t("config_lora_fieldDescription_okToMqtt"),
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
}, },
], ],
}, },
{ {
label: "Waveform Settings", label: t("config_lora_groupLabel_waveformSettings"),
description: "Settings for the LoRa waveform", description: t("config_lora_groupDescription_waveformSettings"),
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "usePreset", name: "usePreset",
label: "Use Preset", label: t("config_lora_fieldLabel_usePreset"),
description: "Use one of the predefined modem presets", description: t("config_lora_fieldDescription_usePreset"),
}, },
{ {
type: "select", type: "select",
name: "modemPreset", name: "modemPreset",
label: "Modem Preset", label: t("config_lora_fieldLabel_modemPreset"),
description: "Modem preset to use", description: t("config_lora_fieldDescription_modemPreset"),
disabledBy: [ disabledBy: [
{ {
fieldName: "usePreset", fieldName: "usePreset",
@ -94,8 +95,8 @@ export const LoRa = () => {
{ {
type: "number", type: "number",
name: "bandwidth", name: "bandwidth",
label: "Bandwidth", label: t("config_lora_fieldLabel_bandwidth"),
description: "Channel bandwidth in MHz", description: t("config_lora_fieldDescription_bandwidth"),
disabledBy: [ disabledBy: [
{ {
fieldName: "usePreset", fieldName: "usePreset",
@ -103,14 +104,14 @@ export const LoRa = () => {
}, },
], ],
properties: { properties: {
suffix: "MHz", suffix: t("common_unit_megahertz"),
}, },
}, },
{ {
type: "number", type: "number",
name: "spreadFactor", name: "spreadFactor",
label: "Spreading Factor", label: t("config_lora_fieldLabel_spreadingFactor"),
description: "Indicates the number of chirps per symbol", description: t("config_lora_fieldDescription_spreadingFactor"),
disabledBy: [ disabledBy: [
{ {
@ -119,14 +120,14 @@ export const LoRa = () => {
}, },
], ],
properties: { properties: {
suffix: "CPS", suffix: t("common_unit_cps"),
}, },
}, },
{ {
type: "number", type: "number",
name: "codingRate", name: "codingRate",
label: "Coding Rate", label: t("config_lora_fieldLabel_codingRate"),
description: "The denominator of the coding rate", description: t("config_lora_fieldDescription_codingRate"),
disabledBy: [ disabledBy: [
{ {
fieldName: "usePreset", fieldName: "usePreset",
@ -137,53 +138,52 @@ export const LoRa = () => {
], ],
}, },
{ {
label: "Radio Settings", label: t("config_lora_groupLabel_radioSettings"),
description: "Settings for the LoRa radio", description: t("config_lora_groupDescription_radioSettings"),
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "txEnabled", name: "txEnabled",
label: "Transmit Enabled", label: t("config_lora_fieldLabel_transmitEnabled"),
description: "Enable/Disable transmit (TX) from the LoRa radio", description: t("config_lora_fieldDescription_transmitEnabled"),
}, },
{ {
type: "number", type: "number",
name: "txPower", name: "txPower",
label: "Transmit Power", label: t("config_lora_fieldLabel_transmitPower"),
description: "Max transmit power", description: t("config_lora_fieldDescription_transmitPower"),
properties: { properties: {
suffix: "dBm", suffix: t("common_unit_dbm"),
}, },
}, },
{ {
type: "toggle", type: "toggle",
name: "overrideDutyCycle", name: "overrideDutyCycle",
label: "Override Duty Cycle", label: t("config_lora_fieldLabel_overrideDutyCycle"),
description: "Override Duty Cycle", description: t("config_lora_fieldDescription_overrideDutyCycle"),
}, },
{ {
type: "number", type: "number",
name: "frequencyOffset", name: "frequencyOffset",
label: "Frequency Offset", label: t("config_lora_fieldLabel_frequencyOffset"),
description: description: t("config_lora_fieldDescription_frequencyOffset"),
"Frequency offset to correct for crystal calibration errors",
properties: { properties: {
suffix: "Hz", suffix: t("common_unit_hertz"),
}, },
}, },
{ {
type: "toggle", type: "toggle",
name: "sx126xRxBoostedGain", name: "sx126xRxBoostedGain",
label: "Boosted RX Gain", label: t("config_lora_fieldLabel_boostedRxGain"),
description: "Boosted RX gain", description: t("config_lora_fieldDescription_boostedRxGain"),
}, },
{ {
type: "number", type: "number",
name: "overrideFrequency", name: "overrideFrequency",
label: "Override Frequency", label: t("config_lora_fieldLabel_overrideFrequency"),
description: "Override frequency", description: t("config_lora_fieldDescription_overrideFrequency"),
properties: { properties: {
suffix: "MHz", suffix: t("common_unit_megahertz"),
step: 0.001, step: 0.001,
}, },
}, },

71
src/components/PageComponents/Config/Network/index.tsx

@ -11,9 +11,11 @@ import {
} from "@core/utils/ip.ts"; } from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { validateSchema } from "@app/validation/validate.ts"; import { validateSchema } from "@app/validation/validate.ts";
import { useTranslation } from "react-i18next";
export const Network = () => { export const Network = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation();
const onSubmit = (data: NetworkValidation) => { const onSubmit = (data: NetworkValidation) => {
const result = validateSchema(NetworkValidationSchema, data); const result = validateSchema(NetworkValidationSchema, data);
@ -63,22 +65,21 @@ export const Network = () => {
}} }}
fieldGroups={[ fieldGroups={[
{ {
label: "WiFi Config", label: t("config_network_groupLabel_wifiConfig"),
description: "WiFi radio configuration", description: t("config_network_groupDescription_wifiConfig"),
notes: notes: t("config_network_groupNotes_wifiBluetoothNote"),
"Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "wifiEnabled", name: "wifiEnabled",
label: "Enabled", label: t("config_network_fieldLabel_wifiEnabled"),
description: "Enable or disable the WiFi radio", description: t("config_network_fieldDescription_wifiEnabled"),
}, },
{ {
type: "text", type: "text",
name: "wifiSsid", name: "wifiSsid",
label: "SSID", label: t("config_network_fieldLabel_ssid"),
description: "Network name", description: t("config_network_fieldDescription_ssid"),
disabledBy: [ disabledBy: [
{ {
fieldName: "wifiEnabled", fieldName: "wifiEnabled",
@ -88,8 +89,8 @@ export const Network = () => {
{ {
type: "password", type: "password",
name: "wifiPsk", name: "wifiPsk",
label: "PSK", label: t("config_network_fieldLabel_psk"),
description: "Network password", description: t("config_network_fieldDescription_psk"),
disabledBy: [ disabledBy: [
{ {
fieldName: "wifiEnabled", fieldName: "wifiEnabled",
@ -99,26 +100,26 @@ export const Network = () => {
], ],
}, },
{ {
label: "Ethernet Config", label: t("config_network_groupLabel_ethernetConfig"),
description: "Ethernet port configuration", description: t("config_network_groupDescription_ethernetConfig"),
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "ethEnabled", name: "ethEnabled",
label: "Enabled", label: t("config_network_fieldLabel_ethernetEnabled"),
description: "Enable or disable the Ethernet port", description: t("config_network_fieldDescription_ethernetEnabled"),
}, },
], ],
}, },
{ {
label: "IP Config", label: t("config_network_groupLabel_ipConfig"),
description: "IP configuration", description: t("config_network_groupDescription_ipConfig"),
fields: [ fields: [
{ {
type: "select", type: "select",
name: "addressMode", name: "addressMode",
label: "Address Mode", label: t("config_network_fieldLabel_addressMode"),
description: "Address assignment selection", description: t("config_network_fieldDescription_addressMode"),
properties: { properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode, enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode,
}, },
@ -126,8 +127,8 @@ export const Network = () => {
{ {
type: "text", type: "text",
name: "ipv4Config.ip", name: "ipv4Config.ip",
label: "IP", label: t("config_network_fieldLabel_ip"),
description: "IP Address", description: t("config_network_fieldDescription_ip"),
disabledBy: [ disabledBy: [
{ {
fieldName: "addressMode", fieldName: "addressMode",
@ -139,8 +140,8 @@ export const Network = () => {
{ {
type: "text", type: "text",
name: "ipv4Config.gateway", name: "ipv4Config.gateway",
label: "Gateway", label: t("config_network_fieldLabel_gateway"),
description: "Default Gateway", description: t("config_network_fieldDescription_gateway"),
disabledBy: [ disabledBy: [
{ {
fieldName: "addressMode", fieldName: "addressMode",
@ -152,8 +153,8 @@ export const Network = () => {
{ {
type: "text", type: "text",
name: "ipv4Config.subnet", name: "ipv4Config.subnet",
label: "Subnet", label: t("config_network_fieldLabel_subnet"),
description: "Subnet Mask", description: t("config_network_fieldDescription_subnet"),
disabledBy: [ disabledBy: [
{ {
fieldName: "addressMode", fieldName: "addressMode",
@ -165,8 +166,8 @@ export const Network = () => {
{ {
type: "text", type: "text",
name: "ipv4Config.dns", name: "ipv4Config.dns",
label: "DNS", label: t("config_network_fieldLabel_dns"),
description: "DNS Server", description: t("config_network_fieldDescription_dns"),
disabledBy: [ disabledBy: [
{ {
fieldName: "addressMode", fieldName: "addressMode",
@ -178,13 +179,13 @@ export const Network = () => {
], ],
}, },
{ {
label: "UDP Config", label: t("config_network_groupLabel_udpConfig"),
description: "UDP over Mesh configuration", description: t("config_network_groupDescription_udpConfig"),
fields: [ fields: [
{ {
type: "select", type: "select",
name: "enabledProtocols", name: "enabledProtocols",
label: "Mesh via UDP", label: t("config_network_fieldLabel_meshViaUdp"),
properties: { properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags, enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true, formatEnumName: true,
@ -193,24 +194,24 @@ export const Network = () => {
], ],
}, },
{ {
label: "NTP Config", label: t("config_network_groupLabel_ntpConfig"),
description: "NTP configuration", description: t("config_network_groupDescription_ntpConfig"),
fields: [ fields: [
{ {
type: "text", type: "text",
name: "ntpServer", name: "ntpServer",
label: "NTP Server", label: t("config_network_fieldLabel_ntpServer"),
}, },
], ],
}, },
{ {
label: "Rsyslog Config", label: t("config_network_groupLabel_rsyslogConfig"),
description: "Rsyslog configuration", description: t("config_network_groupDescription_rsyslogConfig"),
fields: [ fields: [
{ {
type: "text", type: "text",
name: "rsyslogServer", name: "rsyslogServer",
label: "Rsyslog Server", label: t("config_network_fieldLabel_rsyslogServer"),
}, },
], ],
}, },

78
src/components/PageComponents/Config/Position.tsx

@ -8,12 +8,14 @@ import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next";
export const Position = () => { export const Position = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config?.position?.positionFlags ?? 0, config?.position?.positionFlags ?? 0,
); );
const { t } = useTranslation();
const onSubmit = (data: PositionValidation) => { const onSubmit = (data: PositionValidation) => {
return setWorkingConfig( return setWorkingConfig(
@ -42,22 +44,22 @@ export const Position = () => {
defaultValues={config.position} defaultValues={config.position}
fieldGroups={[ fieldGroups={[
{ {
label: "Position Settings", label: t("config_position_groupLabel_positionSettings"),
description: "Settings for the position module", description: t("config_position_groupDescription_positionSettings"),
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "positionBroadcastSmartEnabled", name: "positionBroadcastSmartEnabled",
label: "Enable Smart Position", label: t("config_position_fieldLabel_smartPositionEnabled"),
description: description: t(
"Only send position when there has been a meaningful change in location", "config_position_fieldDescription_smartPositionEnabled",
),
}, },
{ {
type: "select", type: "select",
name: "gpsMode", name: "gpsMode",
label: "GPS Mode", label: t("config_position_fieldLabel_gpsMode"),
description: description: t("config_position_fieldDescription_gpsMode"),
"Configure whether device GPS is Enabled, Disabled, or Not Present",
properties: { properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode, enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
}, },
@ -65,9 +67,8 @@ export const Position = () => {
{ {
type: "toggle", type: "toggle",
name: "fixedPosition", name: "fixedPosition",
label: "Fixed Position", label: t("config_position_fieldLabel_fixedPosition"),
description: description: t("config_position_fieldDescription_fixedPosition"),
"Don't report GPS position, but a manually-specified one",
}, },
{ {
type: "multiSelect", type: "multiSelect",
@ -76,10 +77,11 @@ export const Position = () => {
isChecked: (name: string) => isChecked: (name: string) =>
activeFlags?.includes(name as FlagName) ?? false, activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange, onValueChange: onPositonFlagChange,
label: "Position Flags", label: t("config_position_fieldLabel_positionFlags"),
placeholder: "Select position flags...", placeholder: t(
description: "config_position_fieldPlaceholder_selectPositionFlags",
"Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.", ),
description: t("config_position_fieldDescription_positionFlags"),
properties: { properties: {
enumValue: getAllFlags(), enumValue: getAllFlags(),
}, },
@ -87,51 +89,56 @@ export const Position = () => {
{ {
type: "number", type: "number",
name: "rxGpio", name: "rxGpio",
label: "Receive Pin", label: t("config_position_fieldLabel_receivePin"),
description: "GPS module RX pin override", description: t("config_position_fieldDescription_receivePin"),
}, },
{ {
type: "number", type: "number",
name: "txGpio", name: "txGpio",
label: "Transmit Pin", label: t("config_position_fieldLabel_transmitPin"),
description: "GPS module TX pin override", description: t("config_position_fieldDescription_transmitPin"),
}, },
{ {
type: "number", type: "number",
name: "gpsEnGpio", name: "gpsEnGpio",
label: "Enable Pin", label: t("config_position_fieldLabel_enablePin"),
description: "GPS module enable pin override", description: t("config_position_fieldDescription_enablePin"),
}, },
], ],
}, },
{ {
label: "Intervals", label: t("config_position_groupLabel_intervals"),
description: "How often to send position updates", description: t("config_position_groupDescription_intervals"),
fields: [ fields: [
{ {
type: "number", type: "number",
name: "positionBroadcastSecs", name: "positionBroadcastSecs",
label: "Broadcast Interval", label: t("config_position_fieldLabel_broadcastInterval"),
description: "How often your position is sent out over the mesh", description: t(
"config_position_fieldDescription_broadcastInterval",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "number", type: "number",
name: "gpsUpdateInterval", name: "gpsUpdateInterval",
label: "GPS Update Interval", label: t("config_position_fieldLabel_gpsUpdateInterval"),
description: "How often a GPS fix should be acquired", description: t(
"config_position_fieldDescription_gpsUpdateInterval",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "number", type: "number",
name: "broadcastSmartMinimumDistance", name: "broadcastSmartMinimumDistance",
label: "Smart Position Minimum Distance", label: t("config_position_fieldLabel_smartPositionMinDistance"),
description: description: t(
"Minimum distance (in meters) that must be traveled before a position update is sent", "config_position_fieldDescription_smartPositionMinDistance",
),
disabledBy: [ disabledBy: [
{ {
fieldName: "positionBroadcastSmartEnabled", fieldName: "positionBroadcastSmartEnabled",
@ -141,9 +148,10 @@ export const Position = () => {
{ {
type: "number", type: "number",
name: "broadcastSmartMinimumIntervalSecs", name: "broadcastSmartMinimumIntervalSecs",
label: "Smart Position Minimum Interval", label: t("config_position_fieldLabel_smartPositionMinInterval"),
description: description: t(
"Minimum interval (in seconds) that must pass before a position update is sent", "config_position_fieldDescription_smartPositionMinInterval",
),
disabledBy: [ disabledBy: [
{ {
fieldName: "positionBroadcastSmartEnabled", fieldName: "positionBroadcastSmartEnabled",

71
src/components/PageComponents/Config/Power.tsx

@ -1,11 +1,13 @@
import type { PowerValidation } from "@app/validation/config/power.tsx"; import type { PowerValidation } from "@app/validation/config/power.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const Power = () => { export const Power = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation();
const onSubmit = (data: PowerValidation) => { const onSubmit = (data: PowerValidation) => {
setWorkingConfig( setWorkingConfig(
@ -24,31 +26,35 @@ export const Power = () => {
defaultValues={config.power} defaultValues={config.power}
fieldGroups={[ fieldGroups={[
{ {
label: "Power Config", label: t("config_power_groupLabel_powerConfig"),
description: "Settings for the power module", description: t("config_power_groupDescription_powerConfig"),
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "isPowerSaving", name: "isPowerSaving",
label: "Enable power saving mode", label: t("config_power_fieldLabel_powerSavingEnabled"),
description: description: t(
"Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.", "config_power_fieldDescription_powerSavingEnabled",
),
}, },
{ {
type: "number", type: "number",
name: "onBatteryShutdownAfterSecs", name: "onBatteryShutdownAfterSecs",
label: "Shutdown on battery delay", label: t("config_power_fieldLabel_shutdownOnBatteryDelay"),
description: description: t(
"Automatically shutdown node after this long when on battery, 0 for indefinite", "config_power_fieldDescription_shutdownOnBatteryDelay",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "number", type: "number",
name: "adcMultiplierOverride", name: "adcMultiplierOverride",
label: "ADC Multiplier Override ratio", label: t("config_power_fieldLabel_adcMultiplierOverride"),
description: "Used for tweaking battery voltage reading", description: t(
"config_power_fieldDescription_adcMultiplierOverride",
),
properties: { properties: {
step: 0.0001, step: 0.0001,
}, },
@ -56,52 +62,55 @@ export const Power = () => {
{ {
type: "number", type: "number",
name: "waitBluetoothSecs", name: "waitBluetoothSecs",
label: "No Connection Bluetooth Disabled", label: t("config_power_fieldLabel_noConnectionBluetoothDisabled"),
description: description: t(
"If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long", "config_power_fieldDescription_noConnectionBluetoothDisabled",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "number", type: "number",
name: "deviceBatteryInaAddress", name: "deviceBatteryInaAddress",
label: "INA219 Address", label: t("config_power_fieldLabel_ina219Address"),
description: "Address of the INA219 battery monitor", description: t("config_power_fieldDescription_ina219Address"),
}, },
], ],
}, },
{ {
label: "Sleep Settings", label: t("config_power_groupLabel_sleepSettings"),
description: "Sleep settings for the power module", description: t("config_power_groupDescription_sleepSettings"),
fields: [ fields: [
{ {
type: "number", type: "number",
name: "sdsSecs", name: "sdsSecs",
label: "Super Deep Sleep Duration", label: t("config_power_fieldLabel_superDeepSleepDuration"),
description: description: t(
"How long the device will be in super deep sleep for", "config_power_fieldDescription_superDeepSleepDuration",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "number", type: "number",
name: "lsSecs", name: "lsSecs",
label: "Light Sleep Duration", label: t("config_power_fieldLabel_lightSleepDuration"),
description: "How long the device will be in light sleep for", description: t(
"config_power_fieldDescription_lightSleepDuration",
),
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
{ {
type: "number", type: "number",
name: "minWakeSecs", name: "minWakeSecs",
label: "Minimum Wake Time", label: t("config_power_fieldLabel_minimumWakeTime"),
description: description: t("config_power_fieldDescription_minimumWakeTime"),
"Minimum amount of time the device will stay awake for after receiving a packet",
properties: { properties: {
suffix: "Seconds", suffix: t("common_unit_seconds"),
}, },
}, },
], ],

133
src/components/PageComponents/Config/Security/Security.tsx

@ -10,6 +10,7 @@ import { fromByteArray, toByteArray } from "base64-js";
import { useReducer } from "react"; import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
import type { SecurityConfigInit } from "./types.ts"; import type { SecurityConfigInit } from "./types.ts";
import { useTranslation } from "react-i18next";
export const Security = () => { export const Security = () => {
const { config, setWorkingConfig, setDialogOpen } = useDevice(); const { config, setWorkingConfig, setDialogOpen } = useDevice();
@ -21,6 +22,7 @@ export const Security = () => {
removeError, removeError,
clearErrors, clearErrors,
} = useAppStore(); } = useAppStore();
const { t } = useTranslation();
const [state, dispatch] = useReducer(securityReducer, { const [state, dispatch] = useReducer(securityReducer, {
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
@ -50,7 +52,10 @@ export const Security = () => {
try { try {
removeError(fieldNameKey); removeError(fieldNameKey);
if (fieldName === "privateKey" && input === "") { if (fieldName === "privateKey" && input === "") {
addError(fieldNameKey, "Private Key is required"); addError(
fieldNameKey,
t("config_security_validation_privateKeyRequired"),
);
return; return;
} }
@ -62,7 +67,7 @@ export const Security = () => {
) { ) {
addError( addError(
"adminKey0", "adminKey0",
"At least one admin key is requred if the node is managed.", t("config_security_validation_adminKeyRequiredWhenManaged"),
); );
} }
@ -72,25 +77,30 @@ export const Security = () => {
if (input.length % 4 !== 0) { if (input.length % 4 !== 0) {
addError( addError(
fieldNameKey, fieldNameKey,
`${ fieldName === "privateKey"
fieldName === "privateKey" ? "Private" : "Admin" ? t("config_security_validation_privateKeyMustBe256BitPsk")
} Key is required to be a 256 bit pre-shared key (PSK)`, : t("config_security_validation_adminKeyMustBe256BitPsk"),
); );
return; return;
} }
const decoded = toByteArray(input); const decoded = toByteArray(input);
if (decoded.length !== count) { if (decoded.length !== count) {
addError(fieldNameKey, `Please enter a valid ${count * 8} bit PSK`); addError(
fieldNameKey,
t("config_security_validation_enterValid256BitPsk", {
bits: count * 8,
}),
);
return; return;
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
addError( addError(
fieldNameKey, fieldNameKey,
`Invalid ${ fieldName === "privateKey"
fieldName === "privateKey" ? "Private" : "Admin" ? t("config_security_validation_invalidPrivateKeyFormat")
} Key format`, : t("config_security_validation_invalidAdminKeyFormat"),
); );
} }
}; };
@ -207,10 +217,10 @@ export const Security = () => {
if ( if (
field === "isManaged" && state.adminKey.every((s) => s === "") field === "isManaged" && state.adminKey.every((s) => s === "")
) { ) {
if (next) { if (next) { // If enabling 'managed' and no admin keys are set
addError( addError(
"adminKey0", "adminKey0",
"At least one admin key is requred if the node is managed.", t("config_security_validation_adminKeyRequiredWhenManaged"),
); );
} else { } else {
removeError("adminKey0"); removeError("adminKey0");
@ -252,16 +262,20 @@ export const Security = () => {
}} }}
fieldGroups={[ fieldGroups={[
{ {
label: "Security Settings", label: t("config_security_groupLabel_securitySettings"),
description: "Settings for the Security configuration", description: t("config_security_groupDescription_securitySettings"),
fields: [ fields: [
{ {
type: "passwordGenerator", type: "passwordGenerator",
id: "pskInput", id: "pskInput",
name: "privateKey", name: "privateKey",
label: "Private Key", label: t("config_security_fieldLabel_privateKey"),
description: "Used to create a shared key with a remote device", description: t("config_security_fieldDescription_privateKey"),
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [{
text: t("config_security_option_256bit"),
value: "32",
key: "bit256",
}],
validationText: hasFieldError("privateKey") validationText: hasFieldError("privateKey")
? getErrorMessage("privateKey") ? getErrorMessage("privateKey")
: "", : "",
@ -271,7 +285,7 @@ export const Security = () => {
hide: !state.privateKeyVisible, hide: !state.privateKeyVisible,
actionButtons: [ actionButtons: [
{ {
text: "Generate", text: t("common_button_generate"),
onClick: () => onClick: () =>
dispatch({ dispatch({
type: "SHOW_PRIVATE_KEY_DIALOG", type: "SHOW_PRIVATE_KEY_DIALOG",
@ -280,7 +294,7 @@ export const Security = () => {
variant: "success", variant: "success",
}, },
{ {
text: "Backup Key", text: t("config_security_button_backupKey"),
onClick: () => setDialogOpen("pkiBackup", true), onClick: () => setDialogOpen("pkiBackup", true),
variant: "subtle", variant: "subtle",
}, },
@ -294,10 +308,9 @@ export const Security = () => {
{ {
type: "text", type: "text",
name: "publicKey", name: "publicKey",
label: "Public Key", label: t("config_security_fieldLabel_publicKey"),
disabled: true, disabled: true,
description: description: t("config_security_fieldDescription_publicKey"),
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: { properties: {
value: state.publicKey, value: state.publicKey,
showCopyButton: true, showCopyButton: true,
@ -306,22 +319,27 @@ export const Security = () => {
], ],
}, },
{ {
label: "Admin Settings", label: t("config_security_groupLabel_adminSettings"),
description: "Settings for Admin", description: t("config_security_groupDescription_adminSettings"),
fields: [ fields: [
{ {
type: "passwordGenerator", type: "passwordGenerator",
name: "adminKey.0", name: "adminKey.0",
id: "adminKey0Input", id: "adminKey0Input",
label: "Primary Admin Key", label: t("config_security_fieldLabel_primaryAdminKey"),
description: description: t(
"The primary public key authorized to send admin messages to this node", "config_security_fieldDescription_primaryAdminKey",
),
validationText: hasFieldError("adminKey0") validationText: hasFieldError("adminKey0")
? getErrorMessage("adminKey0") ? getErrorMessage("adminKey0")
: "", : "",
inputChange: (e) => adminKeyInputChangeEvent(e, 0), inputChange: (e) => adminKeyInputChangeEvent(e, 0),
selectChange: () => {}, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [{
text: t("config_security_option_256bit"),
value: "32",
key: "bit256",
}],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[0], hide: !state.adminKeyVisible[0],
actionButtons: [], actionButtons: [],
@ -338,15 +356,20 @@ export const Security = () => {
type: "passwordGenerator", type: "passwordGenerator",
name: "adminKey.1", name: "adminKey.1",
id: "adminKey1Input", id: "adminKey1Input",
label: "Secondary Admin Key", label: t("config_security_fieldLabel_secondaryAdminKey"),
description: description: t(
"The secondary public key authorized to send admin messages to this node", "config_security_fieldDescription_secondaryAdminKey",
),
validationText: hasFieldError("adminKey1") validationText: hasFieldError("adminKey1")
? getErrorMessage("adminKey1") ? getErrorMessage("adminKey1")
: "", : "",
inputChange: (e) => adminKeyInputChangeEvent(e, 1), inputChange: (e) => adminKeyInputChangeEvent(e, 1),
selectChange: () => {}, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [{
text: t("config_security_option_256bit"),
value: "32",
key: "bit256",
}],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[1], hide: !state.adminKeyVisible[1],
actionButtons: [], actionButtons: [],
@ -363,15 +386,20 @@ export const Security = () => {
type: "passwordGenerator", type: "passwordGenerator",
name: "adminKey.2", name: "adminKey.2",
id: "adminKey2Input", id: "adminKey2Input",
label: "Tertiary Admin Key", label: t("config_security_fieldLabel_tertiaryAdminKey"),
description: description: t(
"The tertiary public key authorized to send admin messages to this node", "config_security_fieldDescription_tertiaryAdminKey",
),
validationText: hasFieldError("adminKey2") validationText: hasFieldError("adminKey2")
? getErrorMessage("adminKey2") ? getErrorMessage("adminKey2")
: "", : "",
inputChange: (e) => adminKeyInputChangeEvent(e, 2), inputChange: (e) => adminKeyInputChangeEvent(e, 2),
selectChange: () => {}, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [{
text: t("config_security_option_256bit"),
value: "32",
key: "bit256",
}],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[2], hide: !state.adminKeyVisible[2],
actionButtons: [], actionButtons: [],
@ -387,9 +415,8 @@ export const Security = () => {
{ {
type: "toggle", type: "toggle",
name: "isManaged", name: "isManaged",
label: "Managed", label: t("config_security_fieldLabel_managed"),
description: description: t("config_security_fieldDescription_managed"),
"If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
inputChange: (e: boolean) => onToggleChange("isManaged", e), inputChange: (e: boolean) => onToggleChange("isManaged", e),
properties: { properties: {
checked: state.isManaged, checked: state.isManaged,
@ -404,9 +431,10 @@ export const Security = () => {
{ {
type: "toggle", type: "toggle",
name: "adminChannelEnabled", name: "adminChannelEnabled",
label: "Allow Legacy Admin", label: t("config_security_fieldLabel_allowLegacyAdmin"),
description: description: t(
"Allow incoming device control over the insecure legacy admin channel", "config_security_fieldDescription_adminChannelEnabled",
),
inputChange: (e: boolean) => inputChange: (e: boolean) =>
onToggleChange("adminChannelEnabled", e), onToggleChange("adminChannelEnabled", e),
properties: { properties: {
@ -416,15 +444,16 @@ export const Security = () => {
], ],
}, },
{ {
label: "Logging Settings", label: t("config_security_groupLabel_loggingSettings"),
description: "Settings for Logging", description: t("config_security_groupDescription_loggingSettings"),
fields: [ fields: [
{ {
type: "toggle", type: "toggle",
name: "debugLogApiEnabled", name: "debugLogApiEnabled",
label: "Enable Debug Log API", label: t("config_security_fieldLabel_enableDebugLogApi"),
description: description: t(
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth", "config_security_fieldDescription_enableDebugLogApi",
),
inputChange: (e: boolean) => inputChange: (e: boolean) =>
onToggleChange("debugLogApiEnabled", e), onToggleChange("debugLogApiEnabled", e),
properties: { properties: {
@ -434,8 +463,10 @@ export const Security = () => {
{ {
type: "toggle", type: "toggle",
name: "serialEnabled", name: "serialEnabled",
label: "Serial Output Enabled", label: t("config_security_fieldLabel_serialOutputEnabled"),
description: "Serial Console over the Stream API", description: t(
"config_security_fieldDescription_serialOutputEnabled",
),
inputChange: (e: boolean) => onToggleChange("serialEnabled", e), inputChange: (e: boolean) => onToggleChange("serialEnabled", e),
properties: { properties: {
checked: state.serialEnabled, checked: state.serialEnabled,
@ -447,9 +478,9 @@ export const Security = () => {
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
text={{ text={{
button: "Regenerate", button: t("dialog_pkiRegenerateDialog_buttonRegenerate"),
title: "Regenerate Key pair?", title: t("dialog_pkiRegenerate_title_keyPair"),
description: "Are you sure you want to regenerate key pair?", description: t("dialog_pkiRegenerate_description_keyPair"),
}} }}
open={state.privateKeyDialogOpen} open={state.privateKeyDialogOpen}
onOpenChange={() => onOpenChange={() =>

6
src/components/PageComponents/Connect/BLE.tsx

@ -8,6 +8,7 @@ import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export const BLE = ( export const BLE = (
{ closeDialog }: TabElementProps, { closeDialog }: TabElementProps,
@ -17,6 +18,7 @@ export const BLE = (
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore(); const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
const updateBleDeviceList = useCallback(async (): Promise<void> => { const updateBleDeviceList = useCallback(async (): Promise<void> => {
setBleDevices(await navigator.bluetooth.getDevices()); setBleDevices(await navigator.bluetooth.getDevices());
@ -59,7 +61,9 @@ export const BLE = (
</Button> </Button>
))} ))}
{bleDevices.length === 0 && ( {bleDevices.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono> <Mono className="m-auto select-none">
{t("bluetoothConnection.noDevicesPaired")}
</Mono>
)} )}
</div> </div>
<Button <Button

48
src/components/PageComponents/Connect/HTTP.tsx

@ -13,7 +13,8 @@ import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react"; import { useState } from "react";
import { useController, useForm } from "react-hook-form"; import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
interface FormData { interface FormData {
ip: string; ip: string;
@ -23,6 +24,7 @@ interface FormData {
export const HTTP = ( export const HTTP = (
{ closeDialog }: TabElementProps, { closeDialog }: TabElementProps,
) => { ) => {
const { t } = useTranslation();
const [connectionInProgress, setConnectionInProgress] = useState(false); const [connectionInProgress, setConnectionInProgress] = useState(false);
const isURLHTTPS = location.protocol === "https:"; const isURLHTTPS = location.protocol === "https:";
@ -79,22 +81,24 @@ export const HTTP = (
disabled={connectionInProgress} disabled={connectionInProgress}
> >
<div> <div>
<Label>IP Address/Hostname</Label> <Label>{t("httpConnection.ipAddressLabel")}</Label>
<Input <Input
prefix={tlsValue ? "https://" : "http://"} prefix={tlsValue
placeholder="000.000.000.000 / meshtastic.local" ? `${t("httpConnection.https")}://`
: `${t("httpConnection.http")}://`}
placeholder={t("httpConnection.field_ipAddress_placeholder")}
className="text-slate-900 dark:text-slate-100" className="text-slate-900 dark:text-slate-100"
{...register("ip")} {...register("ip")}
/> />
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="mt-2 flex items-center gap-2">
<Switch <Switch
onCheckedChange={setTLS} onCheckedChange={setTLS}
disabled={isURLHTTPS} disabled={isURLHTTPS}
checked={isURLHTTPS || tlsValue} checked={isURLHTTPS || tlsValue}
{...register("tls")} {...register("tls")}
/> />
<Label>Use HTTPS</Label> <Label>{t("httpConnection.label_useHttps")}</Label>
</div> </div>
{connectionError && ( {connectionError && (
@ -106,30 +110,38 @@ export const HTTP = (
/> />
<div> <div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800"> <p className="text-sm font-medium text-amber-800 dark:text-amber-800">
Connection Failed {t("httpConnection.connectionFailedAlert.title")}
</p> </p>
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700"> <p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
Could not connect to the device. {connectionError.secure && {t("httpConnection.connectionFailedAlert.descriptionPrefix")}
"If using HTTPS, you may need to accept a self-signed certificate first. "} {connectionError.secure &&
Please open{" "} t("httpConnection.connectionFailedAlert.httpsHint")}
{t("httpConnection.connectionFailedAlert.openLinkPrefix")}
<Link <Link
href={`${ href={`${
connectionError.secure ? "https" : "http" connectionError.secure
? t("httpConnection.https")
: t("httpConnection.http")
}://${connectionError.host}`} }://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800" className="underline font-medium text-amber-800 dark:text-amber-800"
> >
{`${ {`${
connectionError.secure ? "https" : "http" connectionError.secure
? t("httpConnection.https")
: t("httpConnection.http")
}://${connectionError.host}`} }://${connectionError.host}`}
</Link>{" "} </Link>{" "}
in a new tab{connectionError.secure {t("httpConnection.connectionFailedAlert.openLinkSuffix")}
? ", accept any TLS warnings if prompted, then try again" {connectionError.secure
? t(
"httpConnection.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}.{" "} : ""}.{" "}
<Link <Link
href="https://meshtastic.org/docs/software/web-client/#http" href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800" className="underline font-medium text-amber-800 dark:text-amber-800"
> >
Learn more {t("httpConnection.connectionFailedAlert.learnMoreLink")}
</Link> </Link>
</p> </p>
</div> </div>
@ -141,7 +153,11 @@ export const HTTP = (
type="submit" type="submit"
variant="default" variant="default"
> >
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span> <span>
{connectionInProgress
? t("httpConnection.button_connecting")
: t("httpConnection.button_connect")}
</span>
</Button> </Button>
</form> </form>
); );

23
src/components/PageComponents/Connect/Serial.tsx

@ -8,6 +8,7 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ( export const Serial = (
@ -18,6 +19,7 @@ export const Serial = (
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore(); const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
const updateSerialPortList = useCallback(async () => { const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator?.serial.getPorts()); setSerialPorts(await navigator?.serial.getPorts());
@ -54,24 +56,31 @@ export const Serial = (
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => { {serialPorts.map((port, index) => {
const { usbProductId, usbVendorId } = port.getInfo(); const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId ?? t("common.unknown");
const product = usbProductId ?? t("common.unknown");
return ( return (
<Button <Button
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`} key={`${vendor}-${product}-${index}`}
disabled={port.readable !== null} disabled={port.readable !== null}
variant="default" variant="default"
onClick={async () => { onClick={async () => {
setConnectionInProgress(true); setConnectionInProgress(true);
await onConnect(port); await onConnect(port);
// No need to setConnectionInProgress(false) here as closeDialog() unmounts.
}} }}
> >
{`# ${index} - ${usbVendorId ?? "UNK"} - ${ {t("serialConnection.deviceIdentifier", {
usbProductId ?? "UNK" index,
}`} vendorId: vendor,
productId: product,
})}
</Button> </Button>
); );
})} })}
{serialPorts.length === 0 && ( {serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono> <Mono className="m-auto select-none">
{t("serialConnection.noDevicesPaired")}
</Mono>
)} )}
</div> </div>
<Button <Button
@ -79,15 +88,15 @@ export const Serial = (
onClick={async () => { onClick={async () => {
await navigator.serial.requestPort().then((port) => { await navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port)); setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error) => { }).catch((error) => {
console.error("Error requesting port:", error); console.error("Error requesting port:", error);
setConnectionInProgress(false);
}).finally(() => { }).finally(() => {
setConnectionInProgress(false); setConnectionInProgress(false);
}); });
}} }}
> >
<span>New device</span> <span>{t("serialConnection.newDeviceButton")}</span>
</Button> </Button>
</fieldset> </fieldset>
); );

76
src/components/PageComponents/Map/NodeDetail.tsx

@ -26,8 +26,9 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { import {
MessageType, MessageType,
useMessageStore, useMessageStore,
} from "../../../core/stores/messageStore/index.ts"; } from "@core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx"; import BatteryStatus from "@components/BatteryStatus.tsx";
import { useTranslation } from "react-i18next";
export interface NodeDetailProps { export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo; node: ProtobufType.Mesh.NodeInfo;
@ -35,13 +36,19 @@ export interface NodeDetailProps {
export const NodeDetail = ({ node }: NodeDetailProps) => { export const NodeDetail = ({ node }: NodeDetailProps) => {
const { setChatType, setActiveChat } = useMessageStore(); const { setChatType, setActiveChat } = useMessageStore();
const { t } = useTranslation();
const { setActivePage } = useDevice(); const { setActivePage } = useDevice();
const name = node.user?.longName ?? `UNK`; const name = node.user?.longName ?? t("common.unknown");
const shortName = node.user?.shortName ?? "UNK"; const shortName = node.user?.shortName ?? t("common.unknown");
const hwModel = node.user?.hwModel ?? 0; const hwModel = node.user?.hwModel ?? 0;
const hardwareType = const rawHardwareType = Protobuf.Mesh.HardwareModel[hwModel] as
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`; | keyof typeof Protobuf.Mesh.HardwareModel
| undefined;
const hardwareType = rawHardwareType
? rawHardwareType === "UNSET"
? t("common.unset")
: rawHardwareType.replaceAll("_", " ")
: `${hwModel}`;
function handleDirectMessage() { function handleDirectMessage() {
setChatType(MessageType.Direct); setChatType(MessageType.Direct);
setActiveChat(node.num); setActiveChat(node.num);
@ -66,7 +73,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
className="text-green-600 mb-1.5" className="text-green-600 mb-1.5"
size={12} size={12}
strokeWidth={3} strokeWidth={3}
aria-label="Public Key Enabled" aria-label={t("node_detail_public_key_enabled_aria_label")}
/> />
) )
: ( : (
@ -74,7 +81,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
className="text-yellow-500 mb-1.5" className="text-yellow-500 mb-1.5"
size={12} size={12}
strokeWidth={3} strokeWidth={3}
aria-label="No Public Key" aria-label={t("node_detail_no_public_key_aria_label")}
/> />
)} )}
@ -94,7 +101,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
align="center" align="center"
sideOffset={5} sideOffset={5}
> >
Direct Message {shortName} {t("node_detail_direct_message_tooltip", {
shortName,
})}
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
@ -103,22 +112,30 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<Star <Star
fill={node.isFavorite ? "black" : "none"} fill={node.isFavorite ? "black" : "none"}
size={15} size={15}
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"} aria-label={node.isFavorite
? t("node_detail_favorite_aria_label")
: t("node_detail_not_favorite_aria_label")}
/> />
</div> </div>
</div> </div>
<div> <div>
<Heading as="h5">{name}</Heading> <Heading as="h5">{name}</Heading>
{hardwareType !== t("common.unset") && <Subtle>{hardwareType}
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>} </Subtle>}
{!!node.deviceMetrics?.batteryLevel && ( {!!node.deviceMetrics?.batteryLevel && (
<BatteryStatus deviceMetrics={node.deviceMetrics} /> <BatteryStatus deviceMetrics={node.deviceMetrics} />
)} )}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{node.user?.shortName && <div>"{node.user?.shortName}"</div>} {node.user?.shortName && (
<div>
{t("node_detail_short_name_display_format", {
name: node.user?.shortName,
})}
</div>
)}
{node.user?.id && <div>{node.user?.id}</div>} {node.user?.id && <div>{node.user?.id}</div>}
</div> </div>
@ -131,13 +148,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div> <div>
{node.lastHeard > 0 && ( {node.lastHeard > 0 && (
<div> <div>
Heard <TimeAgo timestamp={node.lastHeard * 1000} /> {t("node_detail_status_heard")}{" "}
<TimeAgo timestamp={node.lastHeard * 1000} />
</div> </div>
)} )}
</div> </div>
{node.viaMqtt && ( {node.viaMqtt && (
<div style={{ color: "#660066" }} className="font-medium"> <div style={{ color: "#660066" }} className="font-medium">
MQTT {t("node_detail_status_mqtt")}
</div> </div>
)} )}
</div> </div>
@ -149,21 +167,27 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="flex mt-2 text-sm"> <div className="flex mt-2 text-sm">
<div className="flex items-center grow"> <div className="flex items-center grow">
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1"> <div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway} {Number.isNaN(node.hopsAway)
? t("node_detail_hops_unknown")
: node.hopsAway}
</div>
<div>
{node.hopsAway === 1
? t("node_detail_hops_label_one")
: t("node_detail_hops_label_other")}
</div> </div>
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>
</div> </div>
{node.position?.altitude && ( {node.position?.altitude && (
<div className="flex items-center grow"> <div className="flex items-center grow">
<MountainSnow <MountainSnow
size={15} size={15}
className="ml-2 mr-1" className="ml-2 mr-1"
aria-label="Elevation" aria-label={t("node_detail_elevation_aria_label")}
/> />
<div> <div>
{formatQuantity(node.position?.altitude, { {formatQuantity(node.position?.altitude, {
one: "meter", one: t("node_detail_altitude_unit_one"),
other: "meters", other: t("node_detail_altitude_unit_other"),
})} })}
</div> </div>
</div> </div>
@ -173,7 +197,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="flex mt-2"> <div className="flex mt-2">
{!!node.deviceMetrics?.channelUtilization && ( {!!node.deviceMetrics?.channelUtilization && (
<div className="grow"> <div className="grow">
<div>Channel Util</div> <div>{t("node_detail_channel_util_label")}</div>
<Mono> <Mono>
{node.deviceMetrics?.channelUtilization.toPrecision(3)}% {node.deviceMetrics?.channelUtilization.toPrecision(3)}%
</Mono> </Mono>
@ -181,7 +205,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
)} )}
{!!node.deviceMetrics?.airUtilTx && ( {!!node.deviceMetrics?.airUtilTx && (
<div className="grow"> <div className="grow">
<div>Airtime Util</div> <div>{t("node_detail_airtime_util_label")}</div>
<Mono className="text-gray-500"> <Mono className="text-gray-500">
{node.deviceMetrics?.airUtilTx.toPrecision(3)}% {node.deviceMetrics?.airUtilTx.toPrecision(3)}%
</Mono> </Mono>
@ -191,13 +215,15 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{node.snr !== 0 && ( {node.snr !== 0 && (
<div className="mt-2"> <div className="mt-2">
<div>SNR</div> <div>{t("node_detail_snr_label")}</div>
<Mono className="flex items-center text-xs text-gray-500"> <Mono className="flex items-center text-xs text-gray-500">
{node.snr}db {node.snr}
{t("common.dbUnit")}
<Dot /> <Dot />
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}% {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
<Dot /> <Dot />
{(node.snr + 10) * 5}raw {(node.snr + 10) * 5}
{t("common.rawUnit")}
</Mono> </Mono>
</div> </div>
)} )}

10
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,17 +1,21 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
import { Message } from "@core/stores/messageStore/types.ts"; import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
export interface ChannelChatProps { export interface ChannelChatProps {
messages?: Message[]; messages?: Message[];
} }
const EmptyState = () => ( const EmptyState = () => {
const { t } = useTranslation();
return (
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400"> <div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
<InboxIcon className="mb-2 h-8 w-8" /> <InboxIcon className="mb-2 h-8 w-8" />
<span className="text-sm">No Messages</span> <span className="text-sm">{t("messages.empty")}</span>
</div> </div>
); );
};
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
if (!messages?.length) { if (!messages?.length) {

10
src/components/PageComponents/Messages/MessageActionsMenu.tsx

@ -7,6 +7,7 @@ import {
} from "@components/UI/Tooltip.tsx"; } from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Reply, SmilePlus } from "lucide-react"; import { Reply, SmilePlus } from "lucide-react";
import { useTranslation } from "react-i18next";
interface MessageActionsMenuProps { interface MessageActionsMenuProps {
onAddReaction?: () => void; onAddReaction?: () => void;
@ -17,6 +18,7 @@ export const MessageActionsMenu = ({
onAddReaction, onAddReaction,
onReply, onReply,
}: MessageActionsMenuProps) => { }: MessageActionsMenuProps) => {
const { t } = useTranslation();
const hoverIconBarClass = cn( const hoverIconBarClass = cn(
"absolute top-2 right-2", "absolute top-2 right-2",
"flex items-center gap-x-1", "flex items-center gap-x-1",
@ -48,7 +50,7 @@ export const MessageActionsMenu = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
aria-label="Add Reaction" aria-label={t("messages.actionsMenu.addReactionLabel")}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onAddReaction) { if (onAddReaction) {
@ -61,7 +63,7 @@ export const MessageActionsMenu = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs"> <TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
Add Reaction {t("messages.actionsMenu.addReactionLabel")}
<TooltipArrow className="fill-gray-800" /> <TooltipArrow className="fill-gray-800" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -70,7 +72,7 @@ export const MessageActionsMenu = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
aria-label="Reply" aria-label={t("messages.actionsMenu.replyLabel")}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onReply) { if (onReply) {
@ -83,7 +85,7 @@ export const MessageActionsMenu = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs"> <TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
Reply {t("messages.actionsMenu.replyLabel")}
<TooltipArrow className="fill-gray-800" /> <TooltipArrow className="fill-gray-800" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

22
src/components/PageComponents/Messages/TraceRoute.tsx

@ -1,6 +1,7 @@
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface TraceRouteProps { export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo; from?: Protobuf.Mesh.NodeInfo;
@ -23,6 +24,7 @@ const RoutePath = (
{ title, startNode, endNode, path, snr }: RoutePathProps, { title, startNode, endNode, path, snr }: RoutePathProps,
) => { ) => {
const { getNode } = useDevice(); const { getNode } = useDevice();
const { t } = useTranslation();
return ( return (
<span <span
@ -31,13 +33,20 @@ const RoutePath = (
> >
<p className="font-semibold">{title}</p> <p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p> <p>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p> <p>
{snr?.[0] ?? t("traceRoute_snrUnknown")}
{t("common.dbUnit")}
</p>
{path.map((hop, i) => ( {path.map((hop, i) => (
<span key={getNode(hop)?.num ?? hop}> <span key={getNode(hop)?.num ?? hop}>
<p> <p>
{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} {getNode(hop)?.user?.longName ??
`${t("traceRoute_nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`}
</p>
<p>
{snr?.[i + 1] ?? t("traceRoute_snrUnknown")}
{t("common.dbUnit")}
</p> </p>
<p> {snr?.[i + 1] ?? "??"}dB</p>
</span> </span>
))} ))}
<p>{endNode?.user?.longName}</p> <p>{endNode?.user?.longName}</p>
@ -53,18 +62,19 @@ export const TraceRoute = ({
snrTowards, snrTowards,
snrBack, snrBack,
}: TraceRouteProps) => { }: TraceRouteProps) => {
const { t } = useTranslation();
return ( return (
<div className="ml-5 flex"> <div className="ml-5 flex">
<RoutePath <RoutePath
title="Route to destination:" title={t("traceRoute_routeToDestinationTitle")}
startNode={to} startNode={to}
endNode={from} endNode={from}
path={route} path={route}
snr={snrTowards} snr={snrTowards}
/> />
{routeBack && ( {routeBack && routeBack.length > 0 && (
<RoutePath <RoutePath
title="Route back:" title={t("traceRoute_routeBackTitle")}
startNode={from} startNode={from}
endNode={to} endNode={to}
path={routeBack} path={routeBack}

2
src/components/PageComponents/ModuleConfig/AmbientLighting.tsx

@ -1,5 +1,5 @@
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.tsx"; import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";

53
src/components/Sidebar.tsx

@ -25,6 +25,7 @@ import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx"; import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useTranslation } from "react-i18next"; // Import useTranslation
export interface SidebarProps { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -39,7 +40,10 @@ interface NavLink {
const CollapseToggleButton = () => { const CollapseToggleButton = () => {
const { isCollapsed, toggleSidebar } = useSidebar(); const { isCollapsed, toggleSidebar } = useSidebar();
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar"; const { t } = useTranslation();
const buttonLabel = isCollapsed
? t("sidebar.collapseToggle.button_open")
: t("sidebar.collapseToggle.button_close");
return ( return (
<button <button
@ -79,22 +83,33 @@ export const Sidebar = ({ children }: SidebarProps) => {
const { setCommandPaletteOpen } = useAppStore(); const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum); const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { t } = useTranslation();
const myMetadata = metadata.get(0); const myMetadata = metadata.get(0);
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0); const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
const pages: NavLink[] = [ const pages: NavLink[] = [
{ {
name: "Messages", name: t("navigation.title_messages"),
icon: MessageSquareIcon, icon: MessageSquareIcon,
page: "messages", page: "messages",
count: numUnread ? numUnread : undefined, count: numUnread ? numUnread : undefined,
}, },
{ name: "Map", icon: MapIcon, page: "map" }, { name: t("navigation.title_map"), icon: MapIcon, page: "map" },
{ name: "Config", icon: SettingsIcon, page: "config" },
{ name: "Channels", icon: LayersIcon, page: "channels" },
{ {
name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`, name: t("navigation.title_radioConfig"),
icon: SettingsIcon,
page: "config",
},
{
name: t("navigation.title_channels"),
icon: LayersIcon,
page: "channels",
},
{
name: `${t("navigation.title_nodes")} (${
Math.max(getNodesLength() - 1, 0)
})`,
icon: UsersIcon, icon: UsersIcon,
page: "nodes", page: "nodes",
}, },
@ -131,11 +146,11 @@ export const Sidebar = ({ children }: SidebarProps) => {
: "opacity-100 max-w-xs visible ml-2", : "opacity-100 max-w-xs visible ml-2",
)} )}
> >
Meshtastic {t("common.header")}
</h2> </h2>
</div> </div>
<SidebarSection label="Navigation" className="mt-4 px-0"> <SidebarSection label={t("navigation.title")} className="mt-4 px-0">
{pages.map((link) => ( {pages.map((link) => (
<SidebarButton <SidebarButton
key={link.name} key={link.name}
@ -173,7 +188,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible", isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)} )}
> >
Loading... {t("common.loading")}
</Subtle> </Subtle>
</div> </div>
) )
@ -221,9 +236,10 @@ export const Sidebar = ({ children }: SidebarProps) => {
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/> />
<Subtle> <Subtle>
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} {t("sidebar.deviceInfo.volts", {
{" "} voltage: myNode.deviceMetrics?.voltage?.toPrecision(3) ??
volts t("common.unknown"),
})}
</Subtle> </Subtle>
</div> </div>
<div className="inline-flex gap-2"> <div className="inline-flex gap-2">
@ -231,7 +247,12 @@ export const Sidebar = ({ children }: SidebarProps) => {
size={18} size={18}
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/> />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle> <Subtle>
{t("sidebar.deviceInfo.firmwareVersion", {
version: myMetadata?.firmwareVersion ??
t("common.unknown"),
})}
</Subtle>
</div> </div>
</div> </div>
<div <div
@ -245,8 +266,8 @@ export const Sidebar = ({ children }: SidebarProps) => {
> >
<button <button
type="button" type="button"
aria-label="Edit device name" aria-label={t("sidebar.deviceInfo.button_editDeviceName")}
className="p-1 rounded transition-colors hover:text-accent" className="p-1 rounded transition-colors cursor-pointer hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)} onClick={() => setDialogOpen("deviceName", true)}
> >
<PenLine size={22} /> <PenLine size={22} />
@ -254,7 +275,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
<ThemeSwitcher /> <ThemeSwitcher />
<button <button
type="button" type="button"
className="transition-all hover:text-accent" className="transition-all cursor-pointer hover:text-accent"
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
> >
<SearchIcon /> <SearchIcon />

22
src/components/UI/Input.tsx

@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react"; import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts"; import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useTranslation } from "react-i18next";
const inputVariants = cva( const inputVariants = cva(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600", "flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600",
@ -62,6 +63,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
) => { ) => {
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle(); const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 }); const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
const { t } = useTranslation();
const potentialActions: InputActionType[] = [ const potentialActions: InputActionType[] = [
{ {
@ -80,8 +82,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref.current.focus(); ref.current.focus();
} }
}, },
ariaLabel: "Clear input", ariaLabel: t("input_action_clearInput_label"),
tooltip: "Clear input", tooltip: t("input_action_clearInput_label"),
condition: !!showClearButton && !!value, condition: !!showClearButton && !!value,
}, },
{ {
@ -91,8 +93,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
e.stopPropagation(); e.stopPropagation();
toggleVisibility(); toggleVisibility();
}, },
ariaLabel: isVisible ? "Hide password" : "Show password", ariaLabel: isVisible
tooltip: isVisible ? "Hide password" : "Show password", ? t("input_action_hidePassword_label")
: t("input_action_showPassword_label"),
tooltip: isVisible
? t("input_action_hidePassword_label")
: t("input_action_showPassword_label"),
condition: !!showPasswordToggle && type === "password", condition: !!showPasswordToggle && type === "password",
}, },
{ {
@ -104,8 +110,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
copy(String(value)); copy(String(value));
} }
}, },
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard", ariaLabel: isCopied
tooltip: isCopied ? "Copied!" : "Copy to clipboard", ? t("input_action_copied_label")
: t("input_action_copyToClipboard_label"),
tooltip: isCopied
? t("input_action_copied_label")
: t("input_action_copyToClipboard_label"),
condition: !!showCopyButton, condition: !!showCopyButton,
}, },
]; ];

86
src/components/generic/Filter/FilterControl.tsx

@ -26,6 +26,7 @@ import {
FilterSlider, FilterSlider,
FilterToggle, FilterToggle,
} from "@components/generic/Filter/FilterComponents.tsx"; } from "@components/generic/Filter/FilterComponents.tsx";
import { useTranslation } from "react-i18next";
type PopoverContentProps = ComponentProps<typeof PopoverContent>; type PopoverContentProps = ComponentProps<typeof PopoverContent>;
@ -52,6 +53,7 @@ export function FilterControl({
parameters, parameters,
children, children,
}: FilterControlProps) { }: FilterControlProps) {
const { t } = useTranslation();
// Copy of the state that we only use for rendering sliders and their labels directly, rest is debounced // Copy of the state that we only use for rendering sliders and their labels directly, rest is debounced
const [localFilterState, setLocalFilterState] = useState(filterState); const [localFilterState, setLocalFilterState] = useState(filterState);
const skipNextSync = useRef(false); const skipNextSync = useRef(false);
@ -130,7 +132,7 @@ export function FilterControl({
: "", : "",
parameters?.popoverTriggerClassName, parameters?.popoverTriggerClassName,
)} )}
aria-label="Filter" aria-label={t("filter_control_button_filter_ariaLabel")}
> >
{parameters?.triggerIcon ?? <FunnelIcon />} {parameters?.triggerIcon ?? <FunnelIcon />}
</button> </button>
@ -145,54 +147,63 @@ export function FilterControl({
<form className="space-y-4"> <form className="space-y-4">
<Accordion <Accordion
type="single" type="single"
defaultValue="General" defaultValue={t("filter_control_accordion_general_label")}
collapsible collapsible
> >
<FilterAccordionItem label="General"> <FilterAccordionItem
label={t("filter_control_accordion_general_label")}
>
{(parameters?.showTextSearch ?? true) && ( {(parameters?.showTextSearch ?? true) && (
<div className="flex flex-col space-y-1 pb-2"> <div className="flex flex-col space-y-1 pb-2">
<label htmlFor="nodeName" className="font-medium text-sm"> <label htmlFor="nodeName" className="font-medium text-sm">
Node name/number {t("filter_control_input_nodeName_label")}
</label> </label>
<Input <Input
type="text" type="text"
value={filterState.nodeName} value={filterState.nodeName}
onChange={handleTextChange("nodeName")} onChange={handleTextChange("nodeName")}
showClearButton showClearButton
placeholder="Meshtastic 1234" placeholder={t("filter_control_input_nodeName_placeholder")}
/> />
</div> </div>
)} )}
<FilterSlider <FilterSlider
label="Number of hops" label={t("filter_control_slider_hops_label")}
filterKey="hopsAway" filterKey="hopsAway"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
labelContent={ labelContent={
<> <>
Number of hops: {localFilterState.hopsAway[0] === 0 {t("filter_control_slider_hops_labelText", {
? "Direct" value: localFilterState.hopsAway[0] === 0
: localFilterState.hopsAway[0]} ? t("filter_control_slider_hops_directLabel")
: localFilterState.hopsAway[0],
})}
{localFilterState.hopsAway[0] !== {localFilterState.hopsAway[0] !==
localFilterState.hopsAway[1] localFilterState.hopsAway[1]
? " — " + localFilterState.hopsAway[1] ? `${localFilterState.hopsAway[1]}`
: ""} : ""}
</> </>
} }
/> />
<FilterSlider <FilterSlider
label="Last heard" label={t("filter_control_slider_lastHeard_label")}
filterKey="lastHeard" filterKey="lastHeard"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
labelContent={ labelContent={
<> <>
Last heard: <br /> {t("filter_control_slider_lastHeard_labelText", {
{localFilterState.lastHeard[0] === 0 ? "Now" : ( value: "",
})}
<br />
{localFilterState.lastHeard[0] === 0
? t("filter_control_slider_lastHeard_nowLabel")
: (
<> <>
{localFilterState.lastHeard[0] === {localFilterState.lastHeard[0] ===
defaultFilterValues.lastHeard[1] && ">"} defaultFilterValues.lastHeard[1] && ">"}
@ -212,60 +223,70 @@ export function FilterControl({
} }
/> />
<FilterToggle <FilterToggle
label="Favorites" label={t("filter_control_toggle_favorites_label")}
filterKey="isFavorite" filterKey="isFavorite"
alternativeLabels={["Hide", "Show Only"]} alternativeLabels={[
t("filter_control_toggle_hide_label"),
t("filter_control_toggle_showOnly_label"),
]}
filterState={filterState} filterState={filterState}
onChange={handleBoolChange} onChange={handleBoolChange}
/> />
<FilterToggle <FilterToggle
label="Connected via MQTT" label={t("filter_control_toggle_viaMqtt_label")}
filterKey="viaMqtt" filterKey="viaMqtt"
alternativeLabels={["Hide", "Show Only"]} alternativeLabels={[
t("filter_control_toggle_hide_label"),
t("filter_control_toggle_showOnly_label"),
]}
filterState={filterState} filterState={filterState}
onChange={handleBoolChange} onChange={handleBoolChange}
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label="Metrics"> <FilterAccordionItem
label={t("filter_control_accordion_metrics_label")}
>
<FilterSlider <FilterSlider
label="SNR (db)" label={t("filter_control_slider_snr_label")}
filterKey="snr" filterKey="snr"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label="Channel Utilization (%)" label={t("filter_control_slider_channelUtilization_label")}
filterKey="channelUtilization" filterKey="channelUtilization"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label="Airtime Utilization (%)" label={t("filter_control_slider_airtimeUtilization_label")}
filterKey="airUtilTx" filterKey="airUtilTx"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label="Battery level (%)" label={t("filter_control_slider_batteryLevel_label")}
filterKey="batteryLevel" filterKey="batteryLevel"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
labelContent={ labelContent={
<> <>
Battery level (%): {localFilterState.batteryLevel[0] === 101 {t("filter_control_slider_batteryLevel_labelText", {
? "Plugged in" value: localFilterState.batteryLevel[0] === 101
: localFilterState.batteryLevel[0]} ? t("common.batteryStatus.pluggedIn")
: localFilterState.batteryLevel[0],
})}
{localFilterState.batteryLevel[0] !== {localFilterState.batteryLevel[0] !==
localFilterState.batteryLevel[1] && ( localFilterState.batteryLevel[1] && (
<> <>
{" – "} {" – "}
{localFilterState.batteryLevel[1] === 101 {localFilterState.batteryLevel[1] === 101
? "Plugged in" ? t("common.batteryStatus.pluggedIn")
: localFilterState.batteryLevel[1]} : localFilterState.batteryLevel[1]}
</> </>
)} )}
@ -273,15 +294,16 @@ export function FilterControl({
} }
/> />
<FilterSlider <FilterSlider
label="Battery voltage (V)" label={t("filter_control_slider_batteryVoltage_label")}
filterKey="voltage" filterKey="voltage"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem
<FilterAccordionItem label="Role"> label={t("filter_control_accordion_role_label")}
>
<FilterMulti <FilterMulti
filterKey="role" filterKey="role"
filterState={filterState} filterState={filterState}
@ -296,7 +318,9 @@ export function FilterControl({
)} )}
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label="Hardware"> <FilterAccordionItem
label={t("filter_control_accordion_hardware_label")}
>
<FilterMulti <FilterMulti
filterKey="hwModel" filterKey="hwModel"
filterState={filterState} filterState={filterState}
@ -315,7 +339,7 @@ export function FilterControl({
onClick={resetFilters} onClick={resetFilters}
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950" className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950"
> >
Reset Filters {t("filter_control_button_resetFilters_label")}
</button> </button>
{children && ( {children && (
<div className="mt-4 border-t pt-4"> <div className="mt-4 border-t pt-4">

28
src/i18n.ts

@ -0,0 +1,28 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
export const supportedLngs = {
en: "English",
};
i18next
.use(Backend)
.use(initReactI18next)
.use(LanguageDetector)
.init({
backend: {
// this will lazy load resources from the i8n folder
loadPath: "/src/i18n/locales/{{lng}}.json",
},
react: {
useSuspense: true,
},
fallbackLng: "en",
debug: !!import.meta.env.DEV,
supportedLngs: Object.keys(supportedLngs),
detection: {
order: ["navigator", "localStorage"],
},
});

576
src/i18n/locales/en.json

@ -0,0 +1,576 @@
{
"common_title": "Meshtastic Web",
"common_header": "Meshtastic",
"common_description": "Meshtastic Web Client",
"common_button_close": "Close",
"common_button_ok": "OK",
"common_button_cancel": "Cancel",
"common_button_delete": "Delete",
"common_button_import": "Import",
"common_button_export": "Export",
"common_button_scanQr": "Scan QR Code",
"common_button_generate": "Generate",
"common_button_save": "Save",
"common_label_primary": "Primary",
"common_loading": "Loading...",
"common_unknown": "UNK",
"common_unset": "UNSET",
"common_unit_volts": "Volts",
"common_unit_cps": "CPS",
"common_unit_dbm": "dBm",
"common_unit_hertz": "Hz",
"common_unit_megahertz": "MHz",
"common_unit_seconds": "Seconds",
"common_unit_raw": "raw",
"common_batteryStatus_title": "Battery",
"common_batteryStatus_pluggedIn": "Plugged in",
"common_batteryStatus_charging": "{{level}}% charging",
"navigation_title": "Navigation",
"navigation_title_messages": "Messages",
"navigation_title_map": "Map",
"navigation_title_radioConfig": "Radio Config",
"navigation_title_moduleConfig": "Module Config",
"navigation_title_channels": "Channels",
"navigation_title_nodes": "Nodes",
"dialog_button_apply": "Apply",
"dialog_button_clearMessages": "Clear Messages",
"dialog_button_confirm": "Confirm",
"dialog_button_dismiss": "Dismiss",
"dialog_button_download": "Download",
"dialog_button_message": "Message",
"dialog_button_now": "Now",
"dialog_button_print": "Print",
"dialog_button_rebootOtaNow": "Reboot to OTA Mode Now",
"dialog_button_remove": "Remove",
"dialog_button_requestNewKeys": "Request New Keys",
"dialog_button_requestPosition": "Request Position",
"dialog_button_reset": "Reset",
"dialog_button_traceRoute": "Trace Route",
"dialog_deleteMessages_description": "This action will clear all message history. This cannot be undone. Are you sure you want to continue?",
"dialog_deleteMessages_title": "Clear All Messages",
"dialog_deviceName_description": "The Device will restart once the config is saved.",
"dialog_deviceName_label_longName": "Long Name",
"dialog_deviceName_label_shortName": "Short Name",
"dialog_deviceName_title": "Change Device Name",
"dialog_import_description": "The current LoRa configuration will be overridden.",
"dialog_import_error_invalidUrl": "Invalid Meshtastic URL",
"dialog_import_label_channelPrefix": "Channel: ",
"dialog_import_label_channelSetUrl": "Channel Set/QR Code URL",
"dialog_import_label_channels": "Channels:",
"dialog_import_label_usePreset": "Use Preset?",
"dialog_import_title": "Import Channel Set",
"dialog_locationResponse_label_altitude": "Altitude: ",
"dialog_locationResponse_label_coordinates": "Coordinates: ",
"dialog_locationResponse_titlePrefix": "Location: ",
"dialog_locationResponse_unit_meter": "m",
"dialog_pkiRegenerateDialog_title": "Regenerate Pre-Shared Key?",
"dialog_pkiRegenerateDialog_description": "Are you sure you want to regenerate the pre-shared key?",
"dialog_pkiRegenerateDialog_buttonRegenerate": "Regenerate",
"dialog_newDeviceDialog_title": "Connect New Device",
"dialog_newDeviceDialog_tabLabelHttp": "HTTP",
"dialog_newDeviceDialog_tabLabelBluetooth": "Bluetooth",
"dialog_newDeviceDialog_tabLabelSerial": "Serial",
"dialog_newDeviceDialog_errorMessage_requiresFeatures": "This connection type requires <0></0>. Please use a supported browser, like Chrome or Edge.",
"dialog_newDeviceDialog_errorMessage_requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"dialog_newDeviceDialog_errorMessage_additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"dialog_nodeDetails_button_message": "Message",
"dialog_nodeDetails_button_requestPosition": "Request Position",
"dialog_nodeDetails_button_traceRoute": "Trace Route",
"dialog_nodeDetails_label_airTxUtilization": "Air TX utilization",
"dialog_nodeDetails_label_allRawMetrics": "All Raw Metrics:",
"dialog_nodeDetails_label_batteryLevel": "Battery level",
"dialog_nodeDetails_label_channelUtilization": "Channel utilization",
"dialog_nodeDetails_label_details": "Details:",
"dialog_nodeDetails_label_deviceMetrics": "Device Metrics:",
"dialog_nodeDetails_label_hardware": "Hardware: ",
"dialog_nodeDetails_label_lastHeard": "Last Heard: ",
"dialog_nodeDetails_label_nodeHexPrefix": "Node Hex: !",
"dialog_nodeDetails_label_nodeNumber": "Node Number: ",
"dialog_nodeDetails_label_position": "Position:",
"dialog_nodeDetails_label_role": "Role: ",
"dialog_nodeDetails_label_uptime": "Uptime: ",
"dialog_nodeDetails_label_voltage": "Voltage",
"dialog_nodeDetails_titlePrefix": "Node Details for ",
"dialog_nodeDetails_tooltip_ignoreNode": "Ignore node",
"dialog_nodeDetails_tooltip_removeNode": "Remove node",
"dialog_nodeDetails_tooltip_unignoreNode": "Unignore node",
"dialog_pkiBackup_message": "We recommend backing up your key data regularly. Would you like to back up now?",
"dialog_pkiBackup_description_loseKeysWarning": "If you lose your keys, you will need to reset your device.",
"dialog_pkiBackup_description_secureBackup": "Its important to backup your public and private keys and store your backup securely!",
"dialog_pkiBackup_print_footer": "=== END OF KEYS ===",
"dialog_pkiBackup_print_header": "=== MESHTASTIC KEYS ===",
"dialog_pkiBackup_print_label_privateKey": "Private Key:",
"dialog_pkiBackup_print_label_publicKey": "Public Key:",
"dialog_pkiBackup_title": "Backup Keys",
"dialog_pkiRegenerate_description_keyPair": "Are you sure you want to regenerate key pair?",
"dialog_pkiRegenerate_title_keyPair": "Regenerate Key Pair",
"dialog_qr_button_addChannels": "Add Channels",
"dialog_qr_button_replaceChannels": "Replace Channels",
"dialog_qr_description": "The current LoRa configuration will also be shared.",
"dialog_qr_label_sharableUrl": "Sharable URL",
"dialog_qr_title": "Generate QR Code",
"dialog_rebootOta_button_now": "Reboot to OTA Mode Now",
"dialog_rebootOta_description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.",
"dialog_rebootOta_placeholder_enterDelay": "Enter delay (sec)",
"dialog_rebootOta_status_scheduled": "Reboot has been scheduled",
"dialog_reboot_description": "Reboot the connected node after x minutes.",
"dialog_refreshKeys_button_requestNewKeys": "Request New Keys",
"dialog_refreshKeys_description_acceptNewKeys": "This will remove the node from device and request new keys.",
"dialog_refreshKeys_description_keyMismatchReasonSuffix": ". This is due to the remote node's current public key does not match the previously stored key for this node.",
"dialog_refreshKeys_description_unableToSendDmPrefix": "Your node is unable to send a direct message to node: ",
"dialog_refreshKeys_label_acceptNewKeys": "Accept New Keys",
"dialog_refreshKeys_titlePrefix": "Keys Mismatch - ",
"dialog_removeNode_description": "Are you sure you want to remove this Node?",
"dialog_removeNode_title": "Remove Node?",
"dialog_shutdown_description": "Turn off the connected node after x minutes.",
"dialog_shutdown_suffix_minutes": "Minutes",
"dialog_tracerouteResponse_titlePrefix": "Traceroute: ",
"dialog_unsafeRoles_checkbox_confirmUnderstanding": "Yes, I know what I'm doing",
"dialog_unsafeRoles_description_conjunction": " and the blog post about ",
"dialog_unsafeRoles_description_postamble": " and understand the implications of changing the role.",
"dialog_unsafeRoles_description_preamble": "I have read the ",
"dialog_unsafeRoles_link_choosingRightDeviceRole": "Choosing The Right Device Role",
"dialog_unsafeRoles_link_deviceRoleDocumentation": "Device Role Documentation",
"dialog_unsafeRoles_title": "Are you sure?",
"dashboard_connectedDevicesSection_title": "Connected Devices",
"dashboard_connectedDevicesSection_description": "Manage your connected Meshtastic devices.",
"dashboard_connectedDevicesSection_connectionType_ble": "BLE",
"dashboard_connectedDevicesSection_connectionType_serial": "Serial",
"dashboard_connectedDevicesSection_connectionType_network": "Network",
"dashboard_connectedDevicesSection_noDevicesTitle": "No devices connected",
"dashboard_connectedDevicesSection_noDevicesDescription": "Connect a new device to get started.",
"dashboard_connectedDevicesSection_button_newConnection": "New Connection",
"sidebar_collapseToggle_button_open": "Open sidebar",
"sidebar_collapseToggle_button_close": "Close sidebar",
"sidebar_deviceInfo_volts": "{{voltage}} volts",
"sidebar_deviceInfo_firmwareVersion": "v{{version}}",
"sidebar_deviceInfo_button_editDeviceName": "Edit device name",
"messages_empty": "No messages yet.",
"messages_selectChatPrompt": "Select a channel or node to start messaging.",
"messages_channelsSection_title": "Channels",
"messages_channelsSection_primaryChannelName": "Primary",
"messages_channelsSection_channelName": "Ch {{index}}",
"messages_nodesSection_searchPlaceholder": "Search nodes...",
"messages_title": "Messages: {{chatName}}",
"messages_title_default": "Select a Chat",
"messages_sendMessagePrompt": "Select a chat to send a message.",
"messages_actionsMenu_addReactionLabel": "Add Reaction",
"messages_actionsMenu_replyLabel": "Reply",
"channel_page_title": "Channel: {{channelName}}",
"channel_name_primary": "Primary",
"channel_name_prefix": "Ch {{index}}",
"channel_form_toast_saved": "Saved Channel: {{channelName}}",
"channel_form_validation_psk_invalid": "Please enter a valid {{bits}} bit PSK.",
"channel_form_field_group_settings_label": "Channel Settings",
"channel_form_field_group_settings_description": "Crypto, MQTT & misc settings",
"channel_form_field_role_label": "Role",
"channel_form_field_role_description": "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
"channel_form_field_role_option_primary": "PRIMARY",
"channel_form_field_role_option_disabled": "DISABLED",
"channel_form_field_role_option_secondary": "SECONDARY",
"channel_form_field_psk_label": "Pre-Shared Key",
"channel_form_field_psk_description": "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
"channel_form_field_psk_generate_button": "Generate",
"channel_form_field_name_label": "Name",
"channel_form_field_name_description": "A unique name for the channel <12 bytes, leave blank for default",
"channel_form_field_uplink_enabled_label": "Uplink Enabled",
"channel_form_field_uplink_enabled_description": "Send messages from the local mesh to MQTT",
"channel_form_field_downlink_enabled_label": "Downlink Enabled",
"channel_form_field_downlink_enabled_description": "Send messages from MQTT to the local mesh",
"channel_form_field_position_precision_label": "Location",
"channel_form_field_position_precision_description": "The precision of the location to share with the channel. Can be disabled.",
"channel_form_field_position_precision_option_none": "Do not share location",
"channel_form_field_position_precision_option_precise": "Precise Location",
"channel_form_field_position_precision_option_metric_km23": "Within 23 kilometers",
"channel_form_field_position_precision_option_metric_km12": "Within 12 kilometers",
"channel_form_field_position_precision_option_metric_km5_8": "Within 5.8 kilometers",
"channel_form_field_position_precision_option_metric_km2_9": "Within 2.9 kilometers",
"channel_form_field_position_precision_option_metric_km1_5": "Within 1.5 kilometers",
"channel_form_field_position_precision_option_metric_m700": "Within 700 meters",
"channel_form_field_position_precision_option_metric_m350": "Within 350 meters",
"channel_form_field_position_precision_option_metric_m200": "Within 200 meters",
"channel_form_field_position_precision_option_metric_m90": "Within 90 meters",
"channel_form_field_position_precision_option_metric_m50": "Within 50 meters",
"channel_form_field_position_precision_option_imperial_mi15": "Within 15 miles",
"channel_form_field_position_precision_option_imperial_mi7_3": "Within 7.3 miles",
"channel_form_field_position_precision_option_imperial_mi3_6": "Within 3.6 miles",
"channel_form_field_position_precision_option_imperial_mi1_8": "Within 1.8 miles",
"channel_form_field_position_precision_option_imperial_mi0_9": "Within 0.9 miles",
"channel_form_field_position_precision_option_imperial_mi0_5": "Within 0.5 miles",
"channel_form_field_position_precision_option_imperial_mi0_2": "Within 0.2 miles",
"channel_form_field_position_precision_option_imperial_ft600": "Within 600 feet",
"channel_form_field_position_precision_option_imperial_ft300": "Within 300 feet",
"channel_form_field_position_precision_option_imperial_ft150": "Within 150 feet",
"httpConnection_ipAddressLabel": "IP Address/Hostname",
"httpConnection_field_ipAddress_placeholder": "000.000.000.000 / meshtastic.local",
"httpConnection_label_useHttps": "Use HTTPS",
"httpConnection_button_connecting": "Connecting...",
"httpConnection_button_connect": "Connect",
"httpConnection_http": "http",
"httpConnection_https": "https",
"httpConnection_connectionFailedAlert_title": "Connection Failed",
"httpConnection_connectionFailedAlert_descriptionPrefix": "Could not connect to the device. ",
"httpConnection_connectionFailedAlert_httpsHint": "If using HTTPS, you may need to accept a self-signed certificate first. ",
"httpConnection_connectionFailedAlert_openLinkPrefix": "Please open ",
"httpConnection_connectionFailedAlert_openLinkSuffix": " in a new tab",
"httpConnection_connectionFailedAlert_acceptTlsWarningSuffix": ", accept any TLS warnings if prompted, then try again",
"httpConnection_connectionFailedAlert_learnMoreLink": "Learn more",
"serialConnection_noDevicesPaired": "No devices paired yet.",
"serialConnection_newDeviceButton": "New device",
"serialConnection_deviceIdentifier": "# {{index}} - {{vendorId}} - {{productId}}",
"serialConnection_connecting": "Connecting...",
"bluetoothConnection_noDevicesPaired": "No devices paired yet.",
"bluetoothConnection_newDeviceButton": "New device",
"toast_messages_pkiEncryption": "Chat is using PKI encryption.",
"toast_messages_pskEncryption": "Chat is using PSK encryption.",
"toast_positionRequestSent": "Position request sent.",
"toast_requestingPosition": "Requesting position, please wait...",
"toast_sendingTraceroute": "Sending Traceroute, please wait...",
"toast_tracerouteSent": "Traceroute sent.",
"node_detail_public_key_enabled_aria_label": "Public Key Enabled",
"node_detail_no_public_key_aria_label": "No Public Key",
"node_detail_direct_message_tooltip": "Direct Message {{shortName}}",
"node_detail_favorite_aria_label": "Favorite",
"node_detail_not_favorite_aria_label": "Not a Favorite",
"node_detail_status_heard": "Heard",
"node_detail_status_mqtt": "MQTT",
"node_detail_hops_label_one": "Hop",
"node_detail_hops_label_other": "Hops",
"node_detail_hops_unknown": "?",
"node_detail_elevation_aria_label": "Elevation",
"node_detail_altitude_unit_one": "meter",
"node_detail_altitude_unit_other": "meters",
"node_detail_channel_util_label": "Channel Util",
"node_detail_airtime_util_label": "Airtime Util",
"node_detail_snr_label": "SNR",
"node_detail_short_name_display_format": "\"{name}\"",
"traceRoute_routeToDestinationTitle": "Route to destination:",
"traceRoute_routeBackTitle": "Route back:",
"traceRoute_snrUnknown": "??",
"traceRoute_nodeUnknownPrefix": "!",
"nodes_searchPlaceholder": "Search nodes...",
"nodes_table_headings_longName": "Long Name",
"nodes_table_headings_connection": "Connection",
"nodes_table_headings_lastHeard": "Last Heard",
"nodes_table_headings_encryption": "Encryption",
"nodes_table_headings_model": "Model",
"nodes_table_headings_macAddress": "MAC Address",
"nodes_table_connectionStatus_direct": "Direct",
"nodes_table_connectionStatus_hopsAway_one": "{{count}} hop away",
"nodes_table_connectionStatus_hopsAway_other": "{{count}} hops away",
"nodes_table_lastHeardStatus_never": "Never",
"nodes_table_connectionStatus_away": "away",
"nodes_table_connectionStatus_hops_one": "hop",
"nodes_table_connectionStatus_hops_other": "hops",
"nodes_table_connectionStatus_unknown": "-",
"nodes_table_connectionStatus_viaMqtt": ", via MQTT",
"command_palette_input_placeholder": "Type a command or search...",
"command_palette_empty_message": "No results found.",
"command_palette_pin_group_aria_label": "Pin command group",
"command_palette_unpin_group_aria_label": "Unpin command group",
"command_palette_goto_label": "Goto",
"command_palette_goto_command_messages": "Messages",
"command_palette_goto_command_map": "Map",
"command_palette_goto_command_config": "Config",
"command_palette_goto_command_channels": "Channels",
"command_palette_goto_command_nodes": "Nodes",
"command_palette_manage_label": "Manage",
"command_palette_manage_command_switch_node": "Switch Node",
"command_palette_manage_command_connect_new_node": "Connect New Node",
"command_palette_contextual_label": "Contextual",
"command_palette_contextual_command_qr_code": "QR Code",
"command_palette_contextual_command_qr_generator": "Generator",
"command_palette_contextual_command_qr_import": "Import",
"command_palette_contextual_command_schedule_shutdown": "Schedule Shutdown",
"command_palette_contextual_command_schedule_reboot": "Schedule Reboot",
"command_palette_contextual_command_reboot_to_ota_mode": "Reboot To OTA Mode",
"command_palette_contextual_command_reset_nodes": "Reset Nodes",
"command_palette_contextual_command_factory_reset_device": "Factory Reset Device",
"command_palette_contextual_command_factory_reset_config": "Factory Reset Config",
"command_palette_debug_label": "Debug",
"command_palette_debug_command_reconfigure": "Reconfigure",
"command_palette_debug_command_clear_all_stored_messages": "Clear All Stored Message",
"config_page_title_configuration": "Configuration",
"config_device_tab_bluetooth": "Bluetooth",
"config_device_tab_device": "Device",
"config_device_tab_display": "Display",
"config_device_tab_lora": "LoRa",
"config_device_tab_network": "Network",
"config_device_tab_position": "Position",
"config_device_tab_power": "Power",
"config_device_tab_security": "Security",
"config_module_tab_ambientLighting": "Ambient Lighting",
"config_module_tab_audio": "Audio",
"config_module_tab_cannedMessage": "Canned",
"config_module_tab_detectionSensor": "Detection Sensor",
"config_module_tab_externalNotification": "Ext Notif",
"config_module_tab_mqtt": "MQTT",
"config_module_tab_neighborInfo": "Neighbor Info",
"config_module_tab_paxcounter": "Paxcounter",
"config_module_tab_rangeTest": "Range Test",
"config_module_tab_serial": "Serial",
"config_module_tab_storeAndForward": "S&F",
"config_module_tab_telemetry": "Telemetry",
"config_sidebar_section_modules_label": "Modules",
"config_toast_errorSaving_description": "An error occurred while saving the configuration.",
"config_toast_errorSaving_title": "Error Saving Config",
"config_toast_errorsExist_description": "Please fix the configuration errors before saving.",
"config_toast_errorsExist_title": "Config Errors Exist",
"config_toast_saving_description": "The configuration change {{case}} has been saved.",
"config_toast_saving_title": "Saving Config",
"config_bluetooth_fieldDescription_enabled": "Enable or disable Bluetooth",
"config_bluetooth_fieldDescription_pairingMode": "Pin selection behaviour.",
"config_bluetooth_fieldDescription_pin": "Pin to use when pairing",
"config_bluetooth_fieldLabel_enabled": "Enabled",
"config_bluetooth_fieldLabel_pairingMode": "Pairing mode",
"config_bluetooth_fieldLabel_pin": "Pin",
"config_bluetooth_groupDescription_bluetoothSettings": "Settings for the Bluetooth module ",
"config_bluetooth_groupLabel_bluetoothSettings": "Bluetooth Settings",
"config_bluetooth_groupNotes_bluetoothWifiNote": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
"config_bluetooth_validation_pinCannotStartWithZero": "Bluetooth Pin cannot start with 0",
"config_bluetooth_validation_pinMustBeSixDigits": "Pin must be 6 digits",
"config_bluetooth_validation_pinRequired": "Bluetooth Pin is required",
"config_device_fieldDescription_buttonPin": "Button pin override",
"config_device_fieldDescription_buzzerPin": "Buzzer pin override",
"config_device_fieldDescription_disableTripleClick": "Disable triple click",
"config_device_fieldDescription_doubleTapAsButtonPress": "Treat double tap as button press",
"config_device_fieldDescription_ledHeartbeatDisabled": "Disable default blinking LED",
"config_device_fieldDescription_nodeInfoBroadcastInterval": "How often to broadcast node info",
"config_device_fieldDescription_posixTimezone": "The POSIX timezone string for the device",
"config_device_fieldDescription_rebroadcastMode": "How to handle rebroadcasting",
"config_device_fieldDescription_role": "What role the device performs on the mesh",
"config_device_fieldLabel_buttonPin": "Button Pin",
"config_device_fieldLabel_buzzerPin": "Buzzer Pin",
"config_device_fieldLabel_disableTripleClick": "Disable Triple Click",
"config_device_fieldLabel_doubleTapAsButtonPress": "Double Tap as Button Press",
"config_device_fieldLabel_ledHeartbeatDisabled": "LED Heartbeat Disabled",
"config_device_fieldLabel_nodeInfoBroadcastInterval": "Node Info Broadcast Interval",
"config_device_fieldLabel_posixTimezone": "POSIX Timezone",
"config_device_fieldLabel_rebroadcastMode": "Rebroadcast Mode",
"config_device_fieldLabel_role": "Role",
"config_device_groupDescription_deviceSettings": "Settings for the device",
"config_device_groupLabel_deviceSettings": "Device Settings",
"config_display_fieldDescription_boldHeading": "Bolden the heading text",
"config_display_fieldDescription_carouselDelay": "How fast to cycle through windows",
"config_display_fieldDescription_compassNorthTop": "Fix north to the top of compass",
"config_display_fieldDescription_displayMode": "Screen layout variant",
"config_display_fieldDescription_displayUnits": "Display metric or imperial units",
"config_display_fieldDescription_flipScreen": "Flip display 180 degrees",
"config_display_fieldDescription_gpsDisplayUnits": "Coordinate display format",
"config_display_fieldDescription_oledType": "Type of OLED screen attached to the device",
"config_display_fieldDescription_screenTimeout": "Turn off the display after this long",
"config_display_fieldDescription_twelveHourClock": "Use 12-hour clock format",
"config_display_fieldDescription_wakeOnTapOrMotion": "Wake the device on tap or motion",
"config_display_fieldLabel_boldHeading": "Bold Heading",
"config_display_fieldLabel_carouselDelay": "Carousel Delay",
"config_display_fieldLabel_compassNorthTop": "Compass North Top",
"config_display_fieldLabel_displayMode": "Display Mode",
"config_display_fieldLabel_displayUnits": "Display Units",
"config_display_fieldLabel_flipScreen": "Flip Screen",
"config_display_fieldLabel_gpsDisplayUnits": "GPS Display Units",
"config_display_fieldLabel_oledType": "OLED Type",
"config_display_fieldLabel_screenTimeout": "Screen Timeout",
"config_display_fieldLabel_twelveHourClock": "12-Hour Clock",
"config_display_fieldLabel_wakeOnTapOrMotion": "Wake on Tap or Motion",
"config_display_groupDescription_displaySettings": "Settings for the device display",
"config_display_groupLabel_displaySettings": "Display Settings",
"config_lora_fieldDescription_bandwidth": "Channel bandwidth in MHz",
"config_lora_fieldDescription_boostedRxGain": "Boosted RX gain",
"config_lora_fieldDescription_codingRate": "The denominator of the coding rate",
"config_lora_fieldDescription_frequencyOffset": "Frequency offset to correct for crystal calibration errors",
"config_lora_fieldDescription_frequencySlot": "LoRa frequency channel number",
"config_lora_fieldDescription_hopLimit": "Maximum number of hops",
"config_lora_fieldDescription_ignoreMqtt": "Don't forward MQTT messages over the mesh",
"config_lora_fieldDescription_modemPreset": "Modem preset to use",
"config_lora_fieldDescription_okToMqtt": "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
"config_lora_fieldDescription_overrideDutyCycle": "Override Duty Cycle",
"config_lora_fieldDescription_overrideFrequency": "Override frequency",
"config_lora_fieldDescription_region": "Sets the region for your node",
"config_lora_fieldDescription_spreadingFactor": "Indicates the number of chirps per symbol",
"config_lora_fieldDescription_transmitEnabled": "Enable/Disable transmit (TX) from the LoRa radio",
"config_lora_fieldDescription_transmitPower": "Max transmit power",
"config_lora_fieldDescription_usePreset": "Use one of the predefined modem presets",
"config_lora_fieldLabel_bandwidth": "Bandwidth",
"config_lora_fieldLabel_boostedRxGain": "Boosted RX Gain",
"config_lora_fieldLabel_codingRate": "Coding Rate",
"config_lora_fieldLabel_frequencyOffset": "Frequency Offset",
"config_lora_fieldLabel_frequencySlot": "Frequency Slot",
"config_lora_fieldLabel_hopLimit": "Hop Limit",
"config_lora_fieldLabel_ignoreMqtt": "Ignore MQTT",
"config_lora_fieldLabel_modemPreset": "Modem Preset",
"config_lora_fieldLabel_okToMqtt": "OK to MQTT",
"config_lora_fieldLabel_overrideDutyCycle": "Override Duty Cycle",
"config_lora_fieldLabel_overrideFrequency": "Override Frequency",
"config_lora_fieldLabel_region": "Region",
"config_lora_fieldLabel_spreadingFactor": "Spreading Factor",
"config_lora_fieldLabel_transmitEnabled": "Transmit Enabled",
"config_lora_fieldLabel_transmitPower": "Transmit Power",
"config_lora_fieldLabel_usePreset": "Use Preset",
"config_lora_groupDescription_meshSettings": "Settings for the LoRa mesh",
"config_lora_groupDescription_radioSettings": "Settings for the LoRa radio",
"config_lora_groupDescription_waveformSettings": "Settings for the LoRa waveform",
"config_lora_groupLabel_meshSettings": "Mesh Settings",
"config_lora_groupLabel_radioSettings": "Radio Settings",
"config_lora_groupLabel_waveformSettings": "Waveform Settings",
"config_network_fieldDescription_addressMode": "Address assignment selection",
"config_network_fieldDescription_dns": "DNS Server",
"config_network_fieldDescription_ethernetEnabled": "Enable or disable the Ethernet port",
"config_network_fieldDescription_gateway": "Default Gateway",
"config_network_fieldDescription_ip": "IP Address",
"config_network_fieldDescription_psk": "Network password",
"config_network_fieldDescription_ssid": "Network name",
"config_network_fieldDescription_subnet": "Subnet Mask",
"config_network_fieldDescription_wifiEnabled": "Enable or disable the WiFi radio",
"config_network_fieldLabel_addressMode": "Address Mode",
"config_network_fieldLabel_dns": "DNS",
"config_network_fieldLabel_ethernetEnabled": "Enabled",
"config_network_fieldLabel_gateway": "Gateway",
"config_network_fieldLabel_ip": "IP",
"config_network_fieldLabel_meshViaUdp": "Mesh via UDP",
"config_network_fieldLabel_ntpServer": "NTP Server",
"config_network_fieldLabel_psk": "PSK",
"config_network_fieldLabel_rsyslogServer": "Rsyslog Server",
"config_network_fieldLabel_ssid": "SSID",
"config_network_fieldLabel_subnet": "Subnet",
"config_network_fieldLabel_wifiEnabled": "Enabled",
"config_network_groupDescription_ethernetConfig": "Ethernet port configuration",
"config_network_groupDescription_ipConfig": "IP configuration",
"config_network_groupDescription_ntpConfig": "NTP configuration",
"config_network_groupDescription_rsyslogConfig": "Rsyslog configuration",
"config_network_groupDescription_udpConfig": "UDP over Mesh configuration",
"config_network_groupDescription_wifiConfig": "WiFi radio configuration",
"config_network_groupLabel_ethernetConfig": "Ethernet Config",
"config_network_groupLabel_ipConfig": "IP Config",
"config_network_groupLabel_ntpConfig": "NTP Config",
"config_network_groupLabel_rsyslogConfig": "Rsyslog Config",
"config_network_groupLabel_udpConfig": "UDP Config",
"config_network_groupLabel_wifiConfig": "WiFi Config",
"config_network_groupNotes_wifiBluetoothNote": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
"config_position_fieldDescription_broadcastInterval": "How often your position is sent out over the mesh",
"config_position_fieldDescription_enablePin": "GPS module enable pin override",
"config_position_fieldDescription_fixedPosition": "Don't report GPS position, but a manually-specified one",
"config_position_fieldDescription_gpsMode": "Configure whether device GPS is Enabled, Disabled, or Not Present",
"config_position_fieldDescription_gpsUpdateInterval": "How often a GPS fix should be acquired",
"config_position_fieldDescription_positionFlags": "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.",
"config_position_fieldDescription_receivePin": "GPS module RX pin override",
"config_position_fieldDescription_smartPositionEnabled": "Only send position when there has been a meaningful change in location",
"config_position_fieldDescription_smartPositionMinDistance": "Minimum distance (in meters) that must be traveled before a position update is sent",
"config_position_fieldDescription_smartPositionMinInterval": "Minimum interval (in seconds) that must pass before a position update is sent",
"config_position_fieldDescription_transmitPin": "GPS module TX pin override",
"config_position_fieldLabel_broadcastInterval": "Broadcast Interval",
"config_position_fieldLabel_enablePin": "Enable Pin",
"config_position_fieldLabel_fixedPosition": "Fixed Position",
"config_position_fieldLabel_gpsMode": "GPS Mode",
"config_position_fieldLabel_gpsUpdateInterval": "GPS Update Interval",
"config_position_fieldLabel_positionFlags": "Position Flags",
"config_position_fieldLabel_receivePin": "Receive Pin",
"config_position_fieldLabel_smartPositionEnabled": "Enable Smart Position",
"config_position_fieldLabel_smartPositionMinDistance": "Smart Position Minimum Distance",
"config_position_fieldLabel_smartPositionMinInterval": "Smart Position Minimum Interval",
"config_position_fieldLabel_transmitPin": "Transmit Pin",
"config_position_fieldPlaceholder_selectPositionFlags": "Select position flags...",
"config_position_groupDescription_intervals": "How often to send position updates",
"config_position_groupDescription_positionSettings": "Settings for the position module",
"config_position_groupLabel_intervals": "Intervals",
"config_position_groupLabel_positionSettings": "Position Settings",
"config_power_fieldDescription_adcMultiplierOverride": "Used for tweaking battery voltage reading",
"config_power_fieldDescription_ina219Address": "Address of the INA219 battery monitor",
"config_power_fieldDescription_lightSleepDuration": "How long the device will be in light sleep for",
"config_power_fieldDescription_minimumWakeTime": "Minimum amount of time the device will stay awake for after receiving a packet",
"config_power_fieldDescription_noConnectionBluetoothDisabled": "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
"config_power_fieldDescription_powerSavingEnabled": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.",
"config_power_fieldDescription_shutdownOnBatteryDelay": "Automatically shutdown node after this long when on battery, 0 for indefinite",
"config_power_fieldDescription_superDeepSleepDuration": "How long the device will be in super deep sleep for",
"config_power_fieldLabel_adcMultiplierOverride": "ADC Multiplier Override ratio",
"config_power_fieldLabel_ina219Address": "INA219 Address",
"config_power_fieldLabel_lightSleepDuration": "Light Sleep Duration",
"config_power_fieldLabel_minimumWakeTime": "Minimum Wake Time",
"config_power_fieldLabel_noConnectionBluetoothDisabled": "No Connection Bluetooth Disabled",
"config_power_fieldLabel_powerSavingEnabled": "Enable power saving mode",
"config_power_fieldLabel_shutdownOnBatteryDelay": "Shutdown on battery delay",
"config_power_fieldLabel_superDeepSleepDuration": "Super Deep Sleep Duration",
"config_power_groupDescription_powerConfig": "Settings for the power module",
"config_power_groupDescription_sleepSettings": "Sleep settings for the power module",
"config_power_groupLabel_powerConfig": "Power Config",
"config_power_groupLabel_sleepSettings": "Sleep Settings",
"config_security_button_backupKey": "Backup Key",
"config_security_fieldDescription_adminChannelEnabled": "Allow incoming device control over the insecure legacy admin channel",
"config_security_fieldDescription_enableDebugLogApi": "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
"config_security_fieldDescription_managed": "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
"config_security_fieldDescription_primaryAdminKey": "The primary public key authorized to send admin messages to this node",
"config_security_fieldDescription_privateKey": "Used to create a shared key with a remote device",
"config_security_fieldDescription_publicKey": "Sent out to other nodes on the mesh to allow them to compute a shared secret key",
"config_security_fieldDescription_secondaryAdminKey": "The secondary public key authorized to send admin messages to this node",
"config_security_fieldDescription_serialOutputEnabled": "Serial Console over the Stream API",
"config_security_fieldDescription_tertiaryAdminKey": "The tertiary public key authorized to send admin messages to this node",
"config_security_fieldLabel_adminChannelEnabled": "Allow Legacy Admin",
"config_security_fieldLabel_enableDebugLogApi": "Enable Debug Log API",
"config_security_fieldLabel_managed": "Managed",
"config_security_fieldLabel_primaryAdminKey": "Primary Admin Key",
"config_security_fieldLabel_privateKey": "Private Key",
"config_security_fieldLabel_publicKey": "Public Key",
"config_security_fieldLabel_secondaryAdminKey": "Secondary Admin Key",
"config_security_fieldLabel_serialOutputEnabled": "Serial Output Enabled",
"config_security_fieldLabel_tertiaryAdminKey": "Tertiary Admin Key",
"config_security_groupDescription_adminSettings": "Settings for Admin",
"config_security_groupDescription_loggingSettings": "Settings for Logging",
"config_security_groupDescription_securitySettings": "Settings for the Security configuration",
"config_security_groupLabel_adminSettings": "Admin Settings",
"config_security_groupLabel_loggingSettings": "Logging Settings",
"config_security_groupLabel_securitySettings": "Security Settings",
"config_security_option_256bit": "256 bit",
"config_security_validation_adminKeyMustBe256BitPsk": "Admin Key is required to be a 256 bit pre-shared key (PSK)",
"config_security_validation_adminKeyRequiredWhenManaged": "At least one admin key is requred if the node is managed.",
"config_security_validation_enterValid256BitPsk": "Please enter a valid 256 bit PSK",
"config_security_validation_invalidAdminKeyFormat": "Invalid Admin Key format",
"config_security_validation_invalidPrivateKeyFormat": "Invalid Private Key format",
"config_security_validation_privateKeyMustBe256BitPsk": "Private Key is required to be a 256 bit pre-shared key (PSK)",
"config_security_validation_privateKeyRequired": "Private Key is required",
"input_action_clearInput_label": "Clear input",
"input_action_copied_label": "Copied!",
"input_action_copyToClipboard_label": "Copy to clipboard",
"input_action_hidePassword_label": "Hide password",
"input_action_showPassword_label": "Show password",
"filter_control_accordion_general_label": "General",
"filter_control_accordion_hardware_label": "Hardware",
"filter_control_accordion_metrics_label": "Metrics",
"filter_control_accordion_role_label": "Role",
"filter_control_button_filter_ariaLabel": "Filter",
"filter_control_button_resetFilters_label": "Reset Filters",
"filter_control_input_nodeName_label": "Node name/number",
"filter_control_input_nodeName_placeholder": "Meshtastic 1234",
"filter_control_slider_airtimeUtilization_label": "Airtime Utilization (%)",
"filter_control_slider_batteryLevel_label": "Battery level (%)",
"filter_control_slider_batteryLevel_labelText": "Battery level (%): {{value}}",
"filter_control_slider_batteryVoltage_label": "Battery voltage (V)",
"filter_control_slider_channelUtilization_label": "Channel Utilization (%)",
"filter_control_slider_hops_directLabel": "Direct",
"filter_control_slider_hops_label": "Number of hops",
"filter_control_slider_hops_labelText": "Number of hops: {{value}}",
"filter_control_slider_lastHeard_label": "Last heard",
"filter_control_slider_lastHeard_labelText": "Last heard: {{value}}",
"filter_control_slider_lastHeard_nowLabel": "Now",
"filter_control_slider_snr_label": "SNR (db)",
"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"
}

5
src/index.tsx

@ -1,10 +1,11 @@
import "@app/index.css"; import "@app/index.css";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { StrictMode } from "react"; import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "@app/App.tsx"; import { App } from "@app/App.tsx";
import "@app/i18n.ts";
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container); const root = createRoot(container);
@ -13,6 +14,8 @@ enableMapSet();
root.render( root.render(
<StrictMode> <StrictMode>
<Suspense fallback={null}>
<App /> <App />
</Suspense>,
</StrictMode>, </StrictMode>,
); );

15
src/pages/Channels.tsx

@ -10,17 +10,20 @@ import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Types } from "@meshtastic/core"; import { Types } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import i18next from "i18next";
import { ImportIcon, QrCodeIcon } from "lucide-react"; import { ImportIcon, QrCodeIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
export const getChannelName = (channel: Protobuf.Channel.Channel) => export const getChannelName = (channel: Protobuf.Channel.Channel) =>
channel.settings?.name.length channel.settings?.name.length
? channel.settings?.name ? channel.settings?.name
: channel.index === 0 : channel.index === 0
? "Primary" ? i18next.t("channel_name_primary")
: `Ch ${channel.index}`; : i18next.t("channel_name_prefix", { index: channel.index });
const ChannelsPage = () => { const ChannelsPage = () => {
const { t } = useTranslation();
const { channels, setDialogOpen } = useDevice(); const { channels, setDialogOpen } = useDevice();
const [activeChannel] = useState<Types.ChannelNumber>( const [activeChannel] = useState<Types.ChannelNumber>(
Types.ChannelNumber.Primary, Types.ChannelNumber.Primary,
@ -33,9 +36,11 @@ const ChannelsPage = () => {
<> <>
<PageLayout <PageLayout
leftBar={<Sidebar />} leftBar={<Sidebar />}
label={`Channel: ${ label={currentChannel
currentChannel ? getChannelName(currentChannel) : "Loading..." ? t("channel_page_title", {
}`} channelName: getChannelName(currentChannel),
})
: t("common.loading")}
actions={[ actions={[
{ {
key: "search", key: "search",

20
src/pages/Config/DeviceConfig.tsx

@ -12,46 +12,48 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { useTranslation } from "react-i18next";
export const DeviceConfig = () => { export const DeviceConfig = () => {
const { t } = useTranslation();
const tabs = [ const tabs = [
{ {
label: "Device", label: t("config_device_tab_device"),
element: Device, element: Device,
count: 0, count: 0,
}, },
{ {
label: "Position", label: t("config_device_tab_position"),
element: Position, element: Position,
}, },
{ {
label: "Power", label: t("config_device_tab_power"),
element: Power, element: Power,
}, },
{ {
label: "Network", label: t("config_device_tab_network"),
element: Network, element: Network,
}, },
{ {
label: "Display", label: t("config_device_tab_display"),
element: Display, element: Display,
}, },
{ {
label: "LoRa", label: t("config_device_tab_lora"),
element: LoRa, element: LoRa,
}, },
{ {
label: "Bluetooth", label: t("config_device_tab_bluetooth"),
element: Bluetooth, element: Bluetooth,
}, },
{ {
label: "Security", label: t("config_device_tab_security"),
element: Security, element: Security,
}, },
]; ];
return ( return (
<Tabs defaultValue="Device"> <Tabs defaultValue={t("config_device_tab_device")}>
<TabsList className="dark:bg-slate-700"> <TabsList className="dark:bg-slate-700">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger

28
src/pages/Config/ModuleConfig.tsx

@ -16,61 +16,63 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { useTranslation } from "react-i18next";
export const ModuleConfig = () => { export const ModuleConfig = () => {
const { t } = useTranslation();
const tabs = [ const tabs = [
{ {
label: "MQTT", label: t("config_module_tab_mqtt"),
element: MQTT, element: MQTT,
}, },
{ {
label: "Serial", label: t("config_module_tab_serial"),
element: Serial, element: Serial,
}, },
{ {
label: "Ext Notif", label: t("config_module_tab_externalNotification"),
element: ExternalNotification, element: ExternalNotification,
}, },
{ {
label: "S&F", label: t("config_module_tab_storeAndForward"),
element: StoreForward, element: StoreForward,
}, },
{ {
label: "Range Test", label: t("config_module_tab_rangeTest"),
element: RangeTest, element: RangeTest,
}, },
{ {
label: "Telemetry", label: t("config_module_tab_telemetry"),
element: Telemetry, element: Telemetry,
}, },
{ {
label: "Canned", label: t("config_module_tab_cannedMessage"),
element: CannedMessage, element: CannedMessage,
}, },
{ {
label: "Audio", label: t("config_module_tab_audio"),
element: Audio, element: Audio,
}, },
{ {
label: "Neighbor Info", label: t("config_module_tab_neighborInfo"),
element: NeighborInfo, element: NeighborInfo,
}, },
{ {
label: "Ambient Lighting", label: t("config_module_tab_ambientLighting"),
element: AmbientLighting, element: AmbientLighting,
}, },
{ {
label: "Detection Sensor", label: t("config_module_tab_detectionSensor"),
element: DetectionSensor, element: DetectionSensor,
}, },
{ {
label: "Paxcounter", label: t("config_module_tab_paxcounter"),
element: Paxcounter, element: Paxcounter,
}, },
]; ];
return ( return (
<Tabs defaultValue="MQTT"> <Tabs defaultValue={t("config_module_tab_mqtt")}>
<TabsList className="dark:bg-slate-800"> <TabsList className="dark:bg-slate-800">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger

36
src/pages/Config/index.tsx

@ -9,6 +9,7 @@ import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react"; import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
const ConfigPage = () => { const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice(); const { workingConfig, workingModuleConfig, connection } = useDevice();
@ -19,12 +20,13 @@ const ConfigPage = () => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const isError = hasErrors(); const isError = hasErrors();
const { t } = useTranslation();
const handleSave = async () => { const handleSave = async () => {
if (hasErrors()) { if (hasErrors()) {
return toast({ return toast({
title: "Config Errors Exist", title: t("config_toast_errorsExist_title"),
description: "Please fix the configuration errors before saving.", description: t("config_toast_errorsExist_description"),
}); });
} }
@ -35,9 +37,10 @@ const ConfigPage = () => {
workingConfig.map((config) => workingConfig.map((config) =>
connection?.setConfig(config).then(() => connection?.setConfig(config).then(() =>
toast({ toast({
title: "Saving Config", title: t("config_toast_saving_title"),
description: description: t("config_toast_saving_description", {
`The configuration change ${config.payloadVariant.case} has been saved.`, case: config.payloadVariant.case,
}),
}) })
) )
), ),
@ -47,9 +50,10 @@ const ConfigPage = () => {
workingModuleConfig.map((moduleConfig) => workingModuleConfig.map((moduleConfig) =>
connection?.setModuleConfig(moduleConfig).then(() => connection?.setModuleConfig(moduleConfig).then(() =>
toast({ toast({
title: "Saving Config", title: t("config_toast_saving_title"),
description: description: t("config_toast_saving_description", {
`The configuration change ${moduleConfig.payloadVariant.case} has been saved.`, case: moduleConfig.payloadVariant.case,
}),
}) })
) )
), ),
@ -59,8 +63,8 @@ const ConfigPage = () => {
await connection?.commitEditSettings(); await connection?.commitEditSettings();
} catch (_error) { } catch (_error) {
toast({ toast({
title: "Error Saving Config", title: t("config_toast_errorSaving_title"),
description: "An error occurred while saving the configuration.", description: t("config_toast_errorSaving_description"),
}); });
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@ -70,15 +74,15 @@ const ConfigPage = () => {
const leftSidebar = useMemo( const leftSidebar = useMemo(
() => ( () => (
<Sidebar> <Sidebar>
<SidebarSection label="Modules"> <SidebarSection label={t("config_sidebar_section_modules_label")}>
<SidebarButton <SidebarButton
label="Radio Config" label={t("navigation_title_radioConfig")}
active={activeConfigSection === "device"} active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")} onClick={() => setActiveConfigSection("device")}
Icon={SettingsIcon} Icon={SettingsIcon}
/> />
<SidebarButton <SidebarButton
label="Module Config" label={t("navigation_title_moduleConfig")}
active={activeConfigSection === "module"} active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")} onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon} Icon={BoxesIcon}
@ -86,7 +90,7 @@ const ConfigPage = () => {
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
), ),
[activeConfigSection], [activeConfigSection, t],
); );
return ( return (
@ -95,8 +99,8 @@ const ConfigPage = () => {
contentClassName="overflow-auto" contentClassName="overflow-auto"
leftBar={leftSidebar} leftBar={leftSidebar}
label={activeConfigSection === "device" label={activeConfigSection === "device"
? "Radio Config" ? t("navigation_title_radioConfig")
: "Module Config"} : t("navigation_title_moduleConfig")}
actions={[ actions={[
{ {
key: "save", key: "save",

34
src/pages/Dashboard/index.tsx

@ -13,8 +13,10 @@ import {
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export const Dashboard = () => { export const Dashboard = () => {
const { t } = useTranslation();
const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
@ -25,8 +27,12 @@ export const Dashboard = () => {
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Heading as="h3">Connected Devices</Heading> <Heading as="h3">
<Subtle>Manage, connect and disconnect devices</Subtle> {t("dashboard_connectedDevicesSection_title")}
</Heading>
<Subtle>
{t("dashboard_connectedDevicesSection_description")}
</Subtle>
</div> </div>
</div> </div>
@ -55,19 +61,25 @@ export const Dashboard = () => {
{device.connection?.connType === "ble" && ( {device.connection?.connType === "ble" && (
<> <>
<BluetoothIcon size={16} /> <BluetoothIcon size={16} />
BLE {t(
"dashboard_connectedDevicesSection_connectionType_ble",
)}
</> </>
)} )}
{device.connection?.connType === "serial" && ( {device.connection?.connType === "serial" && (
<> <>
<UsbIcon size={16} /> <UsbIcon size={16} />
Serial {t(
"dashboard_connectedDevicesSection_connectionType_serial",
)}
</> </>
)} )}
{device.connection?.connType === "http" && ( {device.connection?.connType === "http" && (
<> <>
<NetworkIcon size={16} /> <NetworkIcon size={16} />
Network {t(
"dashboard_connectedDevicesSection_connectionType_network",
)}
</> </>
)} )}
</div> </div>
@ -96,15 +108,21 @@ export const Dashboard = () => {
size={48} size={48}
className="mx-auto text-text-secondary" className="mx-auto text-text-secondary"
/> />
<Heading as="h3">No Devices</Heading> <Heading as="h3">
<Subtle>Connect at least one device to get started</Subtle> {t("dashboard_connectedDevicesSection_noDevicesTitle")}
</Heading>
<Subtle>
{t("dashboard_connectedDevicesSection_noDevicesDescription")}
</Subtle>
<Button <Button
className="gap-2" className="gap-2"
variant="default" variant="default"
onClick={() => setConnectDialogOpen(true)} onClick={() => setConnectDialogOpen(true)}
> >
<PlusIcon size={16} /> <PlusIcon size={16} />
New Connection {t(
"dashboard_connectedDevicesSection_button_newConnection",
)}
</Button> </Button>
</div> </div>
)} )}

47
src/pages/Messages.tsx

@ -20,6 +20,7 @@ import {
import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
@ -45,6 +46,7 @@ export const MessagesPage = () => {
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const { t } = useTranslation();
const deferredSearch = useDeferredValue(searchTerm); const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => { const filteredNodes = (): NodeInfoWithUnread[] => {
@ -163,7 +165,7 @@ export const MessagesPage = () => {
default: default:
return ( return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4"> <div className="flex-1 flex items-center justify-center text-slate-500 p-4">
Select a channel or node to start messaging. {t("messages_selectChatPrompt")}
</div> </div>
); );
} }
@ -171,13 +173,19 @@ export const MessagesPage = () => {
const leftSidebar = useMemo(() => ( const leftSidebar = useMemo(() => (
<Sidebar> <Sidebar>
<SidebarSection label="Channels" className="py-2 px-0"> <SidebarSection
label={t("messages_channelsSection_title")}
className="py-2 px-0"
>
{filteredChannels?.map((channel) => ( {filteredChannels?.map((channel) => (
<SidebarButton <SidebarButton
key={channel.index} key={channel.index}
count={unreadCounts.get(channel.index)} count={unreadCounts.get(channel.index)}
label={channel.settings?.name || label={channel.settings?.name || (channel.index === 0
(channel.index === 0 ? "Primary" : `Ch ${channel.index}`)} ? t("messages_channelsSection_primaryChannelName")
: t("messages_channelsSection_channelName", {
index: channel.index,
}))}
active={activeChat === channel.index && active={activeChat === channel.index &&
chatType === MessageType.Broadcast} chatType === MessageType.Broadcast}
onClick={() => { onClick={() => {
@ -214,7 +222,7 @@ export const MessagesPage = () => {
<label className="p-2 block"> <label className="p-2 block">
<Input <Input
type="text" type="text"
placeholder="Search nodes..." placeholder={t("messages_nodesSection_searchPlaceholder")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
showClearButton={!!searchTerm} showClearButton={!!searchTerm}
@ -229,7 +237,7 @@ export const MessagesPage = () => {
<SidebarButton <SidebarButton
key={node.num} key={node.num}
preventCollapse preventCollapse
label={node.user?.longName ?? `UNK`} label={node.user?.longName ?? t("common.unknown")}
count={node.unreadCount > 0 ? node.unreadCount : undefined} count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && active={activeChat === node.num &&
chatType === MessageType.Direct} chatType === MessageType.Direct}
@ -240,7 +248,7 @@ export const MessagesPage = () => {
}} }}
> >
<Avatar <Avatar
text={node.user?.shortName ?? "UNK"} text={node.user?.shortName ?? t("common.unknown")}
className={cn(hasNodeError(node.num) && "text-red-500")} className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)} showError={hasNodeError(node.num)}
showFavorite={node.isFavorite} showFavorite={node.isFavorite}
@ -262,15 +270,20 @@ export const MessagesPage = () => {
hasNodeError, hasNodeError,
], ],
); );
const pageTitleChatName = useMemo(() => {
if (isBroadcast && currentChannel) return getChannelName(currentChannel);
if (isDirect && otherNode) {
return otherNode.user?.longName ?? t("common.unknown");
}
return t("messages_title_default");
}, [isBroadcast, currentChannel, isDirect, otherNode, t]);
return ( return (
<PageLayout <PageLayout
label={`Messages: ${ label={t("messages_title", {
isBroadcast && currentChannel chatName: pageTitleChatName,
? getChannelName(currentChannel) })}
: isDirect && otherNode
? (otherNode.user?.longName ?? "Unknown")
: "Select a Chat"
}`}
rightBar={rightSidebar} rightBar={rightSidebar}
leftBar={leftSidebar} leftBar={leftSidebar}
actions={isDirect && otherNode actions={isDirect && otherNode
@ -284,8 +297,8 @@ export const MessagesPage = () => {
onClick() { onClick() {
toast({ toast({
title: otherNode.user?.publicKey?.length title: otherNode.user?.publicKey?.length
? "Chat is using PKI encryption." ? t("toast_messages_pkiEncryption")
: "Chat is using PSK encryption.", : t("toast_messages_pskEncryption"),
}); });
}, },
}, },
@ -306,7 +319,7 @@ export const MessagesPage = () => {
) )
: ( : (
<div className="p-4 text-center text-slate-400 italic"> <div className="p-4 text-center text-slate-400 italic">
Select a chat to send a message. {t("messages_sendMessagePrompt")}
</div> </div>
)} )}
</div> </div>

77
src/pages/Nodes.tsx

@ -26,6 +26,7 @@ import {
useFilterNode, useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts"; } from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { useTranslation } from "react-i18next";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
@ -33,6 +34,7 @@ export interface DeleteNoteDialogProps {
} }
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { t } = useTranslation();
const { getNodes, hardware, connection, hasNodeError } = useDevice(); const { getNodes, hardware, connection, hasNodeError } = useDevice();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const [selectedNode, setSelectedNode] = useState< const [selectedNode, setSelectedNode] = useState<
@ -95,7 +97,7 @@ const NodesPage = (): JSX.Element => {
<div className="pl-2 pt-2 flex flex-row"> <div className="pl-2 pt-2 flex flex-row">
<div className="flex-1 mr-2"> <div className="flex-1 mr-2">
<Input <Input
placeholder="Search nodes..." placeholder={t("nodes_searchPlaceholder")}
value={filterState.nodeName} value={filterState.nodeName}
className="bg-transparent" className="bg-transparent"
showClearButton={!!filterState.nodeName} showClearButton={!!filterState.nodeName}
@ -128,18 +130,46 @@ const NodesPage = (): JSX.Element => {
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "", type: "blank", sortable: false },
{ title: "Long Name", type: "normal", sortable: true }, {
{ title: "Connection", type: "normal", sortable: true }, title: t("nodes_table_headings_longName"),
{ title: "Last Heard", type: "normal", sortable: true }, type: "normal",
{ title: "Encryption", type: "normal", sortable: false }, sortable: true,
{ title: "SNR", type: "normal", sortable: true }, },
{ title: "Model", type: "normal", sortable: true }, {
{ title: "MAC Address", type: "normal", sortable: true }, title: t("nodes_table_headings_connection"),
type: "normal",
sortable: true,
},
{
title: t("nodes_table_headings_lastHeard"),
type: "normal",
sortable: true,
},
{
title: t("nodes_table_headings_encryption"),
type: "normal",
sortable: false,
},
{
title: t("node_detail_snr_label"),
type: "normal",
sortable: true,
},
{
title: t("nodes_table_headings_model"),
type: "normal",
sortable: true,
},
{
title: t("nodes_table_headings_macAddress"),
type: "normal",
sortable: true,
},
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<div key={node.num}> <div key={node.num}>
<Avatar <Avatar
text={node.user?.shortName ?? "UNK "} text={node.user?.shortName ?? t("common.unknown")}
showFavorite={node.isFavorite} showFavorite={node.isFavorite}
showError={hasNodeError(node.num)} showError={hasNodeError(node.num)}
/> />
@ -159,16 +189,20 @@ const NodesPage = (): JSX.Element => {
<Mono key="hops" className="w-16"> <Mono key="hops" className="w-16">
{node.hopsAway !== undefined {node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0 ? node?.viaMqtt === false && node.hopsAway === 0
? "Direct" ? t("nodes_table_connectionStatus_direct")
: `${node.hopsAway?.toString()} ${ : `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1 ? "hops" : "hop" node.hopsAway ?? 0 > 1
} away` ? t("nodes_table_connectionStatus_hops_other")
: "-"} : t("nodes_table_connectionStatus_hops_one")
{node?.viaMqtt === true ? ", via MQTT" : ""} } ${t("nodes_table_connectionStatus_away")}`
: t("nodes_table_connectionStatus_unknown")}
{node?.viaMqtt === true
? t("nodes_table_connectionStatus_viaMqtt")
: ""}
</Mono>, </Mono>,
<Mono key="lastHeard"> <Mono key="lastHeard">
{node.lastHeard === 0 {node.lastHeard === 0
? <p>Never</p> ? <p>{t("nodes_table_lastHeardStatus_never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />} : <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>, </Mono>,
<Mono key="pki"> <Mono key="pki">
@ -177,9 +211,14 @@ const NodesPage = (): JSX.Element => {
: <LockOpenIcon className="text-yellow-300 mx-auto" />} : <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>, </Mono>,
<Mono key="snr"> <Mono key="snr">
{node.snr}db/ {node.snr}
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {t("common_unit_dbm")}/
{(node.snr + 10) * 5}raw {Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("common_unit_raw")}
</Mono>, </Mono>,
<Mono key="model"> <Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
@ -188,7 +227,7 @@ const NodesPage = (): JSX.Element => {
{base16 {base16
.stringify(node.user?.macaddr ?? []) .stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? t("common.unknown")}
</Mono>, </Mono>,
])} ])}
/> />

45
vite.config.ts

@ -1,48 +1,47 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from "vite-plugin-pwa";
import { execSync } from 'node:child_process'; import { execSync } from "node:child_process";
import process from "node:process"; import process from "node:process";
import path from 'node:path'; import path from "node:path";
let hash = "";
let hash = '';
try { try {
hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch (error) { } catch (error) {
console.error('Error getting git hash:', error); console.error("Error getting git hash:", error);
hash = 'DEV'; hash = "DEV";
} }
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: "autoUpdate",
strategies: 'generateSW', strategies: "generateSW",
devOptions: { devOptions: {
enabled: false enabled: false,
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
sourcemap: true sourcemap: true,
} },
}) }),
], ],
define: { define: {
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(hash), "import.meta.env.VITE_COMMIT_HASH": JSON.stringify(hash),
}, },
build: { build: {
emptyOutDir: true, emptyOutDir: true,
assetsDir: './', assetsDir: "./",
}, },
resolve: { resolve: {
alias: { alias: {
'@app': path.resolve(process.cwd(), './src'), "@app": path.resolve(process.cwd(), "./src"),
'@pages': path.resolve(process.cwd(), './src/pages'), "@pages": path.resolve(process.cwd(), "./src/pages"),
'@components': path.resolve(process.cwd(), './src/components'), "@components": path.resolve(process.cwd(), "./src/components"),
'@core': path.resolve(process.cwd(), './src/core'), "@core": path.resolve(process.cwd(), "./src/core"),
'@layouts': path.resolve(process.cwd(), './src/layouts'), "@layouts": path.resolve(process.cwd(), "./src/layouts"),
}, },
}, },
server: { server: {

Loading…
Cancel
Save