Add monorepo support to project (#682)

* feat: add monorepo support

* fix: correct relatedProjects structure in vercel.json

* update github actions with monorepo support

* removed legacy dep

* moved jsr config into web package
This commit is contained in:
Dan Ditomaso
2025-06-25 21:56:55 -04:00
committed by GitHub
parent 85ee0747d1
commit 1c13343e8a
463 changed files with 7568 additions and 2196 deletions
+69
View File
@@ -0,0 +1,69 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import Footer from "@components/UI/Footer.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
export function App() {
const { getDevice } = useDeviceStore();
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
useAppStore();
const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme();
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
setConnectDialogOpen(open);
}}
/>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>
<div
className="flex h-screen flex-col bg-background-primary text-text-primary"
style={{ scrollbarWidth: "thin" }}
>
<SidebarProvider>
<div className="h-full flex flex-col">
{device
? (
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<Outlet />
</MapProvider>
</div>
)
: (
<>
<Dashboard />
<Footer />
</>
)}
</div>
</SidebarProvider>
</div>
</DeviceWrapper>
</ErrorBoundary>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { DeviceContext } from "@core/stores/deviceStore.ts";
import type { Device } from "@core/stores/deviceStore.ts";
import type { ReactNode } from "react";
export interface DeviceWrapperProps {
children: ReactNode;
device?: Device;
}
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
return (
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
);
};
+43
View File
@@ -0,0 +1,43 @@
# Mocks Directory
This directory contains mock implementations used by Vitest for testing.
## Structure
The directory structure mirrors the actual project structure to make mocking
more intuitive:
```
__mocks__/
├── components/
│ └── UI/
│ ├── Dialog.tsx
│ ├── Button.tsx
│ ├── Checkbox.tsx
│ └── ...
├── core/
│ └── ...
└── ...
```
## Auto-mocking
Vitest will automatically use the mock files in this directory when the
corresponding module is imported in tests. For example, when a test imports
`@components/UI/Dialog.tsx`, Vitest will use
`__mocks__/components/UI/Dialog.tsx` instead.
## Creating New Mocks
To create a new mock:
1. Create a file in the same relative path as the original module
2. Export the mocked functionality with the same names as the original
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed
## Mock Guidelines
- Keep mocks as simple as possible
- Use `data-testid` attributes for easy querying in tests
- Implement just enough functionality to test the component
- Use TypeScript types to ensure compatibility with the original module
@@ -0,0 +1,21 @@
import { vi } from "vitest";
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, name, disabled, onClick }: {
children: React.ReactNode;
variant: string;
name: string;
disabled?: boolean;
onClick: () => void;
}) => (
<button
type="button"
name={name}
data-testid={`button-${name}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
),
}));
@@ -0,0 +1,19 @@
import { vi } from "vitest";
vi.mock("@components/UI/Checkbox.tsx", () => ({
Checkbox: (
{ id, checked, onChange }: {
id: string;
checked: boolean;
onChange: () => void;
},
) => (
<input
data-testid="checkbox"
type="checkbox"
id={id}
checked={checked}
onChange={onChange}
/>
),
}));
@@ -0,0 +1,45 @@
import React from "react";
export const Dialog = ({ children, open }: {
children: React.ReactNode;
open: boolean;
onOpenChange?: (open: boolean) => void;
}) => open ? <div data-testid="dialog">{children}</div> : null;
export const DialogContent = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
export const DialogHeader = ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="dialog-header">{children}</div>;
export const DialogTitle = ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="dialog-title">{children}</div>;
export const DialogDescription = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<div data-testid="dialog-description" className={className}>{children}</div>
);
export const DialogFooter = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
@@ -0,0 +1,15 @@
import { vi } from "vitest";
vi.mock("@components/UI/Label.tsx", () => ({
Label: (
{ children, htmlFor, className }: {
children: React.ReactNode;
htmlFor: string;
className?: string;
},
) => (
<label data-testid="label" htmlFor={htmlFor} className={className}>
{children}
</label>
),
}));
@@ -0,0 +1,11 @@
import { vi } from "vitest";
vi.mock("@components/UI/Typography/Link.tsx", () => ({
Link: (
{ children, href, className }: {
children: React.ReactNode;
href: string;
className?: string;
},
) => <a data-testid="link" href={href} className={className}>{children}</a>,
}));
@@ -0,0 +1,102 @@
import React from "react";
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
PlugZapIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts";
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface StatusConfig {
Icon: React.ElementType;
className: string;
text: string;
}
const BATTERY_STATUS = {
PLUGGED_IN: "PLUGGED_IN",
FULL: "FULL",
MEDIUM: "MEDIUM",
LOW: "LOW",
} as const;
export const getBatteryStatus = (level: number): BatteryStatusKey => {
if (level > 100) {
return BATTERY_STATUS.PLUGGED_IN;
}
if (level > 80) {
return BATTERY_STATUS.FULL;
}
if (level > 20) {
return BATTERY_STATUS.MEDIUM;
}
return BATTERY_STATUS.LOW;
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const { t } = useTranslation();
if (
deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null
) {
return null;
}
const { batteryLevel } = deviceMetrics;
const statusKey = getBatteryStatus(batteryLevel);
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
[BATTERY_STATUS.PLUGGED_IN]: {
Icon: PlugZapIcon,
className: "text-gray-500",
text: t("batteryStatus.pluggedIn"),
},
[BATTERY_STATUS.FULL]: {
Icon: BatteryFullIcon,
className: "text-green-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.MEDIUM]: {
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.LOW]: {
Icon: BatteryLowIcon,
className: "text-red-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
};
// 3. Use the key to get the current state configuration
const {
Icon: BatteryIcon,
className: iconClassName,
text: statusText,
} = statusConfigMap[statusKey];
return (
<div
className="flex items-center gap-1 mt-0.5 "
aria-label={t("batteryStatus.title")}
>
<BatteryIcon size={22} className={iconClassName} />
{statusText}
</div>
);
};
export default BatteryStatus;
@@ -0,0 +1,346 @@
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@components/UI/Command.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { useCommandState } from "cmdk";
import {
ArrowLeftRightIcon,
BoxSelectIcon,
BugIcon,
EraserIcon,
FactoryIcon,
LayersIcon,
LinkIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
Pin,
PlusIcon,
PowerIcon,
QrCodeIcon,
RefreshCwIcon,
SettingsIcon,
SmartphoneIcon,
TrashIcon,
UsersIcon,
} from "lucide-react";
import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts";
import { useTranslation } from "react-i18next";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useNavigate } from "@tanstack/react-router";
export interface Group {
id: string;
label: string;
icon: LucideIcon;
commands: Command[];
}
export interface Command {
label: string;
icon: LucideIcon;
action?: () => void;
subItems?: SubItem[];
tags?: string[];
}
export interface SubItem {
label: string;
icon: React.ReactNode;
action: () => void;
}
export const CommandPalette = () => {
const {
commandPaletteOpen,
setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const { t } = useTranslation("commandPalette");
const navigate = useNavigate({ from: "/" });
const groups: Group[] = [
{
id: "gotoGroup",
label: t("goto.label"),
icon: LinkIcon,
commands: [
{
label: t("goto.command.messages"),
icon: MessageSquareIcon,
action() {
navigate({ to: "/messages" });
},
},
{
label: t("goto.command.map"),
icon: MapIcon,
action() {
navigate({ to: "/map" });
},
},
{
label: t("goto.command.config"),
icon: SettingsIcon,
action() {
navigate({ to: "/config" });
},
tags: ["settings"],
},
{
label: t("goto.command.channels"),
icon: LayersIcon,
action() {
navigate({ to: "/channels" });
},
},
{
label: t("goto.command.nodes"),
icon: UsersIcon,
action() {
navigate({ to: "/nodes" });
},
},
],
},
{
id: "manageGroup",
label: t("manage.label"),
icon: SmartphoneIcon,
commands: [
{
label: t("manage.command.switchNode"),
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
t("unknown.shortName"),
icon: (
<Avatar
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")}
/>
),
action() {
setSelectedDevice(device.id);
},
})),
},
{
label: t("manage.command.connectNewNode"),
icon: PlusIcon,
action() {
setConnectDialogOpen(true);
},
},
],
},
{
id: "contextualGroup",
label: t("contextual.label"),
icon: BoxSelectIcon,
commands: [
{
label: t("contextual.command.qrCode"),
icon: QrCodeIcon,
subItems: [
{
label: t("contextual.command.qrGenerator"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("QR", true);
},
},
{
label: t("contextual.command.qrImport"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("import", true);
},
},
],
},
{
label: t("contextual.command.scheduleShutdown"),
icon: PowerIcon,
action() {
setDialogOpen("shutdown", true);
},
},
{
label: t("contextual.command.scheduleReboot"),
icon: RefreshCwIcon,
action() {
setDialogOpen("reboot", true);
},
},
{
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: t("contextual.command.resetNodeDb"),
icon: TrashIcon,
action() {
connection?.resetNodes();
},
},
{
label: t("contextual.command.factoryResetDevice"),
icon: FactoryIcon,
action() {
connection?.factoryResetDevice();
},
},
{
label: t("contextual.command.factoryResetConfig"),
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
},
},
],
},
{
id: "debugGroup",
label: t("debug.label"),
icon: BugIcon,
commands: [
{
label: t("debug.command.reconfigure"),
icon: RefreshCwIcon,
action() {
void connection?.configure();
},
},
{
label: t("debug.command.clearAllStoredMessages"),
icon: EraserIcon,
action() {
setDialogOpen("deleteMessages", true);
},
},
],
},
];
const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
return bPinned - aPinned;
});
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setCommandPaletteOpen(true);
}
};
globalThis.addEventListener("keydown", handleKeydown);
return () => globalThis.removeEventListener("keydown", handleKeydown);
}, [setCommandPaletteOpen]);
return (
<CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder={t("search.commandPalette")} />
<CommandList>
<CommandEmpty>{t("emptyState")}</CommandEmpty>
{sortedGroups.map((group) => (
<CommandGroup
key={group.label}
heading={
<div className="flex items-center justify-between">
<span>{group.label}</span>
<button
type="button"
onClick={() => togglePinnedItem(group.id)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
aria-description={pinnedItems.includes(group.label)
? t("unpinGroup.label")
: t("pinGroup.label")}
>
<span
data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
/>
<Pin
size={16}
className={cn(
"transition-opacity",
pinnedItems.includes(group.id)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70",
)}
/>
</button>
</div>
}
>
{group.commands.map((command) => (
<div key={command.label}>
<CommandItem
onSelect={() => {
command.action?.();
setCommandPaletteOpen(false);
}}
>
<command.icon size={16} className="mr-2" />
{command.label}
</CommandItem>
{command.subItems?.map((subItem) => (
<SubItem
key={subItem.label}
label={subItem.label}
icon={subItem.icon}
action={subItem.action}
/>
))}
</div>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
};
const SubItem = ({
label,
icon,
action,
}: {
label: string;
icon: React.ReactNode;
action: () => void;
}) => {
const search = useCommandState((state) => state.search);
if (!search) return null;
return (
<CommandItem onSelect={action}>
{icon}
{label}
</CommandItem>
);
};
@@ -0,0 +1,239 @@
import { cn } from "@core/utils/cn.ts";
import {
CpuIcon,
Languages,
type LucideIcon,
Palette,
PenLine,
Search as SearchIcon,
ZapIcon,
} from "lucide-react";
import BatteryStatus from "./BatteryStatus.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Avatar } from "./UI/Avatar.tsx";
import type { DeviceMetrics } from "./types.ts";
import { Button } from "./UI/Button.tsx";
import React, { Fragment } from "react";
import { useTranslation } from "react-i18next";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import LanguageSwitcher from "./LanguageSwitcher.tsx";
interface DeviceInfoPanelProps {
isCollapsed: boolean;
deviceMetrics: DeviceMetrics;
firmwareVersion: string;
user: {
shortName: string;
longName: string;
};
setDialogOpen: () => void;
setCommandPaletteOpen: () => void;
disableHover?: boolean;
}
interface InfoDisplayItem {
id: string;
label: string;
icon?: LucideIcon;
customComponent?: React.ReactNode;
value?: string | number | null;
}
interface ActionButtonConfig {
id: string;
label: string;
icon: LucideIcon;
onClick?: () => void;
render?: () => React.ReactNode;
}
export const DeviceInfoPanel = ({
deviceMetrics,
firmwareVersion,
user,
isCollapsed,
setDialogOpen,
setCommandPaletteOpen,
disableHover = false,
}: DeviceInfoPanelProps) => {
const { t } = useTranslation();
const { batteryLevel, voltage } = deviceMetrics;
const deviceInfoItems: InfoDisplayItem[] = [
{
id: "battery",
label: t("batteryStatus.title"),
customComponent: <BatteryStatus deviceMetrics={deviceMetrics} />,
value: batteryLevel !== undefined ? `${batteryLevel}%` : "N/A",
},
{
id: "voltage",
label: t("batteryVoltage.title"),
icon: ZapIcon,
value: voltage !== undefined
? `${voltage?.toPrecision(3)} V`
: t("unknown.notAvailable", "N/A"),
},
{
id: "firmware",
label: t("sidebar.deviceInfo.firmware.title"),
icon: CpuIcon,
value: firmwareVersion ?? t("unknown.notAvailable", "N/A"),
},
];
const actionButtons: ActionButtonConfig[] = [
{
id: "changeName",
label: t("sidebar.deviceInfo.deviceName.changeName"),
icon: PenLine,
onClick: setDialogOpen,
},
{
id: "commandMenu",
label: t("page.title", { ns: "commandPalette" }),
icon: SearchIcon,
onClick: setCommandPaletteOpen,
},
{
id: "theme",
label: t("theme.changeTheme"),
icon: Palette,
render: () => <ThemeSwitcher />,
},
{
id: "language",
label: t("language.changeLanguage"),
icon: Languages,
render: () => <LanguageSwitcher />,
},
];
return (
<>
<div
className={cn(
"flex items-center gap-3 p-1 flex-shrink-0",
isCollapsed && "justify-center",
)}
>
<Avatar
text={user.shortName}
className={cn("flex-shrink-0", isCollapsed && "")}
size="sm"
/>
{!isCollapsed && (
<p
className={cn(
"text-sm font-medium text-gray-800 dark:text-gray-200",
"transition-opacity duration-300 ease-in-out truncate",
)}
>
{user.longName}
</p>
)}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
)}
<div
className={cn(
"flex flex-col gap-2 mt-1",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
: "opacity-100 max-w-xs h-auto visible",
)}
>
{deviceInfoItems.map((item) => {
const IconComponent = item.icon;
return (
<div
key={item.id}
className="flex items-center gap-2.5 text-sm"
>
{IconComponent && (
<IconComponent
size={16}
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/>
)}
{item.customComponent}
{item.id !== "battery" && (
<Subtle className="text-gray-600 dark:text-gray-300">
{item.label}: {item.value}
</Subtle>
)}
</div>
);
})}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
)}
<div
className={cn(
"flex flex-col gap-1 mt-1",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
: "opacity-100 max-w-xs visible",
)}
>
{actionButtons.map((buttonItem) => {
const Icon = buttonItem.icon;
if (buttonItem.render) {
return (
<Fragment key={buttonItem.id}>
{buttonItem.render()}
</Fragment>
);
}
return (
<Button
key={buttonItem.id}
variant="ghost"
aria-label={buttonItem.label}
onClick={buttonItem.onClick}
className={cn(
"group",
"flex w-full items-center justify-start text-sm p-1.5 rounded-md",
"gap-2.5",
"transition-colors duration-150",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
)}
>
<Icon
size={16}
className={cn(
"flex-shrink-0 w-4",
"text-gray-500 dark:text-gray-400",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
)}
/>
<Subtle
className={cn(
"text-sm",
"text-gray-600 dark:text-gray-300",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
{buttonItem.label}
</Subtle>
</Button>
);
})}
</div>
</>
);
};
@@ -0,0 +1,80 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({
deleteAllMessages: vi.fn(),
})),
}));
describe("DeleteMessagesDialog", () => {
const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn();
beforeEach(() => {
mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear();
const mockedUseMessageStore = vi.mocked(useMessageStore);
mockedUseMessageStore.mockImplementation(() => ({
deleteAllMessages: mockClearAllMessages,
}));
mockedUseMessageStore.mockClear();
});
it("calls onOpenChange with false when the close button (X) is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
const closeButton = screen.queryByTestId("dialog-close-button");
if (!closeButton) {
throw new Error(
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
);
}
fireEvent.click(closeButton);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("renders the dialog when open is true", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
expect(screen.getByText(/This action will clear all message history./))
.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear Messages" }))
.toBeInTheDocument();
});
it("does not render the dialog when open is false", () => {
render(
<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
);
expect(screen.queryByText("Clear All Messages")).toBeNull();
});
it("calls onOpenChange with false when the dismiss button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,65 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export interface DeleteMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeleteMessagesDialog = ({
open,
onOpenChange,
}: DeleteMessagesDialogProps) => {
const { t } = useTranslation("dialog");
const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose data-testid="dialog-close-button" />
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-warning" />
{t("deleteMessages.title")}
</DialogTitle>
<DialogDescription>
{t("deleteMessages.description")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={handleCloseDialog}
name="dismiss"
>
{t("button.dismiss")}
</Button>
<Button
variant="destructive"
onClick={() => {
deleteAllMessages();
handleCloseDialog();
}}
name="clearMessages"
>
{t("button.clearMessages")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,148 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { create } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { useTranslation } from "react-i18next";
import { Label } from "../UI/Label.tsx";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
export interface User {
longName: string;
shortName: string;
}
export interface DeviceNameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps) => {
const { t } = useTranslation("dialog");
const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const defaultValues = {
shortName: myNode?.user?.shortName ?? "",
longName: myNode?.user?.longName ?? "",
};
const deviceNameSchema = z.object({
longName: z
.string()
.min(1, t("deviceName.validation.longNameMin"))
.max(40, t("deviceName.validation.longNameMax")),
shortName: z
.string()
.min(2, t("deviceName.validation.shortNameMin"))
.max(4, t("deviceName.validation.shortNameMax")),
});
const { getValues, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
resolver: zodResolver(deviceNameSchema),
});
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...data,
}),
);
onOpenChange(false);
});
const handleReset = () => {
reset(defaultValues);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("deviceName.title")}</DialogTitle>
<DialogDescription>
{t("deviceName.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="longName">
{t("deviceName.longName")}
</Label>
<GenericInput
control={control}
field={{
name: "longName",
label: t("deviceName.longName"),
type: "text",
properties: {
className: "text-slate-900 dark:text-slate-200",
fieldLength: {
currentValueLength: getValues("longName").length,
max: 40,
min: 1,
showCharacterCount: true,
},
},
}}
/>
</div>
<div>
<Label htmlFor="shortName">
{t("deviceName.shortName")}
</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: t("deviceName.shortName"),
type: "text",
properties: {
fieldLength: {
currentValueLength: getValues("shortName").length,
max: 4,
min: 1,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="destructive"
name="reset"
onClick={handleReset}
>
{t("button.reset")}
</Button>
<Button
type="submit"
name="save"
>
{t("button.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,96 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
return (
<>
<QRDialog
open={dialog.QR}
onOpenChange={(open) => {
setDialogOpen("QR", open);
}}
channels={channels}
loraConfig={config.lora}
/>
<ImportDialog
open={dialog.import}
onOpenChange={(open) => {
setDialogOpen("import", open);
}}
loraConfig={config.lora}
/>
<ShutdownDialog
open={dialog.shutdown}
onOpenChange={() => {
setDialogOpen("shutdown", false);
}}
/>
<RebootDialog
open={dialog.reboot}
onOpenChange={() => {
setDialogOpen("reboot", false);
}}
/>
<DeviceNameDialog
open={dialog.deviceName}
onOpenChange={(open) => {
setDialogOpen("deviceName", open);
}}
/>
<RemoveNodeDialog
open={dialog.nodeRemoval}
onOpenChange={(open) => {
setDialogOpen("nodeRemoval", open);
}}
/>
<PkiBackupDialog
open={dialog.pkiBackup}
onOpenChange={(open) => {
setDialogOpen("pkiBackup", open);
}}
/>
<NodeDetailsDialog
open={dialog.nodeDetails}
onOpenChange={(open) => {
setDialogOpen("nodeDetails", open);
}}
/>
<UnsafeRolesDialog
open={dialog.unsafeRoles}
onOpenChange={(open) => {
setDialogOpen("unsafeRoles", open);
}}
/>
<RefreshKeysDialog
open={dialog.refreshKeys}
onOpenChange={(open) => {
setDialogOpen("refreshKeys", open);
}}
/>
<RebootOTADialog
open={dialog.rebootOTA}
onOpenChange={(open) => {
setDialogOpen("rebootOTA", open);
}}
/>
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("deleteMessages", open);
}}
/>
</>
);
};
@@ -0,0 +1,176 @@
import { create, fromBinary } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface ImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
loraConfig?: Protobuf.Config.Config_LoRaConfig;
}
export const ImportDialog = ({
open,
onOpenChange,
}: ImportDialogProps) => {
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false);
const { connection } = useDevice();
useEffect(() => {
// the channel information is contained in the URL's fragment, which will be present after a
// non-URL encoded `#`.
try {
const channelsUrl = new URL(importDialogInput);
if (
(channelsUrl.hostname !== "meshtastic.org" &&
channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash
) {
throw t("import.error.invalidUrl");
}
const encodedChannelConfig = channelsUrl.hash.substring(1);
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
.replace(/_/g, "/");
setChannelSet(
fromBinary(
Protobuf.AppOnly.ChannelSetSchema,
toByteArray(paddedString),
),
);
setValidUrl(true);
} catch (_error) {
setValidUrl(false);
setChannelSet(undefined);
}
}, [importDialogInput]);
const apply = () => {
channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
},
);
if (channelSet?.loraConfig) {
connection?.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: channelSet.loraConfig,
},
}),
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>
{t("import.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label>{t("import.channelSetUrl")}</Label>
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
onChange={(e) => {
setImportDialogInput(e.target.value);
}}
/>
{validUrl && (
<div className="flex flex-col gap-3">
<div className="flex w-full gap-2">
<div className="w-36">
<Label>{t("import.usePreset")}</Label>
<Switch
disabled
checked={channelSet?.loraConfig?.usePreset ?? true}
/>
</div>
{
/* <Select
label="Modem Preset"
disabled
value={channelSet?.loraConfig?.modemPreset}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</Select> */
}
</div>
{
/* <Select
label="Region"
disabled
value={channelSet?.loraConfig?.region}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</Select> */
}
<span className="text-md block font-medium text-text-primary">
{t("import.channels")}
</span>
<div className="flex w-40 flex-col gap-1">
{channelSet?.settings.map((channel) => (
<div className="flex justify-between" key={channel.id}>
<Label>
{channel.name.length
? channel.name
: `${t("import.channelPrefix")}${channel.id}`}
</Label>
<Checkbox key={channel.id} />
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button onClick={apply} disabled={!validUrl} name="apply">
{t("button.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,93 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
open: boolean;
onOpenChange: () => void;
}
export const LocationResponseDialog = ({
location,
open,
onOpenChange,
}: LocationResponseDialogProps) => {
const { t } = useTranslation("dialog");
const { getNode } = useDevice();
const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("locationResponse.title", {
identifier: `${longName} (${shortName})`,
})}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{hasCoordinates
? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{" "}
{position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")} {position.altitude}
{(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
)}
</DialogDescription>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,72 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Trans, useTranslation } from "react-i18next";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { useState } from "react";
export interface ManagedModeDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const ManagedModeDialog = ({
open,
onOpenChange,
onSubmit,
}: ManagedModeDialogProps) => {
const { t } = useTranslation("dialog");
const [confirmState, setConfirmState] = useState(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("managedMode.title")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="managedMode.description"
components={{
"bold": <p className="font-bold inline" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Checkbox
id="managedMode"
checked={confirmState}
onChange={() => setConfirmState(!confirmState)}
name="confirmUnderstanding"
>
<p className="dark:text-white pt-1">
{t("managedMode.confirmUnderstanding")}
</p>
</Checkbox>
</div>
<DialogFooter>
<Button
variant="destructive"
name="regenerate"
disabled={!confirmState}
onClick={() => {
setConfirmState(false);
onSubmit();
}}
>
{t("button.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,188 @@
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx";
export interface TabElementProps {
closeDialog: () => void;
}
export interface TabManifest {
id: "HTTP" | "BLE" | "Serial";
label: string;
element: React.FC<TabElementProps>;
isDisabled: boolean;
}
export interface NewDeviceProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface FeatureErrorProps {
missingFeatures: BrowserFeature[];
tabId: "HTTP" | "BLE" | "Serial";
}
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
"Web Bluetooth": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
},
"Web Serial": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
},
"Secure Context": {
href:
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
};
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null;
const browserFeatures = missingFeatures.filter(
(feature) => feature !== "Secure Context",
);
const needsSecureContext = missingFeatures.includes("Secure Context");
const needsFeature =
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
? "Web Bluetooth"
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
? "Web Serial"
: undefined;
return (
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<div className="text-sm text-white">
{needsFeature && (
<Trans
i18nKey={errors[needsFeature].i18nKey}
components={[
<Link
key="0"
href={errors[needsFeature].href}
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
/>,
]}
/>
)}
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<Trans
i18nKey={browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"}
components={{
"0": (
<Link
href={errors["Secure Context"].href}
className="underline hover:text-slate-200"
/>
),
}}
/>
)}
</div>
</div>
</div>
</div>
);
};
export const NewDeviceDialog = ({
open,
onOpenChange,
}: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [
{
id: "HTTP",
label: t("newDeviceDialog.tabHttp"),
element: HTTP,
isDisabled: false,
},
{
id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"),
},
{
id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial,
isDisabled: unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"),
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="HTTP">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}>
{(tab.id !== "HTTP" &&
tab.isDisabled)
? (
<ErrorMessage
missingFeatures={unsupported}
tabId={tab.id}
/>
)
: (
<tab.element
closeDialog={() => onOpenChange(false)}
/>
)}
</fieldset>
</TabsContent>
))}
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,379 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Uptime } from "@components/generic/Uptime.tsx";
import { toast } from "@core/hooks/useToast.ts";
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { cn } from "@core/utils/cn.ts";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
BellIcon,
BellOffIcon,
MapPinnedIcon,
MessageSquareIcon,
StarIcon,
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { t } = useTranslation("dialog");
const { setDialogOpen, connection, getNode } = useDevice();
const navigate = useNavigate();
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
const { updateFavorite } = useFavoriteNode();
const { updateIgnored } = useIgnoreNode();
const node = getNode(nodeNumDetails);
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(
node?.isFavorite ?? false,
);
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(
node?.isIgnored ?? false,
);
useEffect(() => {
if (!node) return;
setIsFavoriteState(node?.isFavorite);
setIsIgnoredState(node?.isIgnored);
}, [node]);
if (!node) return;
function handleDirectMessage() {
if (!node) return;
navigate({ to: `/messages/direct/${node.num}` });
setDialogOpen("nodeDetails", false);
}
function handleRequestPosition() {
if (!node) return;
toast({
title: t("toast.requestingPosition.title", { ns: "ui" }),
});
connection?.requestPosition(node.num).then(() =>
toast({
title: t("toast.positionRequestSent.title", { ns: "ui" }),
})
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return;
toast({
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
});
connection?.traceRoute(node.num).then(() =>
toast({
title: t("toast.tracerouteSent.title", { ns: "ui" }),
})
);
onOpenChange(false);
}
function handleNodeRemove() {
if (!node) return;
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
onOpenChange(false);
}
function handleToggleFavorite() {
if (!node) return;
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
setIsFavoriteState(!isFavoriteState);
}
function handleToggleIgnored() {
if (!node) return;
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
setIsIgnoredState(!isIgnoredState);
}
const deviceMetricsMap = [
{
key: "airUtilTx",
label: t("nodeDetails.airTxUtilization"),
value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "channelUtilization",
label: t("nodeDetails.channelUtilization"),
value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "batteryLevel",
label: t("nodeDetails.batteryLevel"),
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "voltage",
label: t("nodeDetails.voltage"),
value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("nodeDetails.title", {
identifier: `${node.user?.longName ?? t("unknown.shortName")} (${
node.user?.shortName ?? t("unknown.shortName")
})`,
})}
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<div className="flex flex-row flex-wrap space-y-1">
<Button
className="mr-1"
name="message"
onClick={handleDirectMessage}
>
<MessageSquareIcon className="mr-2" />
{t("nodeDetails.message")}
</Button>
<Button
className="mr-1"
name="traceRoute"
onClick={handleTraceroute}
>
<WaypointsIcon className="mr-2" />
{t("nodeDetails.traceRoute")}
</Button>
<Button className="mr-1" onClick={handleToggleFavorite}>
<StarIcon
className={cn(
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
)}
/>
</Button>
<div className="flex flex-1 justify-start"></div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
"flex justify-end mr-1 text-white",
isIgnoredState
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
: "",
)}
onClick={handleToggleIgnored}
>
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{isIgnoredState
? t("nodeDetails.unignoreNode")
: t("nodeDetails.ignoreNode")}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
className="flex justify-end"
onClick={handleNodeRemove}
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetails.removeNode")}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator className="mt-5 mb-2" />
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
<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">
<p className="text-lg font-semibold">
{t("nodeDetails.details")}
</p>
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
<p>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
</p>
<p>
{t("nodeDetails.role")}
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
{t("nodeDetails.lastHeard")}
{node.lastHeard === 0
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</p>
<p>
{t("nodeDetails.hardware")}
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
t("unknown.shortName"))
.replace(/_/g, " ")}
</p>
</div>
<DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[node.user?.hwModel ?? 0]}
/>
</div>
</div>
<div>
<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">
{t("nodeDetails.position")}
</p>
{node.position
? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</p>
)}
{node.position.altitude && (
<p>
{t("locationResponse.altitude")}
{node.position.altitude}
{t("unit.meter.one")}
</p>
)}
</>
)
: <p>{t("unknown.shortName")}</p>}
<Button
onClick={handleRequestPosition}
name="requestPosition"
className="mt-2"
>
<MapPinnedIcon className="mr-2" />
{t("nodeDetails.requestPosition")}
</Button>
</div>
{node.deviceMetrics && (
<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">
{t("nodeDetails.deviceMetrics")}
</p>
{deviceMetricsMap.map(
(metric) =>
metric.value !== undefined && (
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
</p>
),
)}
{node.deviceMetrics.uptimeSeconds && (
<p>
{t("nodeDetails.uptime")}
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p>
)}
</div>
)}
</div>
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
{t("nodeDetails.allRawMetrics")}
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(node, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,151 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
export interface PkiBackupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const PkiBackupDialog = ({
open,
onOpenChange,
}: PkiBackupDialogProps) => {
const { t } = useTranslation("dialog");
const { config, setDialogOpen, getMyNode } = useDevice();
const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey;
const decodeKeyData = React.useCallback(
(key: Uint8Array<ArrayBufferLike>) => {
if (!key) return "";
return fromByteArray(key ?? new Uint8Array(0));
},
[],
);
const closeDialog = React.useCallback(() => {
setDialogOpen("pkiBackup", false);
}, [setDialogOpen]);
const renderPrintWindow = React.useCallback(() => {
if (!privateKey || !publicKey) return;
const printWindow = globalThis.open("", "_blank");
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>${
t("pkiBackup.header", {
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; }
p { font-size: 14px; word-break: break-all; }
</style>
</head>
<body>
<h1>${
t("pkiBackup.header", {
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</h1>
<h3>${t("pkiBackup.secureBackup")}</h3>
<h3>${t("pkiBackup.publicKey")}</h3>
<p>${decodeKeyData(publicKey)}</p>
<h3>${t("pkiBackup.privateKey")}</h3>
<p>${decodeKeyData(privateKey)}</p>
<p>${t("pkiBackup.footer")}</p>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
closeDialog();
}
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return;
const decodedPrivateKey = decodeKeyData(privateKey);
const decodedPublicKey = decodeKeyData(publicKey);
const formattedContent = [
`${t("pkiBackup.header")}\n\n`,
`${t("pkiBackup.privateKey")}\n`,
decodedPrivateKey,
`\n\n${t("pkiBackup.publicKey")}\n`,
decodedPublicKey,
`\n\n${t("pkiBackup.footer")}`,
].join("");
const blob = new Blob([formattedContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = t("pkiBackup.fileName", {
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
});
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
closeDialog();
URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
<DialogDescription>
{t("pkiBackup.secureBackup")}
</DialogDescription>
<DialogDescription>
<span className="font-bold break-before-auto">
{t("pkiBackup.loseKeysWarning")}
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
variant="default"
name="download"
onClick={() => createDownloadKeyFile()}
className=""
>
<DownloadIcon size={20} className="mr-2" />
{t("button.download")}
</Button>
<Button variant="default" onClick={() => renderPrintWindow()}>
<PrinterIcon size={20} className="mr-2" />
{t("button.print")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,63 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useTranslation } from "react-i18next";
export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
};
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
text = {
title: "",
description: "",
button: "",
},
open,
onOpenChange,
onSubmit,
}: PkiRegenerateDialogProps) => {
const { t } = useTranslation("dialog");
const dialogText = {
title: text.title || t("pkiRegenerate.title"),
description: text.description ||
t("pkiRegenerate.description"),
button: text.button || t("button.regenerate"),
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{dialogText.title}</DialogTitle>
<DialogDescription>
{dialogText.description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="destructive"
name="regenerate"
onClick={() => onSubmit()}
>
{dialogText.button}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,151 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
export interface QRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
loraConfig?: Protobuf.Config.Config_LoRaConfig;
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
}
export const QRDialog = ({
open,
onOpenChange,
loraConfig,
channels,
}: QRDialogProps) => {
const { t } = useTranslation("dialog");
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => {
const channelsToEncode = allChannels
.filter((ch) => selectedChannels.includes(ch.index))
.map((channel) => channel.settings)
.filter((ch): ch is Protobuf.Channel.ChannelSettings => !!ch);
const encoded = create(
Protobuf.AppOnly.ChannelSetSchema,
create(Protobuf.AppOnly.ChannelSetSchema, {
loraConfig,
settings: channelsToEncode,
}),
);
const base64 = fromByteArray(
toBinary(Protobuf.AppOnly.ChannelSetSchema, encoded),
)
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
setQrCodeUrl(
`https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("qr.title")}</DialogTitle>
<DialogDescription>
{t("qr.description")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-3 px-4 py-5 sm:p-6">
<div className="flex w-40 flex-col gap-2">
{allChannels.map((channel) => (
<div className="flex justify-between" key={channel.index}>
<Label>
{channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? t("page.broadcastLabel", { ns: "channels" })
: `${
t("page.channelIndex", {
ns: "channels",
index: channel.index,
})
}${channel.index}`}
</Label>
<Checkbox
key={channel.index}
checked={selectedChannels.includes(channel.index)}
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) =>
c !== channel.index
),
);
} else {
setSelectedChannels([
...selectedChannels,
channel.index,
]);
}
}}
/>
</div>
))}
</div>
<QRCode value={qrCodeUrl} size={200} qrStyle="dots" />
</div>
<div className="flex justify-center">
<button
type="button"
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
name="addChannels"
onClick={() => setQrCodeAdd(true)}
>
{t("qr.addChannels")}
</button>
<button
type="button"
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
name="replaceChannels"
onClick={() => setQrCodeAdd(false)}
>
{t("qr.replaceChannels")}
</button>
</div>
</div>
<DialogFooter>
<Label>{t("qr.sharableUrl")}</Label>
<Input
value={qrCodeUrl}
disabled
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,63 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
export interface RebootDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RebootDialog = ({
open,
onOpenChange,
}: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(5);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("reboot.title")}
</DialogTitle>
<DialogDescription>
{t("reboot.description")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
<Input
type="number"
className="dark:text-slate-900"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
/>
<Button
className="w-24"
name="now"
onClick={() => {
connection?.reboot(2).then(() => onOpenChange(false));
}}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.now")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock,
};
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({
connection: mockConnection,
}),
}));
vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual("@components/UI/Button.tsx");
return {
...actual,
Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
};
});
vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual("@components/UI/Input.tsx");
return {
...actual,
Input: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLInputElement>
& InputHTMLAttributes<HTMLInputElement>,
) => <input {...props} />,
};
});
vi.mock("@components/UI/Dialog.tsx", () => {
return {
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
DialogClose: () => null,
};
});
describe("RebootOTADialog", () => {
beforeEach(() => {
vi.useFakeTimers();
rebootOtaMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }))
.toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
});
it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "3" },
});
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
vi.advanceTimersByTime(3000);
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
});
it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
});
it("does not call reboot if connection is undefined", async () => {
const onOpenChangeMock = vi.fn();
mockConnection = undefined;
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
vi.advanceTimersByTime(5000);
await waitFor(() => {
expect(rebootOtaMock).not.toHaveBeenCalled();
expect(onOpenChangeMock).not.toHaveBeenCalled();
});
mockConnection = { rebootOta: rebootOtaMock };
});
});
@@ -0,0 +1,116 @@
import { useState } from "react";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps,
) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) return;
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, delay * 1000);
}).finally(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
});
connection.rebootOta(0);
};
const handleInstantReboot = async () => {
if (!connection) return;
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("rebootOta.title")}
</DialogTitle>
<DialogDescription>
{t("rebootOta.description")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
className="dark:text-slate-900 appearance-none"
value={inputValue}
onChange={handleSetTime}
placeholder={t("rebootOta.enterDelay")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
</Button>
</div>
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.rebootOtaNow")}
</Button>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,49 @@
import { render } from "@testing-library/react";
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("does not render dialog if no error exists for active chat", () => {
const deviceId = 1;
const activeChatNum = 54321;
useDeviceStore.getState().addDevice(deviceId);
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
handleNodeRemove: vi.fn(),
});
const { container } = render(
<DeviceContext.Provider value={currentDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>,
);
expect(container.firstChild).toBeNull();
});
@@ -0,0 +1,95 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps,
) => {
const { t } = useTranslation("dialog");
const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) {
return null;
}
const nodeWithError = getNode(nodeErrorNum.node);
const text = {
title: t("refreshKeys.title", {
identifier: nodeWithError?.user?.longName ?? "",
}),
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
nodeWithError?.user?.longName ?? ""
} (${nodeWithError?.user?.shortName ?? ""})${
t("refreshKeys.description.keyMismatchReasonSuffix")
}`,
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-8 flex flex-col gap-2"
aria-describedby={undefined}
>
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>{text.title}</DialogTitle>
</DialogHeader>
{text.description}
<ul className="mt-2">
<li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1">
<LockKeyholeOpenIcon
size={30}
className="text-white justify-center"
/>
</div>
<div className="flex flex-col gap-2">
<div>
<p className="font-bold mb-0.5">
{t("refreshKeys.label.acceptNewKeys")}
</p>
<p>
{t("refreshKeys.description.acceptNewKeys")}
</p>
</div>
<Button
variant="default"
name="requestNewKeys"
onClick={handleNodeRemove}
>
{t("button.requestNewKeys")}
</Button>
<Button
variant="outline"
name="dismiss"
onClick={handleCloseDialog}
>
{t("button.dismiss")}
</Button>
</div>
</li>
</ul>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,28 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
useDevice();
const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => {
setDialogOpen("refreshKeys", false);
}, [setDialogOpen]);
const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat);
if (!nodeWithError) {
return;
}
clearNodeError(activeChat);
handleCloseDialog();
return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
return {
handleCloseDialog,
handleNodeRemove,
};
}
@@ -0,0 +1,62 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
import { useTranslation } from "react-i18next";
export interface RemoveNodeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RemoveNodeDialog = ({
open,
onOpenChange,
}: RemoveNodeDialogProps) => {
const { t } = useTranslation("dialog");
const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
connection?.removeNodeByNum(nodeNumToBeRemoved);
removeNode(nodeNumToBeRemoved);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("removeNode.title")}</DialogTitle>
<DialogDescription>
{t("removeNode.description")}
</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
</form>
</div>
<DialogFooter>
<Button
variant="destructive"
name="remove"
onClick={() => onSubmit()}
>
{t("button.remove")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,72 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, PowerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
export interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ShutdownDialog = ({
open,
onOpenChange,
}: ShutdownDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(5);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("shutdown.title")}
</DialogTitle>
<DialogDescription>
{t("shutdown.description")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
<Input
type="number"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
suffix={t("unit.minute.plural")}
/>
<Button
className="w-24"
onClick={() => {
connection?.shutdown(time * 60).then(() => onOpenChange(false));
}}
>
<ClockIcon size={16} />
</Button>
<Button
className="w-24"
name="now"
onClick={() => {
connection?.shutdown(2).then(() => () => onOpenChange(false));
}}
>
<PowerIcon className="mr-2" size={16} />
{t("button.now")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,71 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import { useTranslation } from "react-i18next";
export interface TracerouteResponseDialogProps {
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
open: boolean;
onOpenChange: () => void;
}
export const TracerouteResponseDialog = ({
traceroute,
open,
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { t } = useTranslation("dialog");
const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const fromLongName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const fromShortName = from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const toUser = getNode(traceroute?.to ?? 0);
if (!toUser || !from) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("tracerouteResponse.title", {
identifier: `${fromLongName} (${fromShortName})`,
})}
</DialogTitle>
</DialogHeader>
<DialogDescription>
<TraceRoute
route={route}
routeBack={routeBack}
from={{ user: from.user }}
to={{ user: toUser.user }}
snrTowards={snrTowards}
snrBack={snrBack}
/>
</DialogDescription>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,91 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useState } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useTranslation } from "react-i18next";
export interface RouterRoleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const UnsafeRolesDialog = (
{ open, onOpenChange }: RouterRoleDialogProps,
) => {
const { t } = useTranslation("dialog");
const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice();
const deviceRoleLink =
"https://meshtastic.org/docs/configuration/radio/device/";
const choosingTheRightDeviceRoleLink =
"https://meshtastic.org/blog/choosing-the-right-device-role/";
const handleCloseDialog = (action: "confirm" | "dismiss") => {
setDialogOpen("unsafeRoles", false);
setConfirmState(false);
eventBus.emit("dialog:unsafeRoles", { action });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col">
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
<DialogHeader>
<DialogTitle>{t("unsafeRoles.title")}</DialogTitle>
</DialogHeader>
<DialogDescription className="text-md">
{t("unsafeRoles.preamble")}
<Link href={deviceRoleLink} className="">
{t("unsafeRoles.deviceRoleDocumentation")}
</Link>
{t("unsafeRoles.conjunction")}
<Link href={choosingTheRightDeviceRoleLink}>
{t("unsafeRoles.choosingRightDeviceRole")}
</Link>
{t("unsafeRoles.postamble")}
</DialogDescription>
<div className="flex items-center gap-2">
<Checkbox
id="routerRole"
checked={confirmState}
onChange={() => setConfirmState(!confirmState)}
name="confirmUnderstanding"
>
<span className="dark:text-white">
{t("unsafeRoles.confirmUnderstanding")}
</span>
</Checkbox>
</div>
<DialogFooter className="mt-6">
<Button
variant="default"
name="dismiss"
onClick={() => handleCloseDialog("dismiss")}
>
{t("button.dismiss")}
</Button>
<Button
variant="default"
name="confirm"
disabled={!confirmState}
onClick={() => handleCloseDialog("confirm")}
>
{t("button.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,171 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from "vitest";
import { renderHook } from "@testing-library/react";
import {
UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
const mockNavigate = vi.fn();
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<
typeof import("@tanstack/react-router")
>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
vi.mock("@core/utils/eventBus", () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}));
const mockDevice = {
setDialogOpen: vi.fn(),
};
vi.mock("@core/stores/deviceStore", () => ({
useDevice: () => ({
setDialogOpen: mockDevice.setDialogOpen,
}),
}));
describe("useUnsafeRolesDialog", () => {
beforeEach(() => {
vi.resetAllMocks();
mockNavigate.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
const renderUnsafeRolesHook = () => {
return renderHook(() => useUnsafeRolesDialog());
};
describe("handleCloseDialog", () => {
it("should call setDialogOpen with correct parameters when dialog is closed", () => {
const { result } = renderUnsafeRolesHook();
result.current.handleCloseDialog();
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
false,
);
});
});
describe("validateRoleSelection", () => {
it("should resolve with true for safe roles without opening dialog", async () => {
const { result } = renderUnsafeRolesHook();
const safeRole = "SAFE_ROLE";
const validationResult = await result.current.validateRoleSelection(
safeRole,
);
expect(validationResult).toBe(true);
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
});
it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[0],
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
true,
);
expect(eventBus.on).toHaveBeenCalledWith(
"dialog:unsafeRoles",
expect.any(Function),
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: "confirm" });
const validationResult = await validationPromise;
expect(validationResult).toBe(true);
expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
});
it("should resolve with false when user dismisses the dialog", async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[0],
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: "dismiss" });
const validationResult = await validationPromise;
expect(validationResult).toBe(false);
expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
});
it("should clean up event listener after response", async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[1],
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: "confirm" });
await validationPromise;
expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
});
});
it("should work with all unsafe roles", async () => {
const { result } = renderUnsafeRolesHook();
for (const unsafeRole of UNSAFE_ROLES) {
mockDevice.setDialogOpen.mockClear();
(eventBus.on as Mock).mockClear();
const validationPromise = result.current.validateRoleSelection(
unsafeRole,
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
true,
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: "confirm" });
const validationResult = await validationPromise;
expect(validationResult).toBe(true);
}
});
});
@@ -0,0 +1,41 @@
import { useCallback } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"];
export type UnsafeRole = typeof UNSAFE_ROLES[number];
export const useUnsafeRolesDialog = () => {
const { setDialogOpen } = useDevice();
const handleCloseDialog = useCallback(() => {
setDialogOpen("unsafeRoles", false);
}, [setDialogOpen]);
const validateRoleSelection = useCallback(
(newRoleKey: string): Promise<boolean> => {
if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) {
return Promise.resolve(true);
}
setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => {
const handleResponse = (
{ action }: { action: "confirm" | "dismiss" },
) => {
eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm");
};
eventBus.on("dialog:unsafeRoles", handleResponse);
});
},
[setDialogOpen],
);
return {
handleCloseDialog,
validateRoleSelection,
};
};
@@ -0,0 +1,217 @@
import {
DynamicFormField,
type FieldProps,
} from "@components/Form/DynamicFormField.tsx";
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
type Control,
type DefaultValues,
type FieldValues,
FormProvider,
get,
type Path,
type SubmitHandler,
useForm,
type UseFormReturn,
} from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { ZodType } from "zod/v4";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { dotPaths } from "@core/utils/dotPath.ts";
interface DisabledBy<T> {
fieldName: Path<T>;
selector?: number;
invert?: boolean;
}
export interface BaseFormBuilderProps<T> {
name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[];
label: string;
description?: string;
notes?: string;
validationText?: string;
properties?: Record<string, unknown>;
}
export interface GenericFormElementProps<T extends FieldValues, Y> {
control: Control<T>;
disabled?: boolean;
field: Y;
isDirty?: boolean;
invalid?: boolean;
}
export interface DynamicFormProps<T extends FieldValues> {
propMethods?: UseFormReturn<T, T, T>;
onSubmit: SubmitHandler<T>;
onFormInit?: DynamicFormFormInit<T>;
submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean;
defaultValues?: DefaultValues<T>;
values?: T;
fieldGroups: {
label: string;
description: string;
notes?: string;
valid?: boolean;
validationText?: string;
fields: FieldProps<T>[];
}[];
validationSchema?: ZodType<T>;
formId?: string;
}
export type DynamicFormFormInit<T extends FieldValues> = (
methods: UseFormReturn<T, T, T>,
) => void;
export function DynamicForm<T extends FieldValues>({
propMethods,
onSubmit,
onFormInit,
submitType = "onChange",
hasSubmitButton,
defaultValues,
values,
fieldGroups,
validationSchema,
formId,
}: DynamicFormProps<T>) {
const { t } = useTranslation();
const {
addError,
removeError,
} = useAppStore();
let methods = propMethods;
if (!methods) {
methods = useForm<
T
>({
mode: "onChange",
defaultValues: defaultValues,
resolver: validationSchema
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values,
});
}
const { handleSubmit, control, getValues, formState, getFieldState } =
methods;
useEffect(() => {
if (!propMethods) {
onFormInit?.(methods);
}
}, [onFormInit, propMethods, methods]);
useEffect(() => {
const errorKeys = Object.keys(formState.errors);
if (formId) {
if (
errorKeys.length === 0
) {
dotPaths(getValues()).forEach((key) => {
removeError(key);
});
removeError(formId);
} else {
errorKeys.forEach((key) => {
addError(key, "");
});
addError(formId, "");
}
}
}, [formState.errors]);
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false;
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value;
if (typeof value === "number") {
return field.invert
? field.selector !== value
: field.selector === value;
}
return false;
});
};
return (
<FormProvider {...methods}>
<form
className="space-y-8"
{...(submitType === "onSubmit"
? { onSubmit: handleSubmit(onSubmit) }
: { onChange: handleSubmit(onSubmit) })}
>
{fieldGroups.map((fieldGroup) => (
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
<div>
<Heading as="h4" className="font-medium">
{fieldGroup.label}
</Heading>
<Subtle>{fieldGroup.description}</Subtle>
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
</div>
{fieldGroup.fields.map((field) => {
const error = get(formState.errors, field.name as string);
return (
<FieldWrapper
key={field.label}
label={field.label}
fieldName={field.name}
description={field.description}
valid={!error}
validationText={error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: ""}
>
<DynamicFormField
field={field}
control={control}
disabled={isDisabled(field.disabledBy, field.disabled)}
isDirty={getFieldState(field.name).isDirty}
invalid={getFieldState(field.name).invalid}
/>
</FieldWrapper>
);
})}
</div>
))}
{hasSubmitButton && (
<Button
type="submit"
variant="outline"
disabled={!formState.isValid}
>
{t("button.submit")}
</Button>
)}
</form>
</FormProvider>
);
}
@@ -0,0 +1,100 @@
import {
type MultiSelectFieldProps,
MultiSelectInput,
} from "./FormMultiSelect.tsx";
import {
GenericInput,
type InputFieldProps,
} from "@components/Form/FormInput.tsx";
import {
PasswordGenerator,
type PasswordGeneratorProps,
} from "@components/Form/FormPasswordGenerator.tsx";
import {
type SelectFieldProps,
SelectInput,
} from "@components/Form/FormSelect.tsx";
import {
type ToggleFieldProps,
ToggleInput,
} from "@components/Form/FormToggle.tsx";
import type { Control, FieldValues } from "react-hook-form";
export type FieldProps<T> =
| InputFieldProps<T>
| SelectFieldProps<T>
| MultiSelectFieldProps<T>
| ToggleFieldProps<T>
| PasswordGeneratorProps<T>;
export interface DynamicFormFieldProps<T extends FieldValues> {
field: FieldProps<T>;
control: Control<T>;
disabled?: boolean;
isDirty?: boolean;
invalid?: boolean;
}
export function DynamicFormField<T extends FieldValues>({
field,
control,
disabled,
isDirty,
invalid,
}: DynamicFormFieldProps<T>) {
switch (field.type) {
case "text":
case "password":
case "number":
return (
<GenericInput
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "toggle":
return (
<ToggleInput
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "select":
return (
<SelectInput
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "passwordGenerator":
return (
<PasswordGenerator
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "multiSelect":
return (
<MultiSelectInput
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
}
}
@@ -0,0 +1,106 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Input } from "@components/UI/Input.tsx";
import type { ChangeEventHandler } from "react";
import { type FieldValues, useController } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
prefix?: string;
properties?: {
id?: string;
suffix?: string;
step?: number;
className?: string;
fieldLength?: {
min?: number;
max?: number;
currentValueLength?: number;
showCharacterCount?: boolean;
};
showPasswordToggle?: boolean;
showCopyButton?: boolean;
};
}
export function GenericInput<T extends FieldValues>({
control,
disabled,
field,
invalid,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {};
const {
field: controllerField,
fieldState: { error, isDirty },
} = useController({
name: field.name,
control,
rules: {
minLength: field.properties?.fieldLength?.min,
maxLength: field.properties?.fieldLength?.max,
},
});
const isInvalid = invalid || Boolean(error?.message);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (
field.properties?.fieldLength?.max &&
newValue.length > field.properties.fieldLength.max
) {
return;
}
if (field.inputChange) field.inputChange(e);
controllerField.onChange(
field.type === "number"
? Number.parseFloat(newValue).toString()
: newValue,
);
};
const currentLength = controllerField.value
? String(controllerField.value).length
: 0;
return (
<div className="relative w-full">
<Input
type={field.type}
step={field.properties?.step}
value={field.type === "number"
? String(controllerField.value)
: controllerField.value}
id={field.name}
onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle ||
field.type === "password"}
className={field.properties?.className}
{...restProperties}
disabled={disabled}
variant={error ? "invalid" : isDirty ? "dirty" : "default"}
/>
{fieldLength?.showCharacterCount && fieldLength.max && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
{currentLength}/{fieldLength.max}
</div>
)}
{isInvalid && (
<div className="absolute inset-y-12 bottom-0 flex items-center pr-3">
<p className="text-sm text-red-500">{error?.message ?? ""}</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { FieldValues } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
import { cn } from "@core/utils/cn.ts";
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "multiSelect";
placeholder?: string;
onValueChange: (name: string) => void;
isChecked: (name: string) => boolean;
value: string[];
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue:
| { [s: string]: string | number }
| typeof FLAGS_CONFIG;
formatEnumName?: boolean;
};
}
export function MultiSelectInput<T extends FieldValues>({
field,
isDirty,
invalid,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { t } = useTranslation("deviceConfig");
const { enumValue, className, ...remainingProperties } = field.properties;
const isNewConfigStructure =
typeof Object.values(enumValue)[0] === "object" &&
Object.values(enumValue)[0] !== null &&
"i18nKey" in Object.values(enumValue)[0];
const optionsToRender = Object.entries(enumValue).map(
([key, configOrValue]) => {
if (isNewConfigStructure) {
const config =
configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
return {
key,
display: t(config.i18nKey),
value: config.value,
};
}
return { key, display: key, value: configOrValue as number };
},
);
return (
<MultiSelect
className={cn([
className,
"rounded-md",
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-5" : "",
invalid ? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-5" : "",
])}
{...remainingProperties}
>
{optionsToRender.map((option) => {
return (
<MultiSelectItem
key={option.key}
name={option.key}
value={option.value.toString()}
checked={field.isChecked(option.key)}
onCheckedChange={() => field.onValueChange(option.key)}
>
{option.display}
</MultiSelectItem>
);
})}
</MultiSelect>
);
}
@@ -0,0 +1,74 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useEffect } from "react";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
id: string;
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange?: React.ChangeEventHandler<HTMLInputElement>;
selectChange?: (event: string) => void;
actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
showPasswordToggle?: boolean;
showCopyButton?: boolean;
}
export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
isDirty,
invalid,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle();
const { trigger } = useFormContext();
useEffect(() => {
trigger(field.name);
}, [field.devicePSKBitCount, field.name, trigger]);
return (
<Controller
name={field.name}
control={control}
render={(
{
field: { value, onChange, ...rest },
},
) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(e);
}}
selectChange={field.selectChange ?? (() => {})}
value={value}
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}
{...field.properties}
{...rest}
disabled={disabled}
/>
)}
/>
);
}
@@ -0,0 +1,115 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { type FieldValues, useController } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
selectChange?: (e: string, name: string) => void;
validate?: (newValue: string) => Promise<boolean>;
defaultValue?: string;
properties: BaseFormBuilderProps<T>["properties"] & {
defaultValue?: T;
enumValue: {
[s: string]: string | number;
};
formatEnumName?: boolean;
};
}
const formatEnumDisplay = (name: string): string => {
return name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
};
export function SelectInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
const {
field: { value, onChange, ref, onBlur, ...rest },
} = useController({
name: field.name,
control,
});
const {
enumValue,
formatEnumName,
defaultValue,
className,
...remainingProperties
} = field.properties;
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];
if (enumValue) {
Object.entries(enumValue).forEach(([key, val]) => {
if (typeof val === "number") {
valueToKeyMap[val.toString()] = key;
optionsEnumValues.push([key, val]);
}
});
}
const handleValueChange = async (newValue: string) => {
const selectedKey = valueToKeyMap[newValue];
if (field.validate) {
const isValid = await field.validate(selectedKey);
if (!isValid) return;
}
if (field.selectChange) field.selectChange(newValue, selectedKey);
onChange(Number.parseInt(newValue));
};
return (
<Select
onValueChange={handleValueChange}
disabled={disabled}
value={value?.toString()}
defaultValue={defaultValue?.toString()}
{...rest}
>
<SelectTrigger
id={field.name}
className={cn([
className,
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
invalid
? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2"
: "",
])}
ref={ref}
onBlur={onBlur}
{...remainingProperties}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, val]) => (
<SelectItem key={name} value={val.toString()}>
{formatEnumName ? formatEnumDisplay(name) : name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,51 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Controller, type FieldValues } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";
inputChange?: (value: boolean) => void;
}
export function ToggleInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
return (
<Controller
name={field.name}
control={control}
render={(
{ field: { value, onChange, ...rest } },
) => (
<Switch
checked={value}
onCheckedChange={(v) => {
onChange(v);
field.inputChange?.(v);
}}
id={field.name}
disabled={disabled}
{...field.properties}
className={cn([
field.properties?.className,
isDirty
? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2"
: "",
invalid
? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-2"
: "",
])}
{...rest}
/>
)}
/>
);
}
@@ -0,0 +1,40 @@
import { Label } from "@components/UI/Label.tsx";
export interface FieldWrapperProps {
label: string;
fieldName: string;
description?: string;
disabled?: boolean;
children?: React.ReactNode;
valid?: boolean;
validationText?: string;
}
export const FieldWrapper = ({
label,
fieldName,
description,
children,
valid,
validationText,
}: FieldWrapperProps) => (
<div className="pt-6 sm:pt-5">
<fieldset aria-labelledby="label-notifications">
{/* first column = labels/heading, second column = fields, third column = gutter */}
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
<Label htmlFor={fieldName}>{label}</Label>
<div className="max-w-3xl">
<p className="text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4 sm:col-span-2">
<div className="flex items-center">{children}</div>
</div>
</div>
</div>
</fieldset>
</div>
);
@@ -0,0 +1,64 @@
import type { ZodType } from "zod/v4";
import type {
FieldError,
FieldValues,
Resolver,
ResolverOptions,
ResolverResult,
} from "react-hook-form";
export function createZodResolver<T extends FieldValues>(
schema: ZodType<T, unknown>,
): Resolver<T, unknown> {
return (
values: T,
_context: unknown,
_options?: ResolverOptions<T>,
): ResolverResult<T> => {
const result = schema.safeParse(values);
if (result.success) {
return {
values: result.data,
errors: {},
};
}
const errors: Record<
string,
FieldError & { params?: Record<string, unknown> }
> = {};
for (const issue of result.error.issues) {
const { path, code, message, ...params } = issue;
const key = path.join(".");
const suffix = "format" in params
? params.format
: "origin" in params
? params.origin
: "expected" in params
? params.expected
: "";
const newCode = code.replace(
/_([a-z])/g,
(_, char) => char.toUpperCase(),
) + (suffix ? `.${suffix}` : "");
const fieldError: FieldError & { params?: Record<string, unknown> } = {
type: newCode,
message: message,
...(Object.keys(params).length ? { params } : {}),
};
if (!errors[key]) {
errors[key] = fieldError;
}
}
return {
values: {} as T,
errors,
};
};
}
@@ -0,0 +1,16 @@
import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
export const KeyBackupReminder = () => {
const { setDialogOpen } = useDevice();
const { t } = useTranslation("dialog");
useBackupReminder({
message: t("pkiBackupReminder.description"),
onAccept: () => setDialogOpen("pkiBackup", true),
enabled: true,
});
// deno-lint-ignore jsx-no-useless-fragment
return <></>;
};
@@ -0,0 +1,87 @@
import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LangCode, supportedLanguages } from "@app/i18n-config.ts";
import useLang from "@core/hooks/useLang.ts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./UI/DropdownMenu.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { cn } from "@core/utils/cn.ts";
import { Button } from "./UI/Button.tsx";
interface LanguageSwitcherProps {
disableHover?: boolean;
}
export default function LanguageSwitcher({
disableHover = false,
}: LanguageSwitcherProps) {
const { i18n } = useTranslation("ui");
const { set: setLanguage, currentLanguage } = useLang();
const handleLanguageChange = async (languageCode: LangCode) => {
await setLanguage(languageCode, true);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
"group flex items-center justify-start",
"transition-colors duration-150 gap-2.5 p-1.5 rounded-md",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
)}
>
<Languages
size={16}
className={cn(
"text-gray-500 dark:text-gray-400 w-4 flex-shrink-0 transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
)}
/>
<Subtle
className={cn(
"text-sm text-gray-600 dark:text-gray-100 transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
{`${i18n.t("language.changeLanguage")}:`}
</Subtle>
<Subtle
className={cn(
"text-sm font-medium text-gray-700 dark:text-gray-200 transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-900 dark:group-hover:text-white",
)}
>
{currentLanguage?.name}
</Subtle>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
{supportedLanguages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className="flex items-center justify-between cursor-pointer"
>
<div className="flex items-center gap-2">
<span>{language.flag}</span>
<span>{language.name}</span>
</div>
{i18n.language === language.code && (
<Check className="h-4 w-4 text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+61
View File
@@ -0,0 +1,61 @@
import MapGl, {
AttributionControl,
type MapRef,
NavigationControl,
ScaleControl,
} from "react-map-gl/maplibre";
import { useTheme } from "@core/hooks/useTheme.ts";
import { useEffect, useRef } from "react";
interface MapProps {
children?: React.ReactNode;
onLoad?: (map: MapRef) => void;
}
export const Map = ({ children, onLoad }: MapProps) => {
const { theme } = useTheme();
const darkMode = theme === "dark";
const mapRef = useRef<MapRef | null>(null);
useEffect(() => {
const map = mapRef.current;
if (map && onLoad) onLoad(map);
}, [onLoad]);
return (
<MapGl
ref={mapRef}
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
attributionControl={false}
renderWorldCopies={false}
maxPitch={0}
dragRotate={false}
touchZoomRotate={false}
initialViewState={{
zoom: 1.8,
latitude: 35,
longitude: 0,
}}
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
>
<AttributionControl
style={{
background: darkMode ? "#ffffff" : undefined,
color: darkMode ? "black" : undefined,
}}
/>
{/* { Disabled for now until we can use i18n for the geolocate control} */}
{
/* <GeolocateControl
position="top-right"
i18nIsDynamicList
positionOptions={{ enableHighAccuracy: true }}
trackUserLocation
/> */
}
<NavigationControl position="top-right" showCompass={false} />
<ScaleControl />
{children}
</MapGl>
);
};
@@ -0,0 +1,276 @@
import { makeChannelSchema } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { infer as zodInfer } from "zod/v4";
export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
}
export const Channel = ({ channel }: SettingsPanelProps) => {
const { config, connection, addChannel } = useDevice();
const { t } = useTranslation(["channels", "ui", "dialog"]);
const { toast } = useToast();
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
false,
);
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
const [byteCount, setBytes] = useState<number>(
channel?.settings?.psk.length ?? 16,
);
const ChannelValidationSchema = useMemo(
() => {
return makeChannelSchema(byteCount);
},
[byteCount],
);
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
const onSubmit = (data: ChannelValidation) => {
const channel = create(Protobuf.Channel.ChannelSchema, {
...data,
settings: {
...data.settings,
psk: toByteArray(pass),
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
...data.settings.moduleSettings,
positionPrecision: data.settings.moduleSettings.positionPrecision,
}),
},
});
connection?.setChannel(channel).then(() => {
console.debug(t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}));
toast({
title: t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}),
});
addChannel(channel);
});
};
const preSharedKeyRegenerate = () => {
const newPsk = btoa(
cryptoRandomString({
length: byteCount ?? 0,
type: "alphanumeric",
}),
);
setPass(newPsk);
setPreSharedDialogOpen(false);
};
const preSharedClickEvent = () => {
setPreSharedDialogOpen(true);
};
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
setPass(e.currentTarget?.value);
};
const selectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setBytes(count);
};
return (
<>
<DynamicForm<ChannelValidation>
onSubmit={onSubmit}
submitType="onSubmit"
validationSchema={ChannelValidationSchema}
hasSubmitButton
defaultValues={{
...channel,
...{
settings: {
...channel?.settings,
psk: pass,
moduleSettings: {
...channel?.settings?.moduleSettings,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
},
},
}}
fieldGroups={[
{
label: t("settings.label"),
description: t("settings.description"),
fields: [
{
type: "select",
name: "role",
label: t("role.label"),
disabled: channel.index === 0,
description: t("role.description"),
properties: {
enumValue: channel.index === 0
? { [t("role.options.primary")]: 1 }
: {
[t("role.options.disabled")]: 0,
[t("role.options.secondary")]: 2,
},
},
},
{
type: "passwordGenerator",
name: "settings.psk",
id: "channel-psk",
label: t("psk.label"),
description: t("psk.description"),
devicePSKBitCount: byteCount ?? 0,
inputChange: inputChangeEvent,
selectChange: selectChangeEvent,
actionButtons: [
{
text: t("psk.generate"),
variant: "success",
onClick: preSharedClickEvent,
},
],
hide: true,
properties: {
value: pass,
showPasswordToggle: true,
showCopyButton: true,
},
},
{
type: "text",
name: "settings.name",
label: t("name.label"),
description: t("name.description"),
},
{
type: "toggle",
name: "settings.uplinkEnabled",
label: t("uplinkEnabled.label"),
description: t("uplinkEnabled.description"),
},
{
type: "toggle",
name: "settings.downlinkEnabled",
label: t("downlinkEnabled.label"),
description: t("downlinkEnabled.description"),
},
{
type: "select",
name: "settings.moduleSettings.positionPrecision",
label: t("positionPrecision.label"),
description: t("positionPrecision.description"),
properties: {
enumValue: config.display?.units === 0
? {
[t("positionPrecision.options.none")]: 0,
[
t("positionPrecision.options.metric_km23")
]: 10,
[
t("positionPrecision.options.metric_km12")
]: 11,
[
t("positionPrecision.options.metric_km5_8")
]: 12,
[
t("positionPrecision.options.metric_km2_9")
]: 13,
[
t("positionPrecision.options.metric_km1_5")
]: 14,
[
t("positionPrecision.options.metric_m700")
]: 15,
[
t("positionPrecision.options.metric_m350")
]: 16,
[
t("positionPrecision.options.metric_m200")
]: 17,
[
t("positionPrecision.options.metric_m90")
]: 18,
[
t("positionPrecision.options.metric_m50")
]: 19,
[
t("positionPrecision.options.precise")
]: 32,
}
: {
[t("positionPrecision.options.none")]: 0,
[
t("positionPrecision.options.imperial_mi15")
]: 10,
[
t("positionPrecision.options.imperial_mi7_3")
]: 11,
[
t("positionPrecision.options.imperial_mi3_6")
]: 12,
[
t("positionPrecision.options.imperial_mi1_8")
]: 13,
[
t("positionPrecision.options.imperial_mi0_9")
]: 14,
[
t("positionPrecision.options.imperial_mi0_5")
]: 15,
[
t("positionPrecision.options.imperial_mi0_2")
]: 16,
[
t("positionPrecision.options.imperial_ft600")
]: 17,
[
t("positionPrecision.options.imperial_ft300")
]: 18,
[
t("positionPrecision.options.imperial_ft150")
]: 19,
[
t("positionPrecision.options.precise")
]: 32,
},
},
},
],
},
]}
/>
<PkiRegenerateDialog
text={{
button: t("pkiRegenerateDialog.regenerate", { ns: "dialog" }),
title: t("pkiRegenerateDialog.title", { ns: "dialog" }),
description: t("pkiRegenerateDialog.description", { ns: "dialog" }),
}}
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}
/>
</>
);
};
@@ -0,0 +1,85 @@
import {
type BluetoothValidation,
BluetoothValidationSchema,
} from "@app/validation/config/bluetooth.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface BluetoothConfigProps {
onFormInit: DynamicFormFormInit<BluetoothValidation>;
}
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: BluetoothValidation) => {
if (deepCompareConfig(config.bluetooth, data, true)) {
removeWorkingConfig("bluetooth");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "bluetooth",
value: data,
},
}),
);
};
return (
<DynamicForm<BluetoothValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={BluetoothValidationSchema}
formId="Config_BluetoothConfig"
defaultValues={config.bluetooth}
values={getEffectiveConfig("bluetooth")}
fieldGroups={[
{
label: t("bluetooth.title"),
description: t("bluetooth.description"),
notes: t("bluetooth.note"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("bluetooth.enabled.label"),
description: t("bluetooth.enabled.description"),
},
{
type: "select",
name: "mode",
label: t("bluetooth.pairingMode.label"),
description: t("bluetooth.pairingMode.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode,
formatEnumName: true,
},
},
{
type: "number",
name: "fixedPin",
label: t("bluetooth.pin.label"),
description: t("bluetooth.pin.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,37 @@
import {
useDevice,
ValidConfigType,
ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useEffect, useState } from "react";
export function ConfigSuspender({
configCase,
moduleConfigCase,
children,
}: {
configCase?: ValidConfigType;
moduleConfigCase?: ValidModuleConfigType;
children: React.ReactNode;
}) {
const { config, moduleConfig } = useDevice();
let cfg = undefined;
if (configCase) {
cfg = config[configCase];
} else if (moduleConfigCase) {
cfg = moduleConfig[moduleConfigCase];
} else {
return children;
}
const [ready, setReady] = useState(() => cfg !== undefined);
useEffect(() => {
if (cfg !== undefined) setReady(true);
}, [cfg]);
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
return children;
}
@@ -0,0 +1,133 @@
import {
type DeviceValidation,
DeviceValidationSchema,
} from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DeviceConfigProps {
onFormInit: DynamicFormFormInit<DeviceValidation>;
}
export const Device = ({ onFormInit }: DeviceConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
if (deepCompareConfig(config.device, data, true)) {
removeWorkingConfig("device");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: data,
},
}),
);
};
return (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DeviceValidationSchema}
formId="Config_DeviceConfig"
defaultValues={config.device}
values={getEffectiveConfig("device")}
fieldGroups={[
{
label: t("device.title"),
description: t("device.description"),
fields: [
{
type: "select",
name: "role",
label: t("device.role.label"),
description: t("device.role.description"),
validate: validateRoleSelection,
properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
formatEnumName: true,
},
},
{
type: "number",
name: "buttonGpio",
label: t("device.buttonPin.label"),
description: t("device.buttonPin.description"),
},
{
type: "number",
name: "buzzerGpio",
label: t("device.buzzerPin.label"),
description: t("device.buzzerPin.description"),
},
{
type: "select",
name: "rebroadcastMode",
label: t("device.rebroadcastMode.label"),
description: t("device.rebroadcastMode.description"),
properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
formatEnumName: true,
},
},
{
type: "number",
name: "nodeInfoBroadcastSecs",
label: t("device.nodeInfoBroadcastInterval.label"),
description: t("device.nodeInfoBroadcastInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "doubleTapAsButtonPress",
label: t("device.doubleTapAsButtonPress.label"),
description: t("device.doubleTapAsButtonPress.description"),
},
{
type: "toggle",
name: "disableTripleClick",
label: t("device.disableTripleClick.label"),
description: t("device.disableTripleClick.description"),
},
{
type: "text",
name: "tzdef",
label: t("device.posixTimezone.label"),
description: t("device.posixTimezone.description"),
properties: {
fieldLength: {
max: 64,
currentValueLength: getEffectiveConfig("device")?.tzdef
?.length,
showCharacterCount: true,
},
},
},
{
type: "toggle",
name: "ledHeartbeatDisabled",
label: t("device.ledHeartbeatDisabled.label"),
description: t("device.ledHeartbeatDisabled.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,144 @@
import {
type DisplayValidation,
DisplayValidationSchema,
} from "@app/validation/config/display.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DisplayConfigProps {
onFormInit: DynamicFormFormInit<DisplayValidation>;
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: DisplayValidation) => {
if (deepCompareConfig(config.display, data, true)) {
removeWorkingConfig("display");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "display",
value: data,
},
}),
);
};
return (
<DynamicForm<DisplayValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DisplayValidationSchema}
formId="Config_DisplayConfig"
defaultValues={config.display}
values={getEffectiveConfig("display")}
fieldGroups={[
{
label: t("display.title"),
description: t("display.description"),
fields: [
{
type: "number",
name: "screenOnSecs",
label: t("display.screenTimeout.label"),
description: t("display.screenTimeout.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "select",
name: "gpsFormat",
label: t("display.gpsDisplayUnits.label"),
description: t("display.gpsDisplayUnits.description"),
properties: {
enumValue:
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
},
},
{
type: "number",
name: "autoScreenCarouselSecs",
label: t("display.carouselDelay.label"),
description: t("display.carouselDelay.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "compassNorthTop",
label: t("display.compassNorthTop.label"),
description: t("display.compassNorthTop.description"),
},
{
type: "toggle",
name: "use12hClock",
label: t("display.twelveHourClock.label"),
description: t("display.twelveHourClock.description"),
},
{
type: "toggle",
name: "flipScreen",
label: t("display.flipScreen.label"),
description: t("display.flipScreen.description"),
},
{
type: "select",
name: "units",
label: t("display.displayUnits.label"),
description: t("display.displayUnits.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits,
formatEnumName: true,
},
},
{
type: "select",
name: "oled",
label: t("display.oledType.label"),
description: t("display.oledType.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
},
},
{
type: "select",
name: "displaymode",
label: t("display.displayMode.label"),
description: t("display.displayMode.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
formatEnumName: true,
},
},
{
type: "toggle",
name: "headingBold",
label: t("display.headingBold.label"),
description: t("display.headingBold.description"),
},
{
type: "toggle",
name: "wakeOnTapOrMotion",
label: t("display.wakeOnTapOrMotion.label"),
description: t("display.wakeOnTapOrMotion.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,215 @@
import {
type LoRaValidation,
LoRaValidationSchema,
} from "@app/validation/config/lora.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface LoRaConfigProps {
onFormInit: DynamicFormFormInit<LoRaValidation>;
}
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: LoRaValidation) => {
if (deepCompareConfig(config.lora, data, true)) {
removeWorkingConfig("lora");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: data,
},
}),
);
};
return (
<DynamicForm<LoRaValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
formId="Config_LoRaConfig"
defaultValues={config.lora}
values={getEffectiveConfig("lora")}
fieldGroups={[
{
label: t("lora.title"),
description: t("lora.description"),
fields: [
{
type: "select",
name: "region",
label: t("lora.region.label"),
description: t("lora.region.description"),
properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
},
},
{
type: "select",
name: "hopLimit",
label: t("lora.hopLimit.label"),
description: t("lora.hopLimit.description"),
properties: {
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
},
},
{
type: "number",
name: "channelNum",
label: t("lora.frequencySlot.label"),
description: t("lora.frequencySlot.description"),
},
{
type: "toggle",
name: "ignoreMqtt",
label: t("lora.ignoreMqtt.label"),
description: t("lora.ignoreMqtt.description"),
},
{
type: "toggle",
name: "configOkToMqtt",
label: t("lora.okToMqtt.label"),
description: t("lora.okToMqtt.description"),
},
],
},
{
label: t("lora.waveformSettings.label"),
description: t("lora.waveformSettings.description"),
fields: [
{
type: "toggle",
name: "usePreset",
label: t("lora.usePreset.label"),
description: t("lora.usePreset.description"),
},
{
type: "select",
name: "modemPreset",
label: t("lora.modemPreset.label"),
description: t("lora.modemPreset.description"),
disabledBy: [
{
fieldName: "usePreset",
},
],
properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset,
formatEnumName: true,
},
},
{
type: "number",
name: "bandwidth",
label: t("lora.bandwidth.label"),
description: t("lora.bandwidth.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
properties: {
suffix: t("unit.megahertz"),
},
},
{
type: "number",
name: "spreadFactor",
label: t("lora.spreadingFactor.label"),
description: t("lora.spreadingFactor.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
properties: {
suffix: t("unit.cps"),
},
},
{
type: "number",
name: "codingRate",
label: t("lora.codingRate.label"),
description: t("lora.codingRate.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
},
],
},
{
label: t("lora.radioSettings.label"),
description: t("lora.radioSettings.description"),
fields: [
{
type: "toggle",
name: "txEnabled",
label: t("lora.transmitEnabled.label"),
description: t("lora.transmitEnabled.description"),
},
{
type: "number",
name: "txPower",
label: t("lora.transmitPower.label"),
description: t("lora.transmitPower.description"),
properties: {
suffix: t("unit.dbm"),
},
},
{
type: "toggle",
name: "overrideDutyCycle",
label: t("lora.overrideDutyCycle.label"),
description: t("lora.overrideDutyCycle.description"),
},
{
type: "number",
name: "frequencyOffset",
label: t("lora.frequencyOffset.label"),
description: t("lora.frequencyOffset.description"),
properties: {
suffix: t("unit.hertz"),
},
},
{
type: "toggle",
name: "sx126xRxBoostedGain",
label: t("lora.boostedRxGain.label"),
description: t("lora.boostedRxGain.description"),
},
{
type: "number",
name: "overrideFrequency",
label: t("lora.overrideFrequency.label"),
description: t("lora.overrideFrequency.description"),
properties: {
suffix: t("unit.megahertz"),
step: 0.001,
},
},
],
},
]}
/>
);
};
@@ -0,0 +1,248 @@
import {
type NetworkValidation,
NetworkValidationSchema,
} from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
convertIntToIpAddress,
convertIpAddressToInt,
} from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface NetworkConfigProps {
onFormInit: DynamicFormFormInit<NetworkValidation>;
}
export const Network = ({ onFormInit }: NetworkConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const networkConfig = getEffectiveConfig("network");
const onSubmit = (data: NetworkValidation) => {
const payload = {
...data,
ipv4Config: create(
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
{
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
},
),
};
if (deepCompareConfig(config.network, payload, true)) {
removeWorkingConfig("network");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "network",
value: payload,
},
}),
);
};
return (
<DynamicForm<NetworkValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema}
formId="Config_NetworkConfig"
defaultValues={{
...config.network,
ipv4Config: {
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
config.network?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
config.network?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
values={{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
networkConfig?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
networkConfig?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols: networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation}
fieldGroups={[
{
label: t("network.title"),
description: t("network.description"),
notes: t("network.note"),
fields: [
{
type: "toggle",
name: "wifiEnabled",
label: t("network.wifiEnabled.label"),
description: t("network.wifiEnabled.description"),
},
{
type: "text",
name: "wifiSsid",
label: t("network.ssid.label"),
description: t("network.ssid.label"),
disabledBy: [
{
fieldName: "wifiEnabled",
},
],
},
{
type: "password",
name: "wifiPsk",
label: t("network.psk.label"),
description: t("network.psk.description"),
disabledBy: [
{
fieldName: "wifiEnabled",
},
],
},
],
},
{
label: t("network.ethernetConfigSettings.label"),
description: t("network.ethernetConfigSettings.description"),
fields: [
{
type: "toggle",
name: "ethEnabled",
label: t("network.ethernetEnabled.label"),
description: t("network.ethernetEnabled.description"),
},
],
},
{
label: t("network.ipConfigSettings.label"),
description: t("network.ipConfigSettings.description"),
fields: [
{
type: "select",
name: "addressMode",
label: t("network.addressMode.label"),
description: t("network.addressMode.description"),
properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode,
},
},
{
type: "text",
name: "ipv4Config.ip",
label: t("network.ip.label"),
description: t("network.ip.description"),
disabledBy: [
{
fieldName: "addressMode",
selector:
Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
{
type: "text",
name: "ipv4Config.gateway",
label: t("network.gateway.label"),
description: t("network.gateway.description"),
disabledBy: [
{
fieldName: "addressMode",
selector:
Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
{
type: "text",
name: "ipv4Config.subnet",
label: t("network.subnet.label"),
description: t("network.subnet.description"),
disabledBy: [
{
fieldName: "addressMode",
selector:
Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
{
type: "text",
name: "ipv4Config.dns",
label: t("network.dns.label"),
description: t("network.dns.description"),
disabledBy: [
{
fieldName: "addressMode",
selector:
Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
],
},
{
label: t("network.udpConfigSettings.label"),
description: t("network.udpConfigSettings.description"),
fields: [
{
type: "select",
name: "enabledProtocols",
label: t("network.meshViaUdp.label"),
properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
},
},
],
},
{
label: t("network.ntpConfigSettings.label"),
description: t("network.ntpConfigSettings.description"),
fields: [
{
type: "text",
name: "ntpServer",
label: t("network.ntpServer.label"),
},
],
},
{
label: t("network.rsyslogConfigSettings.label"),
description: t("network.rsyslogConfigSettings.description"),
fields: [
{
type: "text",
name: "rsyslogServer",
label: t("network.rsyslogServer.label"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,174 @@
import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
import {
type PositionValidation,
PositionValidationSchema,
} from "@app/validation/config/position.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PositionConfigProps {
onFormInit: DynamicFormFormInit<PositionValidation>;
}
export const Position = ({ onFormInit }: PositionConfigProps) => {
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
getEffectiveConfig("position")?.positionFlags ?? 0,
);
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PositionValidation) => {
if (deepCompareConfig(config.position, data, true)) {
removeWorkingConfig("position");
return;
}
return setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "position",
value: { ...data, positionFlags: flagsValue },
},
}),
);
};
const onPositonFlagChange = useCallback(
(name: string) => {
return toggleFlag(name as FlagName);
},
[toggleFlag],
);
return (
<DynamicForm<PositionValidation>
onSubmit={(data) => {
data.positionFlags = flagsValue;
return onSubmit(data);
}}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
formId="Config_PositionConfig"
defaultValues={config.position}
values={getEffectiveConfig("position")}
fieldGroups={[
{
label: t("position.title"),
description: t("position.description"),
fields: [
{
type: "toggle",
name: "positionBroadcastSmartEnabled",
label: t("position.smartPositionEnabled.label"),
description: t("position.smartPositionEnabled.description"),
},
{
type: "select",
name: "gpsMode",
label: t("position.gpsMode.label"),
description: t("position.gpsMode.description"),
properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
},
},
{
type: "toggle",
name: "fixedPosition",
label: t("position.fixedPosition.label"),
description: t("position.fixedPosition.description"),
},
{
type: "multiSelect",
name: "positionFlags",
value: activeFlags,
isChecked: (name: string) =>
activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange,
label: t("position.positionFlags.label"),
placeholder: t("position.flags.placeholder"),
description: t("position.positionFlags.description"),
properties: {
enumValue: getAllFlags(),
},
},
{
type: "number",
name: "rxGpio",
label: t("position.receivePin.label"),
description: t("position.receivePin.description"),
},
{
type: "number",
name: "txGpio",
label: t("position.transmitPin.label"),
description: t("position.transmitPin.description"),
},
{
type: "number",
name: "gpsEnGpio",
label: t("position.enablePin.label"),
description: t("position.enablePin.description"),
},
],
},
{
label: t("position.intervalsSettings.label"),
description: t("position.intervalsSettings.description"),
fields: [
{
type: "number",
name: "positionBroadcastSecs",
label: t("position.broadcastInterval.label"),
description: t("position.broadcastInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "gpsUpdateInterval",
label: t("position.gpsUpdateInterval.label"),
description: t("position.gpsUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "broadcastSmartMinimumDistance",
label: t("position.smartPositionMinDistance.label"),
description: t("position.smartPositionMinDistance.description"),
disabledBy: [
{
fieldName: "positionBroadcastSmartEnabled",
},
],
},
{
type: "number",
name: "broadcastSmartMinimumIntervalSecs",
label: t("position.smartPositionMinInterval.label"),
description: t("position.smartPositionMinInterval.description"),
disabledBy: [
{
fieldName: "positionBroadcastSmartEnabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,129 @@
import {
type PowerValidation,
PowerValidationSchema,
} from "@app/validation/config/power.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PowerConfigProps {
onFormInit: DynamicFormFormInit<PowerValidation>;
}
export const Power = ({ onFormInit }: PowerConfigProps) => {
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PowerValidation) => {
if (deepCompareConfig(config.power, data, true)) {
removeWorkingConfig("power");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "power",
value: data,
},
}),
);
};
return (
<DynamicForm<PowerValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PowerValidationSchema}
formId="Config_PowerConfig"
defaultValues={config.power}
values={getEffectiveConfig("power")}
fieldGroups={[
{
label: t("power.powerConfigSettings.label"),
description: t("power.powerConfigSettings.description"),
fields: [
{
type: "toggle",
name: "isPowerSaving",
label: t("power.powerSavingEnabled.label"),
description: t("power.powerSavingEnabled.description"),
},
{
type: "number",
name: "onBatteryShutdownAfterSecs",
label: t("power.shutdownOnBatteryDelay.label"),
description: t("power.shutdownOnBatteryDelay.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "adcMultiplierOverride",
label: t("power.adcMultiplierOverride.label"),
description: t("power.adcMultiplierOverride.description"),
properties: {
step: 0.0001,
},
},
{
type: "number",
name: "waitBluetoothSecs",
label: t("power.noConnectionBluetoothDisabled.label"),
description: t("power.noConnectionBluetoothDisabled.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "deviceBatteryInaAddress",
label: t("power.ina219Address.label"),
description: t("power.ina219Address.description"),
},
],
},
{
label: t("power.sleepSettings.label"),
description: t("power.sleepSettings.description"),
fields: [
{
type: "number",
name: "sdsSecs",
label: t("power.superDeepSleepDuration.label"),
description: t("power.superDeepSleepDuration.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "lsSecs",
label: t("power.lightSleepDuration.label"),
description: t("power.lightSleepDuration.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "minWakeSecs",
label: t("power.minimumWakeTime.label"),
description: t("power.minimumWakeTime.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
],
},
]}
/>
);
};
@@ -0,0 +1,313 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import {
type ParsedSecurity,
type RawSecurity,
RawSecuritySchema,
} from "@app/validation/config/security.ts";
import { useEffect, useState } from "react";
import { create } from "@bufbuild/protobuf";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { useTranslation } from "react-i18next";
import { type DefaultValues, useForm } from "react-hook-form";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
export const Security = ({ onFormInit }: SecurityConfigProps) => {
const {
config,
setWorkingConfig,
setDialogOpen,
getEffectiveConfig,
removeWorkingConfig,
} = useDevice();
const { removeError } = useAppStore();
const { t } = useTranslation("deviceConfig");
const securityConfig = getEffectiveConfig("security");
const defaultValues = {
...securityConfig,
...{
privateKey: fromByteArray(
securityConfig?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
securityConfig?.publicKey ?? new Uint8Array(0),
),
adminKey: [
fromByteArray(
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
),
],
},
};
const formMethods = useForm<RawSecurity>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<RawSecurity>,
resolver: createZodResolver(RawSecuritySchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
});
const { setValue, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
false,
);
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
false,
);
const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) return;
const payload: ParsedSecurity = {
...data,
privateKey: toByteArray(data.privateKey),
publicKey: toByteArray(data.publicKey),
adminKey: [
toByteArray(data.adminKey.at(0) ?? ""),
toByteArray(data.adminKey.at(1) ?? ""),
toByteArray(data.adminKey.at(2) ?? ""),
],
};
if (deepCompareConfig(config.security, payload, true)) {
removeWorkingConfig("security");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "security",
value: payload,
},
}),
);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
updatePublicKey(fromByteArray(privateKey));
};
const updatePublicKey = (privateKey: string) => {
try {
const publicKey = fromByteArray(
getX25519PublicKey(toByteArray(privateKey)),
);
setValue("privateKey", privateKey);
setValue("publicKey", publicKey);
removeError("privateKey");
removeError("publicKey");
setPrivateKeyDialogOpen(false);
} catch (_e) {
setValue("privateKey", privateKey);
}
};
const bits = [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
];
return (
<>
<DynamicForm<RawSecurity>
propMethods={formMethods}
onSubmit={onSubmit}
formId="Config_SecurityConfig"
fieldGroups={[
{
label: t("security.title"),
description: t("security.description"),
fields: [
{
type: "passwordGenerator",
id: "pskInput",
name: "privateKey",
label: t("security.privateKey.label"),
description: t("security.privateKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
inputChange: (e: React.ChangeEvent<HTMLInputElement>) => {
updatePublicKey(e.target.value);
},
actionButtons: [
{
text: t("button.generate"),
onClick: () => setPrivateKeyDialogOpen(true),
variant: "success",
},
{
text: t("button.backupKey"),
onClick: () => setDialogOpen("pkiBackup", true),
variant: "subtle",
},
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "text",
name: "publicKey",
label: t("security.publicKey.label"),
disabled: true,
description: t("security.publicKey.description"),
properties: {
showCopyButton: true,
},
},
],
},
{
label: t("security.adminSettings.label"),
description: t("security.adminSettings.description"),
fields: [
{
type: "passwordGenerator",
name: "adminKey.0",
id: "adminKey0Input",
label: t("security.primaryAdminKey.label"),
description: t("security.primaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "passwordGenerator",
name: "adminKey.1",
id: "adminKey1Input",
label: t("security.secondaryAdminKey.label"),
description: t("security.secondaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "passwordGenerator",
name: "adminKey.2",
id: "adminKey2Input",
label: t("security.tertiaryAdminKey.label"),
description: t("security.tertiaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "toggle",
name: "isManaged",
label: t("security.managed.label"),
description: t("security.managed.description"),
inputChange: (checked) => {
if (checked) {
setManagedModeDialogOpen(true);
}
setValue("isManaged", false);
},
},
{
type: "toggle",
name: "adminChannelEnabled",
label: t("security.adminChannelEnabled.label"),
description: t("security.adminChannelEnabled.description"),
},
],
},
{
label: t("security.loggingSettings.label"),
description: t("security.loggingSettings.description"),
fields: [
{
type: "toggle",
name: "debugLogApiEnabled",
label: t("security.enableDebugLogApi.label"),
description: t("security.enableDebugLogApi.description"),
},
{
type: "toggle",
name: "serialEnabled",
label: t("security.serialOutputEnabled.label"),
description: t("security.serialOutputEnabled.description"),
},
],
},
]}
/>
<PkiRegenerateDialog
text={{
button: t("button.regenerate"),
title: t("pkiRegenerate.title"),
description: t("pkiRegenerate.description"),
}}
open={privateKeyDialogOpen}
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
onSubmit={pkiRegenerate}
/>
<ManagedModeDialog
open={managedModeDialogOpen}
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
onSubmit={() => {
setValue("isManaged", true);
setManagedModeDialogOpen(false);
}}
/>
</>
);
};
@@ -0,0 +1,92 @@
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export const BLE = (
{ closeDialog }: TabElementProps,
) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
setBleDevices(await navigator.bluetooth.getDevices());
}, []);
useEffect(() => {
updateBleDeviceList();
}, [updateBleDeviceList]);
const onConnect = async (bleDevice: BluetoothDevice) => {
const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
};
return (
<fieldset
className="flex w-full flex-col gap-2 p-4"
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{bleDevices.map((device) => (
<Button
key={device.id}
variant="default"
onClick={() => {
setConnectionInProgress(true);
onConnect(device);
}}
>
{device.name}
</Button>
))}
{bleDevices.length === 0 && (
<Mono className="m-auto select-none">
{t("newDeviceDialog.bluetoothConnection.noDevicesPaired")}
</Mono>
)}
</div>
<Button
variant="default"
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
}).catch((error: Error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {
setConnectionInProgress(false);
});
}}
>
<span>{t("newDeviceDialog.bluetoothConnection.newDeviceButton")}</span>
</Button>
</fieldset>
);
};
@@ -0,0 +1,98 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
useDeviceStore: vi.fn(() => ({
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
})),
}));
vi.mock("@core/utils/randId.ts", () => ({
randId: vi.fn(() => "mock-id"),
}));
vi.mock("@meshtastic/transport-http", () => ({
TransportHTTP: {
create: vi.fn(() => Promise.resolve({})),
},
}));
vi.mock("@meshtastic/core", () => ({
MeshDevice: vi.fn(() => ({
configure: vi.fn(),
})),
}));
describe("HTTP Component", () => {
it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput);
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput);
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
it("enables HTTPS toggle when location protocol is https", () => {
Object.defineProperty(window, "location", {
value: { protocol: "https:" },
writable: true,
});
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked();
expect(screen.getByText("https://")).toBeInTheDocument();
});
it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();
try {
fireEvent.click(button);
await waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toBeCalled();
expect(TransportHTTP.create).toBeCalled();
expect(MeshDevice).toBeCalled();
});
} catch (e) {
console.error(e);
}
});
});
@@ -0,0 +1,166 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react";
import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
interface FormData {
ip: string;
tls: boolean;
}
export const HTTP = (
{ closeDialog }: TabElementProps,
) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false);
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
tls: isURLHTTPS ? true : false,
},
});
const {
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<
{ host: string; secure: boolean } | null
>(null);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportHTTP.create(data.ip, data.tls);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
} catch (error) {
if (error instanceof Error) {
console.error("Connection error:", error);
}
// Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls });
setConnectionInProgress(false);
}
});
return (
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
<fieldset
className="flex flex-col gap-2"
style={{ minHeight: "12rem" }}
disabled={connectionInProgress}
>
<div>
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
<Input
prefix={tlsValue
? `${t("newDeviceDialog.https")}://`
: `${t("newDeviceDialog.http")}://`}
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
className="text-slate-900 dark:text-slate-100"
{...register("ip")}
/>
</div>
<div className="mt-2 flex items-center gap-2">
<Switch
onCheckedChange={setTLS}
disabled={isURLHTTPS}
checked={isURLHTTPS || tlsValue}
{...register("tls")}
/>
<Label>{t("newDeviceDialog.useHttps")}</Label>
</div>
{connectionError && (
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
<div className="flex gap-2 items-start">
<AlertTriangle
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
size={20}
/>
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
{t("newDeviceDialog.connectionFailedAlert.title")}
</p>
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")}
{connectionError.secure &&
t("newDeviceDialog.connectionFailedAlert.httpsHint")}
{t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")}
<Link
href={`${
connectionError.secure
? t("newDeviceDialog.https")
: t("newDeviceDialog.http")
}://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{`${
connectionError.secure
? t("newDeviceDialog.https")
: t("newDeviceDialog.http")
}://${connectionError.host}`}
</Link>{" "}
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
{connectionError.secure
? t(
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{t("newDeviceDialog.connectionFailedAlert.learnMoreLink")}
</Link>
</p>
</div>
</div>
</div>
)}
</fieldset>
<Button
type="submit"
variant="default"
>
<span>
{connectionInProgress
? t("newDeviceDialog.connecting")
: t("newDeviceDialog.connect")}
</span>
</Button>
</form>
);
};
@@ -0,0 +1,103 @@
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export const Serial = (
{ closeDialog }: TabElementProps,
) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator.serial.getPorts());
}, []);
navigator.serial.addEventListener("connect", () => {
updateSerialPortList();
});
navigator.serial.addEventListener("disconnect", () => {
updateSerialPortList();
});
useEffect(() => {
updateSerialPortList();
}, [updateSerialPortList]);
const onConnect = async (port: SerialPort) => {
const id = randId();
const device = addDevice(id);
setSelectedDevice(id);
const transport = await TransportWebSerial.createFromPort(port);
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
};
return (
<fieldset
className="flex w-full flex-col gap-2 p-4"
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => {
const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId ?? t("unknown.shortName");
const product = usbProductId ?? t("unknown.shortName");
return (
<Button
key={`${vendor}-${product}-${index}`}
disabled={port.readable !== null}
variant="default"
onClick={async () => {
setConnectionInProgress(true);
await onConnect(port);
// No need to setConnectionInProgress(false) here as closeDialog() unmounts.
}}
>
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
index: index,
vendorId: vendor,
productId: product,
})}
</Button>
);
})}
{serialPorts.length === 0 && (
<Mono className="m-auto select-none">
{t("newDeviceDialog.serialConnection.noDevicesPaired")}
</Mono>
)}
</div>
<Button
variant="default"
onClick={async () => {
await navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error: Error) => {
console.error("Error requesting port:", error);
}).finally(() => {
setConnectionInProgress(false);
});
}}
>
<span>{t("newDeviceDialog.serialConnection.newDeviceButton")}</span>
</Button>
</fieldset>
);
};
@@ -0,0 +1,216 @@
import { Separator } from "@components/UI/Seperator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@core/utils/string.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/core";
import {
Dot,
LockIcon,
LockOpenIcon,
MessageSquareIcon,
MountainSnow,
Star,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
}
export const NodeDetail = ({ node }: NodeDetailProps) => {
const navigate = useNavigate();
const { t } = useTranslation("nodes");
const name = node.user?.longName ?? t("unknown.shortName");
const shortName = node.user?.shortName ?? t("unknown.shortName");
const hwModel = node.user?.hwModel ?? 0;
const rawHardwareType = Protobuf.Mesh.HardwareModel[hwModel] as
| keyof typeof Protobuf.Mesh.HardwareModel
| undefined;
const hardwareType = rawHardwareType
? rawHardwareType === "UNSET"
? t("unset")
: rawHardwareType.replaceAll("_", " ")
: `${hwModel}`;
function handleDirectMessage() {
navigate({ to: `/messages/direct/${node.num}` });
}
return (
<div className="p-1 text-slate-900">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} size="sm" />
<div
onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
}}
>
{node.user?.publicKey && node.user?.publicKey.length > 0
? (
<LockIcon
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_public_key_enabled_aria_label")}
/>
)
: (
<LockOpenIcon
className="text-yellow-500 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_no_public_key_aria_label")}
/>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageSquareIcon
size={14}
onClick={handleDirectMessage}
className="cursor-pointer hover:text-blue-500"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{t("nodeDetail.directMessage.label", {
shortName,
})}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite
? t("nodeDetail.favorite.label")
: t("nodeDetail.notFavorite.label")}
/>
</div>
</div>
<div>
<Heading as="h5">{name}</Heading>
{hardwareType !== t("unset") && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && (
<BatteryStatus deviceMetrics={node.deviceMetrics} />
)}
<div className="flex gap-2 items-center">
{node.user?.shortName && <div>"{node.user?.shortName}"</div>}
{node.user?.id && <div>{node.user?.id}</div>}
</div>
<div
className="flex gap-1"
title={new Date(node.lastHeard * 1000).toLocaleString(
navigator.language,
)}
>
<div>
{node.lastHeard > 0 && (
<div>
{t("nodeDetail.status.heard")}{" "}
<TimeAgo timestamp={node.lastHeard * 1000} />
</div>
)}
</div>
{node.viaMqtt && (
<div style={{ color: "#660066" }} className="font-medium">
{t("nodeDetail.status.mqtt")}
</div>
)}
</div>
</div>
</div>
<Separator className="my-1" />
<div className="flex mt-2 text-sm">
<div className="flex items-center grow">
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
{Number.isNaN(node.hopsAway)
? t("unit.hopsAway.unknown")
: node.hopsAway}
</div>
<div>
{node.hopsAway === 1 ? t("unit.hops.one") : t("unit.hop.plural")}
</div>
</div>
{node.position?.altitude && (
<div className="flex items-center grow">
<MountainSnow
size={15}
className="ml-2 mr-1"
aria-label={t("node_detail_elevation_aria_label")}
/>
<div>
{formatQuantity(node.position?.altitude, {
one: t("unit.meter.one"),
other: t("unit.meter.plural"),
})}
</div>
</div>
)}
</div>
<div className="flex mt-2">
{!!node.deviceMetrics?.channelUtilization && (
<div className="grow">
<div>{t("nodeDetail.channelUtilization")}</div>
<Mono>
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
</Mono>
</div>
)}
{!!node.deviceMetrics?.airUtilTx && (
<div className="grow">
<div>{t("nodeDetail.airTxUtilization")}</div>
<Mono className="text-gray-500">
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
</Mono>
</div>
)}
</div>
{node.snr !== 0 && (
<div className="mt-2">
<div>{t("unit.snr")}</div>
<Mono className="flex items-center text-xs text-gray-500">
{node.snr}
{t("unit.dbm")}
<Dot />
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
<Dot />
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
</div>
)}
</div>
);
};
@@ -0,0 +1,39 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { InboxIcon } from "lucide-react";
import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
export interface ChannelChatProps {
messages?: Message[];
}
const EmptyState = () => {
const { t } = useTranslation("messages");
return (
<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" />
<span className="text-sm">{t("emptyState.text")}</span>
</div>
);
};
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
if (!messages?.length) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState />
</div>
);
}
return (
<ul className="flex flex-col-reverse flex-grow overflow-y-auto px-3 py-2 ">
{messages?.map((message) => (
<MessageItem
key={message.messageId ?? `${message.from}-${message.date}`}
message={message}
/>
))}
</ul>
);
};
@@ -0,0 +1,95 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts";
import { Reply, SmilePlus } from "lucide-react";
import { useTranslation } from "react-i18next";
interface MessageActionsMenuProps {
onAddReaction?: () => void;
onReply?: () => void;
}
export const MessageActionsMenu = ({
onAddReaction,
onReply,
}: MessageActionsMenuProps) => {
const { t } = useTranslation();
const hoverIconBarClass = cn(
"absolute top-2 right-2",
"flex items-center gap-x-1",
"bg-white dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-600",
"rounded-md shadow-sm p-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-100 ease-in-out",
"z-10",
);
const hoverIconButtonClass = cn(
"p-1 rounded",
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-zinc-700",
"cursor-pointer",
);
const iconSizeClass = "size-4";
return (
<div
className={cn(hoverIconBarClass)}
onClick={(e) => e.stopPropagation()}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("messages_actionsMenu_addReactionLabel")}
onClick={(e) => {
e.stopPropagation();
if (onAddReaction) {
onAddReaction();
}
}}
className={hoverIconButtonClass}
>
<SmilePlus className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{t("messages_actionsMenu_addReactionLabel")}
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("messages_actionsMenu_replyLabel")}
onClick={(e) => {
e.stopPropagation();
if (onReply) {
onReply();
}
}}
className={hoverIconButtonClass}
>
<Reply className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{t("messages_actionsMenu_replyLabel")}
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
@@ -0,0 +1,254 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
import { Types } from "@meshtastic/core";
vi.mock("@components/UI/Button.tsx", () => ({
Button: vi.fn((
{ type, className, children, onClick, onSubmit, ...rest },
) => (
<button
type={type}
className={className}
onClick={onClick}
onSubmit={onSubmit}
{...rest}
>
{children}
</button>
)),
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: vi.fn((
{ autoFocus, minLength, name, placeholder, value, onChange },
) => (
<input
autoFocus={autoFocus}
minLength={minLength}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
data-testid="message-input-field"
/>
)),
}));
const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
})),
MessageState: {
Ack: "ack",
Waiting: "waiting",
Failed: "failed",
},
MessageType: {
Direct: "direct",
Broadcast: "broadcast",
},
}));
vi.mock("lucide-react", () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
}));
describe("MessageInput", () => {
const mockOnSend = vi.fn();
const defaultProps: MessageInputProps = {
onSend: mockOnSend,
to: 123,
maxBytes: 256,
};
beforeEach(() => {
vi.clearAllMocks();
mockGetDraft.mockReturnValue("");
});
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...defaultProps} {...props} />);
};
it("should render the input field, byte counter, and send button", () => {
renderComponent();
expect(screen.getByTestId("message-input-field")).toBeInTheDocument();
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
});
it("should initialize with the draft from the store", () => {
const initialDraft = "Existing draft message";
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : "";
});
renderComponent();
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
});
it("should update input value, byte counter, and call setDraft on change within limits", () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const testMessage = "Hello there!";
const expectedBytes = new Blob([testMessage]).size;
fireEvent.change(inputElement, { target: { value: testMessage } });
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
});
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByTestId("message-input-field");
const initialValue = "12345";
const excessiveValue = "123456";
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
mockSetDraft.mockClear();
fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${smallMaxBytes}/${smallMaxBytes}`,
);
expect(mockSetDraft).not.toHaveBeenCalled();
});
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const testMessage = "Send this message";
fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe("");
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`0/${defaultProps.maxBytes}`,
);
expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
it("should trim whitespace before calling onSend", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const testMessageWithWhitespace = " Trim me! ";
const expectedTrimmedMessage = "Trim me!";
fireEvent.change(inputElement, {
target: { value: testMessageWithWhitespace },
});
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
it("should not call onSend or clearDraft if input is empty on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
expect((inputElement as HTMLInputElement).value).toBe("");
fireEvent.submit(formElement);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
});
it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const whitespaceMessage = " \t ";
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
fireEvent.submit(formElement);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
});
it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) =>
key === broadcastDest ? "Broadcast draft" : ""
);
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect(
(screen.getByTestId(
"message-input-field",
) as HTMLInputElement).value,
).toBe("Broadcast draft");
const inputElement = screen.getByTestId(
"message-input-field",
) as HTMLInputElement;
const formElement = screen.getByRole("form");
const newMessage = "New broadcast msg";
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
fireEvent.submit(formElement);
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
});
});
@@ -0,0 +1,88 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination;
maxBytes: number;
}
export const MessageInput = ({
onSend,
to,
maxBytes,
}: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore();
const { t } = useTranslation("messages");
const calculateBytes = (text: string) => new Blob([text]).size;
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() =>
calculateBytes(initialDraft)
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const byteLength = calculateBytes(newValue);
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
setMessageBytes(byteLength);
setDraft(to, newValue);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) return;
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
startTransition(() => {
onSend(localDraft.trim());
setLocalDraft("");
clearDraft(to);
});
};
return (
<div className="flex gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<label className="w-full">
<Input
autoFocus
minLength={1}
name="messageInput"
placeholder={t("sendMessage.placeholder")}
autoComplete="off"
value={localDraft}
onChange={handleInputChange}
/>
</label>
<label
data-testid="byte-counter"
className="flex items-center w-20 p-1 text-sm place-content-end"
>
{messageBytes}/{maxBytes}
</label>
<Button
type="submit"
variant="default"
>
<SendIcon size={16} />
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,209 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react";
import {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
interface MessageStatusInfo {
displayText: string;
icon: LucideIcon;
ariaLabel: string;
iconClassName?: string;
}
const StatusTooltip = (
{ statusInfo, children }: {
statusInfo: MessageStatusInfo;
children: ReactNode;
},
) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{statusInfo.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
interface MessageItemProps {
message: Message;
}
export const MessageItem = ({ message }: MessageItemProps) => {
const { getNode } = useDevice();
const { getMyNodeNum } = useMessageStore();
const { t, i18n } = useTranslation("messages");
const MESSAGE_STATUS_MAP = useMemo(
(): Record<MessageState, MessageStatusInfo> => ({
[MessageState.Ack]: {
displayText: t("deliveryStatus.delivered.displayText"),
icon: CheckCircle2,
ariaLabel: t("deliveryStatus.delivered.label"),
iconClassName: "text-green-500",
},
[MessageState.Waiting]: {
displayText: t("deliveryStatus.waiting.displayText"),
icon: CircleEllipsis,
ariaLabel: t("deliveryStatus.waiting.label"),
iconClassName: "text-slate-400",
},
[MessageState.Failed]: {
displayText: t("deliveryStatus.failed.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.failed.label"),
iconClassName: "text-red-500 dark:text-red-400",
},
}),
[t],
);
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
displayText: t("deliveryStatus.unknown.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.unknown.label"),
iconClassName: "text-red-500 dark:text-red-400",
}), [t]);
const getMessageStatusInfo = useMemo(
() => (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
);
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const { displayName, shortName, isFavorite } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
const last4 = userIdHex.slice(-4);
const fallbackName = t("fallbackName", { last4 });
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
const isFavorite = messageUser?.num !== myNodeNum &&
messageUser?.isFavorite;
return {
displayName: derivedDisplayName,
shortName: derivedShortName,
isFavorite: isFavorite,
};
}, [messageUser, message.from, t, myNodeNum]);
const messageStatusInfo = getMessageStatusInfo(message.state);
const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(
() => message.date ? new Date(message.date) : null,
[message.date],
);
const locale = i18n.language;
const formattedTime = useMemo(
() =>
messageDate?.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}) ?? "",
[messageDate, locale],
);
const fullDateTime = useMemo(
() =>
messageDate?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
}) ?? "",
[messageDate, locale],
);
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none",
"rounded-md",
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out",
);
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
return (
<li className={messageItemWrapperClass}>
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<Avatar
size="sm"
text={shortName}
className="pt-0.5"
showFavorite={isFavorite}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
{displayName}
</span>
{messageDate && (
<time
dateTime={messageDate.toISOString()}
className={dateTextStyle}
>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDateTime}</span>
</time>
)}
{shouldShowStatusIcon && (
<StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent
className={cn(
"size-4 shrink-0",
messageStatusInfo.iconClassName,
)}
aria-hidden="true"
/>
</span>
</StatusTooltip>
)}
</div>
{message?.message && (
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
{message.message}
</div>
)}
</div>
</div>
{/* Actions Menu Placeholder */}
{
/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */
}
</li>
);
};
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const fromUser = {
user: {
$typeName: "meshtastic.User",
longName: "Source Node",
publicKey: new Uint8Array([1, 2, 3]),
shortName: "Source",
hwModel: 1,
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
id: "source-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
} as Protobuf.Mesh.NodeInfo["user"],
};
const toUser = {
user: {
$typeName: "meshtastic.User",
longName: "Destination Node",
publicKey: new Uint8Array([4, 5, 6]),
shortName: "Destination",
hwModel: 2,
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
id: "destination-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
} as Protobuf.Mesh.NodeInfo["user"],
};
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[
1,
{
num: 1,
user: { longName: "Node A", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
[
2,
{
num: 2,
user: { longName: "Node B", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
[
3,
{
num: 3,
user: { longName: "Node C", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
]);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(useDevice).mockReturnValue({
...mockDeviceStore,
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
});
});
it("renders the route to destination with SNR values", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[1, 2]}
snrTowards={[10, 20, 30]}
/>,
);
expect(screen.getByText("Source Node")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3);
expect(screen.getByText("↓ 10dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 20dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 30dBm")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[1]}
snrTowards={[15, 25]}
routeBack={[3]}
snrBack={[35, 45]}
/>,
);
// Check for the translated title
expect(screen.getByText("Route back:")).toBeInTheDocument();
// With route back, both names appear twice
expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[1]}
/>,
);
expect(screen.getByText("Node A")).toBeInTheDocument();
// Check for translated '??' placeholder
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
});
});
@@ -0,0 +1,88 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
type NodeUser = Pick<Protobuf.Mesh.NodeInfo, "user">;
export interface TraceRouteProps {
from: NodeUser;
to: NodeUser;
route: Array<number>;
routeBack?: Array<number>;
snrTowards?: Array<number>;
snrBack?: Array<number>;
}
interface RoutePathProps {
title: string;
from: NodeUser;
to: NodeUser;
path: number[];
snr?: number[];
}
const RoutePath = (
{ title, from, to, path, snr }: RoutePathProps,
) => {
const { getNode } = useDevice();
const { t } = useTranslation();
return (
<span
id={title}
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
>
<p className="font-semibold">{title}</p>
<p>{from?.user?.longName}</p>
<p>
↓ {snr?.[0] ?? t("unknown.num")}
{t("unit.dbm")}
</p>
{path.map((hop, i) => (
<span key={getNode(hop)?.num ?? hop}>
<p>
{getNode(hop)?.user?.longName ??
`${t("traceRoute.nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`}
</p>
<p>
↓ {snr?.[i + 1] ?? t("unknown.num")}
{t("unit.dbm")}
</p>
</span>
))}
<p>{to?.user?.longName}</p>
</span>
);
};
export const TraceRoute = ({
from,
to,
route,
routeBack,
snrTowards,
snrBack,
}: TraceRouteProps) => {
const { t } = useTranslation("dialog");
return (
<div className="ml-5 flex">
<RoutePath
title={t("traceRoute.routeToDestination")}
to={to}
from={from}
path={route}
snr={snrTowards}
/>
{routeBack && routeBack.length > 0 && (
<RoutePath
title={t("traceRoute.routeBack")}
to={from}
from={to}
path={routeBack}
snr={snrBack}
/>
)}
</div>
);
};
@@ -0,0 +1,94 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import {
type AmbientLightingValidation,
AmbientLightingValidationSchema,
} from "@app/validation/moduleConfig/ambientLighting.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AmbientLightingModuleConfigProps {
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
}
export const AmbientLighting = (
{ onFormInit }: AmbientLightingModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => {
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
removeWorkingModuleConfig("ambientLighting");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "ambientLighting",
value: data,
},
}),
);
};
return (
<DynamicForm<AmbientLightingValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AmbientLightingValidationSchema}
formId="ModuleConfig_AmbientLightingConfig"
defaultValues={moduleConfig.ambientLighting}
values={getEffectiveModuleConfig("ambientLighting")}
fieldGroups={[
{
label: t("ambientLighting.title"),
description: t("ambientLighting.description"),
fields: [
{
type: "toggle",
name: "ledState",
label: t("ambientLighting.ledState.label"),
description: t("ambientLighting.ledState.description"),
},
{
type: "number",
name: "current",
label: t("ambientLighting.current.label"),
description: t("ambientLighting.current.description"),
},
{
type: "number",
name: "red",
label: t("ambientLighting.red.label"),
description: t("ambientLighting.red.description"),
},
{
type: "number",
name: "green",
label: t("ambientLighting.green.label"),
description: t("ambientLighting.green.description"),
},
{
type: "number",
name: "blue",
label: t("ambientLighting.blue.label"),
description: t("ambientLighting.blue.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,108 @@
import {
type AudioValidation,
AudioValidationSchema,
} from "@app/validation/moduleConfig/audio.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AudioModuleConfigProps {
onFormInit: DynamicFormFormInit<AudioValidation>;
}
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => {
if (deepCompareConfig(moduleConfig.audio, data, true)) {
removeWorkingModuleConfig("audio");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "audio",
value: data,
},
}),
);
};
return (
<DynamicForm<AudioValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AudioValidationSchema}
formId="ModuleConfig_AudioConfig"
defaultValues={moduleConfig.audio}
values={getEffectiveModuleConfig("audio")}
fieldGroups={[
{
label: t("audio.title"),
description: t("audio.description"),
fields: [
{
type: "toggle",
name: "codec2Enabled",
label: t("audio.codec2Enabled.label"),
description: t("audio.codec2Enabled.description"),
},
{
type: "number",
name: "pttPin",
label: t("audio.pttPin.label"),
description: t("audio.pttPin.description"),
},
{
type: "select",
name: "bitrate",
label: t("audio.bitrate.label"),
description: t("audio.bitrate.description"),
properties: {
enumValue:
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
},
},
{
type: "number",
name: "i2sWs",
label: t("audio.i2sWs.label"),
description: t("audio.i2sWs.description"),
},
{
type: "number",
name: "i2sSd",
label: t("audio.i2sSd.label"),
description: t("audio.i2sSd.description"),
},
{
type: "number",
name: "i2sDin",
label: t("audio.i2sDin.label"),
description: t("audio.i2sDin.description"),
},
{
type: "number",
name: "i2sSck",
label: t("audio.i2sSck.label"),
description: t("audio.i2sSck.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,142 @@
import {
type CannedMessageValidation,
CannedMessageValidationSchema,
} from "@app/validation/moduleConfig/cannedMessage.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface CannedMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
}
export const CannedMessage = (
{ onFormInit }: CannedMessageModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => {
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
removeWorkingModuleConfig("cannedMessage");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "cannedMessage",
value: data,
},
}),
);
};
return (
<DynamicForm<CannedMessageValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={CannedMessageValidationSchema}
formId="ModuleConfig_CannedMessageConfig"
defaultValues={moduleConfig.cannedMessage}
values={getEffectiveModuleConfig("cannedMessage")}
fieldGroups={[
{
label: t("cannedMessage.title"),
description: t("cannedMessage.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("cannedMessage.moduleEnabled.label"),
description: t("cannedMessage.moduleEnabled.description"),
},
{
type: "toggle",
name: "rotary1Enabled",
label: t("cannedMessage.rotary1Enabled.label"),
description: t("cannedMessage.rotary1Enabled.description"),
},
{
type: "number",
name: "inputbrokerPinA",
label: t("cannedMessage.inputbrokerPinA.label"),
description: t("cannedMessage.inputbrokerPinA.description"),
},
{
type: "number",
name: "inputbrokerPinB",
label: t("cannedMessage.inputbrokerPinB.label"),
description: t("cannedMessage.inputbrokerPinB.description"),
},
{
type: "number",
name: "inputbrokerPinPress",
label: t("cannedMessage.inputbrokerPinPress.label"),
description: t("cannedMessage.inputbrokerPinPress.description"),
},
{
type: "select",
name: "inputbrokerEventCw",
label: t("cannedMessage.inputbrokerEventCw.label"),
description: t("cannedMessage.inputbrokerEventCw.description"),
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "select",
name: "inputbrokerEventCcw",
label: t("cannedMessage.inputbrokerEventCcw.label"),
description: t("cannedMessage.inputbrokerEventCcw.description"),
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "select",
name: "inputbrokerEventPress",
label: t("cannedMessage.inputbrokerEventPress.label"),
description: t("cannedMessage.inputbrokerEventPress.description"),
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "toggle",
name: "updown1Enabled",
label: t("cannedMessage.updown1Enabled.label"),
description: t("cannedMessage.updown1Enabled.description"),
},
{
type: "text",
name: "allowInputSource",
label: t("cannedMessage.allowInputSource.label"),
description: t("cannedMessage.allowInputSource.description"),
},
{
type: "toggle",
name: "sendBell",
label: t("cannedMessage.sendBell.label"),
description: t("cannedMessage.sendBell.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,158 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import {
type DetectionSensorValidation,
DetectionSensorValidationSchema,
} from "@app/validation/moduleConfig/detectionSensor.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DetectionSensorModuleConfigProps {
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
}
export const DetectionSensor = (
{ onFormInit }: DetectionSensorModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => {
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
removeWorkingModuleConfig("detectionSensor");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "detectionSensor",
value: data,
},
}),
);
};
return (
<DynamicForm<DetectionSensorValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DetectionSensorValidationSchema}
formId="ModuleConfig_DetectionSensorConfig"
defaultValues={moduleConfig.detectionSensor}
values={getEffectiveModuleConfig("detectionSensor")}
fieldGroups={[
{
label: t("detectionSensor.title"),
description: t("detectionSensor.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("detectionSensor.enabled.label"),
description: t("detectionSensor.enabled.description"),
},
{
type: "number",
name: "minimumBroadcastSecs",
label: t("detectionSensor.minimumBroadcastSecs.label"),
description: t(
"detectionSensor.minimumBroadcastSecs.description",
),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "stateBroadcastSecs",
label: t("detectionSensor.stateBroadcastSecs.label"),
description: t("detectionSensor.stateBroadcastSecs.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "sendBell",
label: t("detectionSensor.sendBell.label"),
description: t("detectionSensor.sendBell.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "name",
label: t("detectionSensor.name.label"),
description: t("detectionSensor.name.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "monitorPin",
label: t("detectionSensor.monitorPin.label"),
description: t("detectionSensor.monitorPin.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "select",
name: "detectionTriggerType",
label: t("detectionSensor.detectionTriggerType.label"),
description: t(
"detectionSensor.detectionTriggerType.description",
),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_DetectionSensorConfig_TriggerType,
},
},
{
type: "toggle",
name: "usePullup",
label: t("detectionSensor.usePullup.label"),
description: t("detectionSensor.usePullup.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,234 @@
import {
type ExternalNotificationValidation,
ExternalNotificationValidationSchema,
} from "@app/validation/moduleConfig/externalNotification.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface ExternalNotificationModuleConfigProps {
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
}
export const ExternalNotification = (
{ onFormInit }: ExternalNotificationModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => {
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
removeWorkingModuleConfig("externalNotification");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "externalNotification",
value: data,
},
}),
);
};
return (
<DynamicForm<ExternalNotificationValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={ExternalNotificationValidationSchema}
formId="ModuleConfig_ExternalNotificationConfig"
defaultValues={moduleConfig.externalNotification}
values={getEffectiveModuleConfig("externalNotification")}
fieldGroups={[
{
label: t("externalNotification.title"),
description: t("externalNotification.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("externalNotification.enabled.label"),
description: t("externalNotification.enabled.description"),
},
{
type: "number",
name: "outputMs",
label: t("externalNotification.outputMs.label"),
description: t("externalNotification.outputMs.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.millisecond.suffix"),
},
},
{
type: "number",
name: "output",
label: t("externalNotification.output.label"),
description: t("externalNotification.output.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputVibra",
label: t("externalNotification.outputVibra.label"),
description: t("externalNotification.outputVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputBuzzer",
label: t("externalNotification.outputBuzzer.label"),
description: t("externalNotification.outputBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "active",
label: t("externalNotification.active.label"),
description: t("externalNotification.active.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessage",
label: t("externalNotification.alertMessage.label"),
description: t("externalNotification.alertMessage.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageVibra",
label: t("externalNotification.alertMessageVibra.label"),
description: t(
"externalNotification.alertMessageVibra.description",
),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageBuzzer",
label: t("externalNotification.alertMessageBuzzer.label"),
description: t(
"externalNotification.alertMessageBuzzer.description",
),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBell",
label: t("externalNotification.alertBell.label"),
description: t("externalNotification.alertBell.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellVibra",
label: t("externalNotification.alertBellVibra.label"),
description: t("externalNotification.alertBellVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellBuzzer",
label: t("externalNotification.alertBellBuzzer.label"),
description: t(
"externalNotification.alertBellBuzzer.description",
),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "usePwm",
label: t("externalNotification.usePwm.label"),
description: t("externalNotification.usePwm.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "nagTimeout",
label: t("externalNotification.nagTimeout.label"),
description: t("externalNotification.nagTimeout.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "useI2sAsBuzzer",
label: t("externalNotification.useI2sAsBuzzer.label"),
description: t("externalNotification.useI2sAsBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,292 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import {
type MqttValidation,
MqttValidationSchema,
} from "@app/validation/moduleConfig/mqtt.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface MqttModuleConfigProps {
onFormInit: DynamicFormFormInit<MqttValidation>;
}
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
const {
config,
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: MqttValidation) => {
const payload = {
...data,
mapReportSettings: create(
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
data.mapReportSettings,
),
};
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
removeWorkingModuleConfig("mqtt");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: payload,
},
}),
);
};
const populateDefaultValues = (
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
) => {
return cfg
? {
...cfg,
mapReportSettings: cfg.mapReportSettings ??
{ publishIntervalSecs: 0, positionPrecision: 10 },
}
: undefined;
};
return (
<DynamicForm<MqttValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={MqttValidationSchema}
formId="ModuleConfig_MqttConfig"
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
fieldGroups={[
{
label: t("mqtt.title"),
description: t("mqtt.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("mqtt.enabled.label"),
description: t("mqtt.enabled.description"),
},
{
type: "text",
name: "address",
label: t("mqtt.address.label"),
description: t("mqtt.address.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "username",
label: t("mqtt.username.label"),
description: t("mqtt.username.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "password",
name: "password",
label: t("mqtt.password.label"),
description: t("mqtt.password.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "encryptionEnabled",
label: t("mqtt.encryptionEnabled.label"),
description: t("mqtt.encryptionEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "jsonEnabled",
label: t("mqtt.jsonEnabled.label"),
description: t("mqtt.jsonEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "tlsEnabled",
label: t("mqtt.tlsEnabled.label"),
description: t("mqtt.tlsEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "root",
label: t("mqtt.root.label"),
description: t("mqtt.root.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "proxyToClientEnabled",
label: t("mqtt.proxyToClientEnabled.label"),
description: t("mqtt.proxyToClientEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "mapReportingEnabled",
label: t("mqtt.mapReportingEnabled.label"),
description: t("mqtt.mapReportingEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "mapReportSettings.publishIntervalSecs",
label: t("mqtt.mapReportSettings.publishIntervalSecs.label"),
description: t(
"mqtt.mapReportSettings.publishIntervalSecs.description",
),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
{
fieldName: "mapReportingEnabled",
},
],
},
{
type: "select",
name: "mapReportSettings.positionPrecision",
label: t(
"mqtt.mapReportSettings.positionPrecision.label",
),
description: t(
"mqtt.mapReportSettings.positionPrecision.description",
),
properties: {
enumValue: config.display?.units === 0
? {
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")
]: 10,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")
]: 11,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")
]: 12,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")
]: 13,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")
]: 14,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")
]: 15,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m350")
]: 16,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m200")
]: 17,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m90")
]: 18,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m50")
]: 19,
}
: {
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")
]: 10,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")
]: 11,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")
]: 12,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")
]: 13,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")
]: 14,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")
]: 15,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2")
]: 16,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft600")
]: 17,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft300")
]: 18,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft150")
]: 19,
},
},
disabledBy: [
{
fieldName: "enabled",
},
{
fieldName: "mapReportingEnabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,82 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import {
type NeighborInfoValidation,
NeighborInfoValidationSchema,
} from "@app/validation/moduleConfig/neighborInfo.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface NeighborInfoModuleConfigProps {
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
}
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => {
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
removeWorkingModuleConfig("neighborInfo");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "neighborInfo",
value: data,
},
}),
);
};
return (
<DynamicForm<NeighborInfoValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NeighborInfoValidationSchema}
formId="ModuleConfig_NeighborInfoConfig"
defaultValues={moduleConfig.neighborInfo}
values={getEffectiveModuleConfig("neighborInfo")}
fieldGroups={[
{
label: t("neighborInfo.title"),
description: t("neighborInfo.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("neighborInfo.enabled.label"),
description: t("neighborInfo.enabled.description"),
},
{
type: "number",
name: "updateInterval",
label: t("neighborInfo.updateInterval.label"),
description: t("neighborInfo.updateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,104 @@
import {
type PaxcounterValidation,
PaxcounterValidationSchema,
} from "@app/validation/moduleConfig/paxcounter.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PaxcounterModuleConfigProps {
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
}
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => {
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
removeWorkingModuleConfig("paxcounter");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "paxcounter",
value: data,
},
}),
);
};
return (
<DynamicForm<PaxcounterValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PaxcounterValidationSchema}
formId="ModuleConfig_PaxcounterConfig"
defaultValues={moduleConfig.paxcounter}
values={getEffectiveModuleConfig("paxcounter")}
fieldGroups={[
{
label: t("paxcounter.title"),
description: t("paxcounter.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("paxcounter.enabled.label"),
description: t("paxcounter.enabled.description"),
},
{
type: "number",
name: "paxcounterUpdateInterval",
label: t("paxcounter.paxcounterUpdateInterval.label"),
description: t("paxcounter.paxcounterUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "wifiThreshold",
label: t("paxcounter.wifiThreshold.label"),
description: t("paxcounter.wifiThreshold.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "bleThreshold",
label: t("paxcounter.bleThreshold.label"),
description: t("paxcounter.bleThreshold.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,93 @@
import {
type RangeTestValidation,
RangeTestValidationSchema,
} from "@app/validation/moduleConfig/rangeTest.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface RangeTestModuleConfigProps {
onFormInit: DynamicFormFormInit<RangeTestValidation>;
}
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => {
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
removeWorkingModuleConfig("rangeTest");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "rangeTest",
value: data,
},
}),
);
};
return (
<DynamicForm<RangeTestValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={RangeTestValidationSchema}
formId="ModuleConfig_RangeTestConfig"
defaultValues={moduleConfig.rangeTest}
values={getEffectiveModuleConfig("rangeTest")}
fieldGroups={[
{
label: t("rangeTest.title"),
description: t("rangeTest.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("rangeTest.enabled.label"),
description: t("rangeTest.enabled.description"),
},
{
type: "number",
name: "sender",
label: t("rangeTest.sender.label"),
description: t("rangeTest.sender.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "save",
label: t("rangeTest.save.label"),
description: t("rangeTest.save.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,154 @@
import {
type SerialValidation,
SerialValidationSchema,
} from "@app/validation/moduleConfig/serial.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface SerialModuleConfigProps {
onFormInit: DynamicFormFormInit<SerialValidation>;
}
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => {
if (deepCompareConfig(moduleConfig.serial, data, true)) {
removeWorkingModuleConfig("serial");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "serial",
value: data,
},
}),
);
};
return (
<DynamicForm<SerialValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={SerialValidationSchema}
formId="ModuleConfig_SerialConfig"
defaultValues={moduleConfig.serial}
values={getEffectiveModuleConfig("serial")}
fieldGroups={[
{
label: t("serial.title"),
description: t("serial.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("serial.enabled.label"),
description: t("serial.enabled.description"),
},
{
type: "toggle",
name: "echo",
label: t("serial.echo.label"),
description: t("serial.echo.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "rxd",
label: t("serial.rxd.label"),
description: t("serial.rxd.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "txd",
label: t("serial.txd.label"),
description: t("serial.txd.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "select",
name: "baud",
label: t("serial.baud.label"),
description: t("serial.baud.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue:
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
},
},
{
type: "number",
name: "timeout",
label: t("serial.timeout.label"),
description: t("serial.timeout.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "select",
name: "mode",
label: t("serial.mode.label"),
description: t("serial.mode.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue:
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
formatEnumName: true,
},
},
{
type: "toggle",
name: "overrideConsoleSerialPort",
label: t("serial.overrideConsoleSerialPort.label"),
description: t("serial.overrideConsoleSerialPort.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,115 @@
import {
type StoreForwardValidation,
StoreForwardValidationSchema,
} from "@app/validation/moduleConfig/storeForward.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface StoreForwardModuleConfigProps {
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
}
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => {
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
removeWorkingModuleConfig("storeForward");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "storeForward",
value: data,
},
}),
);
};
return (
<DynamicForm<StoreForwardValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={StoreForwardValidationSchema}
formId="ModuleConfig_StoreForwardConfig"
defaultValues={moduleConfig.storeForward}
values={getEffectiveModuleConfig("storeForward")}
fieldGroups={[
{
label: t("storeForward.title"),
description: t("storeForward.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("storeForward.enabled.label"),
description: t("storeForward.enabled.description"),
},
{
type: "toggle",
name: "heartbeat",
label: t("storeForward.heartbeat.label"),
description: t("storeForward.heartbeat.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "records",
label: t("storeForward.records.label"),
description: t("storeForward.records.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.record.plural"),
},
},
{
type: "number",
name: "historyReturnMax",
label: t("storeForward.historyReturnMax.label"),
description: t("storeForward.historyReturnMax.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "historyReturnWindow",
label: t("storeForward.historyReturnWindow.label"),
description: t("storeForward.historyReturnWindow.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,132 @@
import {
type TelemetryValidation,
TelemetryValidationSchema,
} from "@app/validation/moduleConfig/telemetry.ts";
import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface TelemetryModuleConfigProps {
onFormInit: DynamicFormFormInit<TelemetryValidation>;
}
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => {
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
removeWorkingModuleConfig("telemetry");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "telemetry",
value: data,
},
}),
);
};
return (
<DynamicForm<TelemetryValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={TelemetryValidationSchema}
formId="ModuleConfig_TelemetryConfig"
defaultValues={moduleConfig.telemetry}
values={getEffectiveModuleConfig("telemetry")}
fieldGroups={[
{
label: t("telemetry.title"),
description: t("telemetry.description"),
fields: [
{
type: "number",
name: "deviceUpdateInterval",
label: t("telemetry.deviceUpdateInterval.label"),
description: t("telemetry.deviceUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "environmentUpdateInterval",
label: t("telemetry.environmentUpdateInterval.label"),
description: t("telemetry.environmentUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "environmentMeasurementEnabled",
label: t("telemetry.environmentMeasurementEnabled.label"),
description: t(
"telemetry.environmentMeasurementEnabled.description",
),
},
{
type: "toggle",
name: "environmentScreenEnabled",
label: t("telemetry.environmentScreenEnabled.label"),
description: t("telemetry.environmentScreenEnabled.description"),
},
{
type: "toggle",
name: "environmentDisplayFahrenheit",
label: t("telemetry.environmentDisplayFahrenheit.label"),
description: t(
"telemetry.environmentDisplayFahrenheit.description",
),
},
{
type: "toggle",
name: "airQualityEnabled",
label: t("telemetry.airQualityEnabled.label"),
description: t("telemetry.airQualityEnabled.description"),
},
{
type: "number",
name: "airQualityInterval",
label: t("telemetry.airQualityInterval.label"),
description: t("telemetry.airQualityInterval.description"),
},
{
type: "toggle",
name: "powerMeasurementEnabled",
label: t("telemetry.powerMeasurementEnabled.label"),
description: t("telemetry.powerMeasurementEnabled.description"),
},
{
type: "number",
name: "powerUpdateInterval",
label: t("telemetry.powerUpdateInterval.label"),
description: t("telemetry.powerUpdateInterval.description"),
},
{
type: "toggle",
name: "powerScreenEnabled",
label: t("telemetry.powerScreenEnabled.label"),
description: t("telemetry.powerScreenEnabled.description"),
},
],
},
]}
/>
);
};
+137
View File
@@ -0,0 +1,137 @@
import React from "react";
import { cn } from "@core/utils/cn.ts";
import { type LucideIcon } from "lucide-react";
import Footer from "@components/UI/Footer.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem {
key: string;
icon?: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
ariaLabel?: string;
label?: string;
className?: string;
}
export interface PageLayoutProps {
label: string;
actions?: ActionItem[];
children: React.ReactNode;
leftBar?: React.ReactNode;
rightBar?: React.ReactNode;
noPadding?: boolean;
leftBarClassName?: string;
rightBarClassName?: string;
topBarClassName?: string;
contentClassName?: string;
}
export const PageLayout = ({
label,
actions,
children,
leftBar,
rightBar,
noPadding,
leftBarClassName,
rightBarClassName,
topBarClassName,
contentClassName,
}: PageLayoutProps) => {
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<div className="flex flex-1 bg-background text-foreground overflow-hidden">
{/* Left Sidebar */}
{leftBar && (
<aside
className={cn(
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
leftBarClassName,
)}
>
{leftBar}
</aside>
)}
<div className="flex flex-1 flex-col min-w-0">
{/* Header */}
<header
className={cn(
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
topBarClassName,
)}
>
{/* Header Content */}
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="text-lg font-medium text-foreground truncate px-2">
{label}
</span>
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
{actions?.map((action) => {
return (
<button
key={action.key}
type="button"
disabled={action.disabled || action.isLoading}
className={cn(
"flex items-center space-x-2 py-2 px-3 rounded-md",
"text-foreground transition-colors duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
action.className,
)}
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
{action.icon &&
(action.isLoading ? <Spinner size="md" /> : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
))}
{action.label && (
<span className="text-sm px-1 pt-0.5">
{action.label}
</span>
)}
</button>
);
})}
</div>
</div>
</header>
<main
className={cn(
"flex-1 flex flex-col",
"overflow-hidden",
!noPadding && "px-2",
contentClassName,
)}
>
{children}
</main>
<Footer />
</div>
{/* Right Sidebar */}
{rightBar && (
<aside
className={cn(
"w-56 lg:w-[270px] text-balance shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
rightBarClassName,
)}
>
{rightBar}
</aside>
)}
</div>
</ErrorBoundary>
);
};
+233
View File
@@ -0,0 +1,233 @@
import React, { useEffect, useState, useTransition } from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
CircleChevronLeft,
LayersIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
SettingsIcon,
UsersIcon,
} from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useTranslation } from "react-i18next";
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
import { useLocation, useNavigate } from "@tanstack/react-router";
export interface SidebarProps {
children?: React.ReactNode;
}
interface NavLink {
name: string;
icon: LucideIcon;
page: Page;
count?: number;
}
const CollapseToggleButton = () => {
const { isCollapsed, toggleSidebar } = useSidebar();
const { t } = useTranslation("ui");
const buttonLabel = isCollapsed
? t("sidebar.collapseToggle.button.open")
: t("sidebar.collapseToggle.button.close");
return (
<button
type="button"
aria-label={buttonLabel}
onClick={toggleSidebar}
className={cn(
"absolute top-20 right-0 z-10 p-0.5 rounded-full transform translate-x-1/2",
"transition-colors duration-300 ease-in-out",
"border border-slate-300 dark:border-slate-200",
"text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400",
"focus:outline-none focus:ring-2 focus:ring-accent transition-transform bg-background-primary",
)}
>
<CircleChevronLeft
size={24}
className={cn(
"transition-transform duration-300 ease-in-out",
isCollapsed && "rotate-180",
)}
/>
</button>
);
};
export const Sidebar = ({ children }: SidebarProps) => {
const {
hardware,
getNode,
getNodesLength,
metadata,
unreadCounts,
setDialogOpen,
} = useDevice();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const { t } = useTranslation("ui");
const navigate = useNavigate({ from: "/" });
const pathname = useLocation({
select: (location) => location.pathname.replace(/^\//, ""),
});
const myMetadata = metadata.get(0);
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
Math.max(getNodesLength() - 1, 0)
);
const [_, startNodeCountTransition] = useTransition();
const currentNodeCountValue = Math.max(getNodesLength() - 1, 0);
useEffect(() => {
if (currentNodeCountValue !== displayedNodeCount) {
startNodeCountTransition(() => {
setDisplayedNodeCount(currentNodeCountValue);
});
}
}, [currentNodeCountValue, displayedNodeCount, startNodeCountTransition]);
const pages: NavLink[] = [
{
name: t("navigation.messages"),
icon: MessageSquareIcon,
page: "messages",
count: numUnread ? numUnread : undefined,
},
{ name: t("navigation.map"), icon: MapIcon, page: "map" },
{
name: t("navigation.config"),
icon: SettingsIcon,
page: "config",
},
{
name: t("navigation.channels"),
icon: LayersIcon,
page: "channels",
},
{
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
icon: UsersIcon,
page: "nodes",
},
];
return (
<div
className={cn(
"relative border-slate-300 dark:border-slate-700",
"transition-all duration-300 ease-in-out flex-shrink-0",
isCollapsed ? "w-24" : "w-52 lg:w-64",
)}
>
<CollapseToggleButton />
<div
className={cn(
"h-14 flex mt-2 gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out",
"border-b-[0.5px] border-slate-300 dark:border-slate-700",
isCollapsed && "justify-center px-0",
)}
>
<img
src="/Logo.svg"
alt={t("app.logo")}
className="size-10 flex-shrink-0 rounded-xl"
/>
<h2
className={cn(
"text-xl font-semibold text-gray-800 dark:text-gray-100 whitespace-nowrap",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 invisible ml-0"
: "opacity-100 max-w-xs visible ml-2",
)}
>
{t("app.title")}
</h2>
</div>
<SidebarSection
label={t("navigation.title")}
className="mt-4 px-0"
>
{pages.map((link) => {
return (
<SidebarButton
key={link.name}
count={link.count}
label={link.name}
Icon={link.icon}
onClick={() => {
if (myNode !== undefined) {
navigate({ to: `/${link.page}` });
}
}}
active={link.page === pathname}
disabled={myNode === undefined}
/>
);
})}
</SidebarSection>
<div
className={cn(
"flex-1 min-h-0",
isCollapsed && "overflow-hidden",
)}
>
{children}
</div>
<div className=" pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 h-full flex-1">
{myNode === undefined
? (
<div className="flex flex-col items-center justify-center py-6">
<Spinner />
<Subtle
className={cn(
"mt-4 transition-opacity duration-300",
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)}
>
{t("loading")}
</Subtle>
</div>
)
: (
<DeviceInfoPanel
isCollapsed={isCollapsed}
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
setDialogOpen={() => setDialogOpen("deviceName", true)}
user={{
longName: myNode?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
}}
firmwareVersion={myMetadata?.firmwareVersion ??
t("unknown.notAvailable")}
deviceMetrics={{
batteryLevel: myNode.deviceMetrics?.batteryLevel,
voltage: myNode.deviceMetrics?.voltage,
}}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,93 @@
import { useTheme } from "@core/hooks/useTheme.ts";
import { cn } from "@core/utils/cn.ts";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Button } from "./UI/Button.tsx";
type ThemePreference = "light" | "dark" | "system";
interface ThemeSwitcherProps {
className?: string;
disableHover?: boolean;
}
export default function ThemeSwitcher({
className: passedClassName = "",
disableHover = false,
}: ThemeSwitcherProps) {
const { preference, setPreference } = useTheme();
const { t } = useTranslation("ui");
const iconBaseClass =
"size-4 flex-shrink-0 text-gray-500 dark:text-gray-400 transition-colors duration-150";
const iconHoverClass = !disableHover
? "group-hover:text-gray-700 dark:group-hover:text-gray-200"
: "";
const combinedIconClass = cn(iconBaseClass, iconHoverClass);
const themeIcons = {
light: <Sun className={combinedIconClass} />,
dark: <Moon className={combinedIconClass} />,
system: <Monitor className={combinedIconClass} />,
};
const toggleTheme = () => {
const preferences: ThemePreference[] = ["light", "dark", "system"];
const currentIndex = preferences.indexOf(preference);
const nextPreference = preferences[(currentIndex + 1) % preferences.length];
setPreference(nextPreference);
};
const preferenceDisplayMap: Record<ThemePreference, string> = {
light: t("theme.light"),
dark: t("theme.dark"),
system: t("theme.system"),
};
const currentDisplayPreference = preferenceDisplayMap[preference];
return (
<Button
variant="ghost"
onClick={toggleTheme}
id="theme-switcher"
aria-label={t("theme.changeTheme")}
className={cn(
"group relative flex justify-start",
"gap-2.5 p-1.5 rounded-md transition-colors duration-150",
"cursor-pointer",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
"focus:*:data-label:opacity-100",
passedClassName,
)}
>
<span
data-label="theme-preference-tooltip"
className={cn(
"transition-opacity duration-150",
"block absolute w-max max-w-xs",
"p-1 text-xs text-white dark:text-black bg-black dark:bg-white",
"rounded-md shadow-lg",
"left-1/2 -translate-x-1/2 -top-8",
"opacity-0",
)}
>
{currentDisplayPreference}
</span>
{themeIcons[preference]}
<Subtle
className={cn(
"text-sm",
"text-gray-600 dark:text-gray-300",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
{t("theme.changeTheme")}
</Subtle>
</Button>
);
}
+34
View File
@@ -0,0 +1,34 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@components/UI/Toast.tsx";
import { useToast } from "@core/hooks/useToast.ts";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, duration, ...props }) => (
<Toast
key={id}
{...props}
duration={duration}
className="flex flex-col gap-4"
>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);
}
@@ -0,0 +1,44 @@
import { cn } from "@core/utils/cn.ts";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { type ComponentRef, forwardRef } from "react";
export const Accordion = AccordionPrimitive.Root;
export const AccordionHeader = AccordionPrimitive.Header;
export const AccordionItem = AccordionPrimitive.Item;
export const AccordionTrigger = forwardRef<
ComponentRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex justify-between items-center w-full p-4 border-b border-slate-200 dark:border-slate-800 group",
className,
)}
{...props}
>
{props.children}
<ChevronDownIcon
className="h-5 w-5 transition-transform duration-300 group-data-[state=open]:rotate-180"
aria-hidden
/>
</AccordionPrimitive.Trigger>
));
export const AccordionContent = forwardRef<
ComponentRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"p-4 border-b border-slate-200 dark:border-slate-800",
className,
)}
{...props}
/>
));
+147
View File
@@ -0,0 +1,147 @@
import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useTranslation } from "react-i18next";
type RGBColor = {
r: number;
g: number;
b: number;
a: number;
};
interface AvatarProps {
text: string | number;
size?: "sm" | "lg";
className?: string;
showError?: boolean;
showFavorite?: boolean;
}
class ColorUtils {
static hexToRgb(hex: number): RGBColor {
return {
r: (hex & 0xff0000) >> 16,
g: (hex & 0x00ff00) >> 8,
b: hex & 0x0000ff,
a: 255,
};
}
static rgbToHex(color: RGBColor): number {
return (
(Math.round(color.a) << 24) |
(Math.round(color.r) << 16) |
(Math.round(color.g) << 8) |
Math.round(color.b)
);
}
static isLight(color: RGBColor): boolean {
const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
return brightness > 127.5;
}
}
const getColorFromText = (text: string): RGBColor => {
if (!text) {
return { r: 0, g: 0, b: 0, a: 255 };
}
let hash = 0;
for (let i = 0; i < text?.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash |= 0;
}
return {
r: (hash & 0xff0000) >> 16,
g: (hash & 0x00ff00) >> 8,
b: hash & 0x0000ff,
a: 255,
};
};
export const Avatar = ({
text,
size = "sm",
showError = false,
showFavorite = false,
className,
}: AvatarProps) => {
const { t } = useTranslation();
const sizes = {
sm: "size-10 text-xs font-light",
lg: "size-16 text-lg",
};
const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText);
const isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
return (
<div
className={cn(
`relative flex items-center justify-center rounded-full font-semibold
`,
sizes[size],
className,
)}
style={{
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
color: textColor,
}}
>
{showFavorite
? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<StarIcon
className="absolute -top-0.5 -right-0.5 z-10 size-4 stroke-1 fill-yellow-400"
aria-hidden="true"
style={{
color: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
}}
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.favorite.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
: null}
{showError
? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<LockKeyholeOpenIcon
className="absolute -bottom-0.5 -right-0.5 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.error.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
: null}
<p className="p-1 text-nowrap">
{initials}
</p>
</div>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:cursor-not-allowed dark:focus:ring-offset-slate-900 cursor-pointer",
{
variants: {
variant: {
default:
"bg-slate-900 text-white dark:bg-slate-50 hover:dark:bg-slate-200 dark:text-slate-900 hover:bg-slate-500",
destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline:
"bg-transparent border border-slate-400 hover:text-slate-400 dark:hover:text-slate-300 dark:border-slate-400 dark:text-slate-100 ",
subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:
"bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
link:
"bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
iconAlignment?: "left" | "right";
}
const Button = ({
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
}: ButtonProps) => {
return (
<button
type="button"
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled },
"inline-flex items-center",
)}
disabled={disabled}
{...props}
>
{icon && iconAlignment === "left" && (
<span className={cn({ "mr-2": !!children })}>{icon}</span>
)}
{children}
{icon && iconAlignment === "right" && (
<span className={cn({ "ml-2": !!children })}>{icon}</span>
)}
</button>
);
};
export { Button, buttonVariants };
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import React from "react";
vi.mock("@components/UI/Label.tsx", () => ({
Label: (
{ children, className, htmlFor, id }: {
children: React.ReactNode;
className: string;
htmlFor: string;
id: string;
},
) => (
<label
data-testid="label-component"
className={className}
htmlFor={htmlFor}
id={id}
>
{children}
</label>
),
}));
describe("Checkbox", () => {
beforeEach(cleanup);
it("renders unchecked by default", () => {
render(<Checkbox />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
expect(screen.queryByText("Check")).not.toBeInTheDocument();
});
it("renders checked when checked prop is true", () => {
render(<Checkbox checked />);
expect(screen.getByRole("checkbox")).toBeChecked();
expect(screen.getByRole("presentation")).toBeInTheDocument();
});
it("calls onChange when clicked", () => {
const onChange = vi.fn();
render(<Checkbox onChange={onChange} />);
fireEvent.click(screen.getByRole("presentation"));
expect(onChange).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole("presentation"));
expect(onChange).toHaveBeenCalledWith(false);
});
it("uses provided id", () => {
render(<Checkbox id="custom-id" />);
expect(screen.getByRole("checkbox").id).toBe("custom-id");
});
it("renders children in Label component", () => {
render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveTextContent(
"Test Label",
);
});
it("applies custom className", () => {
const { container } = render(<Checkbox className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
});
it("applies labelClassName to Label", () => {
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveClass("label-class");
});
it("disables checkbox when disabled prop is true", () => {
render(<Checkbox disabled />);
expect(screen.getByRole("checkbox")).toBeDisabled();
expect(screen.getByRole("presentation")).toHaveClass("opacity-50");
});
it("does not call onChange when disabled", () => {
const onChange = vi.fn();
render(<Checkbox onChange={onChange} disabled />);
fireEvent.click(screen.getByRole("presentation"));
expect(onChange).not.toHaveBeenCalled();
});
it("sets required attribute when required prop is true", () => {
render(<Checkbox required />);
expect(screen.getByRole("checkbox")).toHaveAttribute("required");
});
it("sets name attribute when name prop is provided", () => {
render(<Checkbox name="test-name" />);
expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name");
});
it("passes through additional props", () => {
render(<Checkbox data-testid="extra-prop" />);
expect(screen.getByRole("checkbox")).toHaveAttribute(
"data-testid",
"extra-prop",
);
});
it("toggles checked state correctly", () => {
render(<Checkbox />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
expect(checkbox).not.toBeChecked();
fireEvent.click(presentation);
expect(checkbox).toBeChecked();
fireEvent.click(presentation);
expect(checkbox).not.toBeChecked();
});
});
@@ -0,0 +1,100 @@
import { useEffect, useId, useState } from "react";
import { Check } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { cn } from "@core/utils/cn.ts";
interface CheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
className?: string;
labelClassName?: string;
id?: string;
children?: React.ReactNode;
disabled?: boolean;
required?: boolean;
name?: string;
}
export function Checkbox({
checked,
onChange,
className,
labelClassName,
id: propId,
children,
disabled = false,
required = false,
name,
...rest
}: CheckboxProps) {
const generatedId = useId();
const id = propId || generatedId;
const [isChecked, setIsChecked] = useState(checked || false);
// Make sure setIsChecked state updates with checked
useEffect(() => {
setIsChecked(checked || false);
}, [checked]);
const handleToggle = () => {
if (disabled) return;
const newChecked = !isChecked;
setIsChecked(newChecked);
onChange?.(newChecked);
};
return (
<div className={cn("flex items-center", className)}>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={handleToggle}
disabled={disabled}
required={required}
name={name}
className="sr-only"
{...rest}
/>
<div
onClick={handleToggle}
role="presentation"
className={cn(
"w-6 h-6 border-2 border-gray-500 rounded-md flex items-center justify-center",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
isChecked ? "" : "",
)}
>
{isChecked && (
<div className="animate-fade-in scale-100 opacity-100">
<Check className="w-4 h-4" />
</div>
)}
</div>
</div>
{children && (
<div className="ml-3 text-sm">
<Label
htmlFor={id}
id={`${id}-label`}
className={cn(
"text-gray-900 dark:text-gray-900",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
labelClassName,
)}
>
{children}
</Label>
</div>
)}
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent } from "@components/UI/Dialog.tsx";
import { cn } from "@core/utils/cn.ts";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-lg bg-white dark:bg-slate-800",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl [&_[dialog-overlay]]:bg-red-100">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group]]:px-2 [&_[cmdk-group]_+[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700"
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50 text-white" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden py-3 px-2 text-slate-700 dark:text-slate-200 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-slate-900 dark:[&_[cmdk-group-heading]]:text-slate-300",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-slate-100 dark:bg-slate-200", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700 dark:text-white ",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
};
+140
View File
@@ -0,0 +1,140 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({
children,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-xs transition-opacity animate-in fade-in",
className,
)}
{...props}
ref={ref}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full bg-white dark:bg-slate-800 dark:text-slate-200 text-slate-900 max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
className,
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogClose = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>) => (
<DialogPrimitive.Close
aria-label="Close"
data-testid="dialog-close-button"
className={cn(
"absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className,
)}
{...props}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
);
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left ",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-slate-900 dark:text-slate-100",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-800 dark:text-slate-200", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,201 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-xs py-1.5 px-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-700 dark:data-[state=open]:bg-slate-700",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white p-1 text-slate-700 shadow-md animate-in slide-in-from-left-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white p-1 text-slate-700 shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs py-1.5 px-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:focus:bg-slate-700",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:focus:bg-slate-700",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:focus:bg-slate-700",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-slate-900 dark:text-slate-300",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-700", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500",
className,
)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
@@ -0,0 +1,107 @@
import newGithubIssueUrl from "@core/utils/github.ts";
import { ExternalLink } from "lucide-react";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { P } from "@components/UI/Typography/P.tsx";
import { Trans, useTranslation } from "react-i18next";
export function ErrorPage({ error }: { error: Error }) {
if (!error) {
return null;
}
const { t } = useTranslation();
return (
<article className="w-full h-screen overflow-y-auto bg-background-primary text-text-primary">
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center dark:bg-background-primary text-slate-900 dark:text-text-primary">
<div>
<Heading as="h2" className="text-text-primary">
{t("errorPage.title")}
</Heading>
<P>
{t("errorPage.description1")}
</P>
<P>
{t("errorPage.description2")}
</P>
<P>Please include the following information in your report:</P>
<ul className="list-disc list-inside text-sm">
<li>{t("errorPage.reportSteps.step1")}</li>
<li>{t("errorPage.reportSteps.step2")}</li>
<li>{t("errorPage.reportSteps.step3")}</li>
<li>{t("errorPage.reportSteps.step4")}</li>
</ul>
<P>
<Trans
i18nKey="errorPage.reportLink"
components={[
<Link
key="github"
href={newGithubIssueUrl({
repoUrl: "https://github.com/meshtastic/web",
template: "bug.yml",
title:
"[Bug]: An unhandled error occurred. <Add details here>",
logs: error?.stack,
})}
/>,
]}
/>
<ExternalLink size={24} className="inline-block ml-2" />
</P>
<P>
<Trans
i18nKey="errorPage.dashboardLink"
components={[<Link key="dashboard" href="/" />]}
/>
</P>
</div>
<div className="hidden md:block md:max-w-64 lg:max-w-80 w-full aspect-suqare">
<img
src="/chirpy.svg"
alt="Chirpy the Meshtastic error"
className="max-w-full h-auto"
/>
</div>
</section>
<details className="mt-8 px-4 md:px-8 text-lg md:text-xl space-y-2 text-md whitespace-pre-wrap break-all">
<summary className="cursor-pointer">
{t("errorPage.detailsSummary")}
</summary>
<span className="text-sm mt-4">
{error?.message && (
<>
<label htmlFor="message">
{t("errorPage.errorMessageLabel")}
</label>
<p
id="message"
className="text-slate-400 break-words overflow-wrap"
>
{error.message}
</p>
</> // TODO: Use Trans for the label and message together?
)}
{error?.stack && (
<>
<label htmlFor="stack">{t("errorPage.stackTraceLabel")}</label>
<p
id="stack"
className="text-slate-400 break-words overflow-wrap"
>
{error.stack}
</p>
</>
)}
{!error?.message && !error?.stack && (
<p className="text-slate-400">
{t("errorPage.fallbackError", { error: error.toString() })}
</p>
)}
</span>
</details>
</article>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { cn } from "@core/utils/cn.ts";
import { Trans, useTranslation } from "react-i18next";
type FooterProps = {
className?: string;
};
const Footer = ({ className, ...props }: FooterProps) => {
const { t } = useTranslation();
return (
<footer
className={cn(
"flex mt-auto justify-center py-2 px-4 text-sm lg:text-md",
className,
)}
{...props}
>
<div className="justify-start px-2">
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
{t("footer.commitSha", {
sha: String(import.meta.env.VITE_COMMIT_HASH)?.toUpperCase(),
})}
</span>
</div>
<p className="ml-auto mr-auto text-gray-500 dark:text-gray-400">
<Trans
i18nKey="footer.text"
components={[
<a
key="vercel"
rel="noopener noreferrer"
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline text-link"
/>,
<a
key="legal"
rel="noopener noreferrer"
href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
/>,
]}
/>
</p>
</footer>
);
};
export default Footer;
@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { useTranslation } from "react-i18next";
export interface ActionButton {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}
[];
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
type: "text" | "password";
devicePSKBitCount?: number;
value: string;
id: string;
variant: "default" | "invalid" | "dirty";
actionButtons: ActionButton[];
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: React.ChangeEventHandler<HTMLInputElement>;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
disabled?: boolean;
}
const Generator = (
{
type,
devicePSKBitCount,
id = "pskInput",
variant,
value,
actionButtons,
bits,
selectChange,
inputChange,
disabled,
showPasswordToggle,
showCopyButton,
...props
}: GeneratorProps,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const passwordRequiredBitSize = bits ? bits : [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
{
text: t("security.128bit"),
value: "16",
key: "bit128",
},
{
text: t("security.8bit"),
value: "1",
key: "bit8",
},
{
text: t("security.0bit"),
value: "0",
key: "bit0",
},
];
// Invokes onChange event on the input element when the value changes from the parent component
useEffect(() => {
if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
type={type}
id={id}
variant={variant}
value={value}
onChange={inputChange}
disabled={disabled}
ref={inputRef}
showCopyButton={showCopyButton}
showPasswordToggle={showPasswordToggle}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger className="w-36 ml-2">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-36">
{passwordRequiredBitSize.map(({ text, value, key }) => (
<SelectItem key={key} value={value} className="w-36">
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex ml-2 space-x-2">
{actionButtons?.map(({ text, onClick, variant, className }) => (
<Button
key={text}
type="button"
onClick={onClick}
disabled={disabled}
variant={variant}
className={className}
{...props}
>
{text}
</Button>
))}
</div>
</>
);
};
Generator.displayName = "Button";
export { Generator };
+219
View File
@@ -0,0 +1,219 @@
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useTranslation } from "react-i18next";
const cnInvalidBase = "border-2 border-red-500 dark:border-red-500";
const cnDirtyBase = "border-2 border-sky-500 dark:border-sky-500";
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",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-500",
invalid: `${cnInvalidBase} focus:ring-red-500 dark:focus:ring-red-500`,
dirty: `${cnDirtyBase} focus:ring-sky-500 dark:focus:ring-sky-500`,
},
},
defaultVariants: {
variant: "default",
},
},
);
type InputActionType = {
id: string;
icon: LucideIcon;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaLabel: string;
tooltip?: string;
condition?: boolean;
};
export interface InputProps
extends
Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
VariantProps<typeof inputVariants> {
prefix?: React.ReactNode;
suffix?: React.ReactNode;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
showClearButton?: boolean;
containerClassName?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
containerClassName,
variant,
disabled,
type = "text",
prefix,
suffix,
showPasswordToggle,
showCopyButton,
showClearButton,
value,
onChange,
...props
},
ref,
) => {
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
const { t } = useTranslation("ui");
const potentialActions: InputActionType[] = [
{
id: "clear-input",
icon: X,
onClick: (e) => {
e.stopPropagation();
if (onChange) {
const event = {
target: { value: "" },
currentTarget: { value: "" },
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (ref && typeof ref !== "function" && ref.current) {
ref.current.focus();
}
},
ariaLabel: t("clearInput.label"),
tooltip: t("clearInput.label"),
condition: !!showClearButton && !!value,
},
{
id: "toggle-visibility",
icon: isVisible ? EyeOff : Eye,
onClick: (e) => {
e.stopPropagation();
toggleVisibility();
},
ariaLabel: isVisible
? t("notifications.hidePassword.label")
: t("notifications.showPassword.label"),
tooltip: isVisible
? t("notifications.hidePassword.label")
: t("notifications.showPassword.label"),
condition: !!showPasswordToggle && type === "password",
},
{
id: "copy-value",
icon: isCopied ? Check : Copy,
onClick: (e) => {
e.stopPropagation();
if (value !== undefined && value !== null) {
copy(String(value));
}
},
ariaLabel: isCopied
? t("notifications.copied.label")
: t("notifications.copyToClipboard.label"),
tooltip: isCopied
? t("notifications.copied.label")
: t("notifications.copyToClipboard.label"),
condition: !!showCopyButton,
},
];
const actions = potentialActions.filter((action) => action.condition);
const inputType = showPasswordToggle
? (isVisible ? "text" : "password")
: type;
const hasPrefix = !!prefix;
const hasSuffix = !!suffix;
const hasActions = actions.length > 0;
const inputClassName = cn(
inputVariants({ variant }),
hasActions && !hasSuffix && "pr-10",
hasPrefix && "rounded-l-none",
className,
);
const extrasClassName = cn([
variant === "invalid" && `${cnInvalidBase} border-l-0`,
variant === "dirty" && `${cnDirtyBase} border-l-0`,
]);
return (
<div
className={cn("relative flex w-full items-stretch", containerClassName)}
>
{prefix && (
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-200 dark:text-slate-700">
{prefix}
</span>
)}
<input
type={inputType === "password" && isVisible ? "text" : inputType}
className={inputClassName}
ref={ref}
value={value}
onChange={onChange}
disabled={disabled}
{...props}
/>
<div className="absolute right-0 top-0 flex h-full items-stretch">
{suffix && (
<span
className={cn(
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
extrasClassName,
!hasActions && "rounded-r-md",
)}
>
{suffix}
</span>
)}
{hasActions && (
<div
className={cn(
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-500",
extrasClassName,
disabled &&
"border-slate-200 dark:border-slate-700 divide-slate-200",
!hasSuffix && "rounded-r-md",
"bg-white dark:bg-slate-800",
)}
>
{actions.map((action) => (
<button
key={action.id}
type="button"
className={cn(
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 last:hover:rounded-r-md last:dark:hover:rounded-r-md",
disabled && "text-slate-300 dark:text-slate-600",
action.id === "copy-value" && isCopied &&
"text-green-600 dark:text-green-500",
)}
onClick={action.onClick}
aria-label={action.ariaLabel}
title={action.tooltip || action.ariaLabel}
>
<action.icon size={18} aria-hidden="true" />
</button>
))}
</div>
)}
</div>
</div>
);
},
);
Input.displayName = "Input";
export { Input, inputVariants };
+21
View File
@@ -0,0 +1,21 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+234
View File
@@ -0,0 +1,234 @@
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border border-slate-300 bg-white p-1 dark:border-slate-700 dark:bg-slate-800",
className,
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-[0.2rem] py-1.5 px-3 text-sm font-medium outline-hidden focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-700 dark:data-[state=open]:bg-slate-700",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-xs py-1.5 px-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-700 dark:data-[state=open]:bg-slate-700",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white p-1 shadow-md animate-in slide-in-from-left-1 dark:border-slate-700 dark:bg-slate-800",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref,
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-slate-100 bg-white p-1 text-slate-700 shadow-md animate-in slide-in-from-top-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
),
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs py-1.5 px-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:focus:bg-slate-700",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:focus:bg-slate-700",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm font-medium outline-hidden focus:bg-slate-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:focus:bg-slate-700",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-slate-900 dark:text-slate-300",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-700", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500",
className,
)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarLabel,
MenubarMenu,
MenubarPortal,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
};
@@ -0,0 +1,56 @@
import { cn } from "../../core/utils/cn.ts";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
interface MultiSelectProps {
children: React.ReactNode;
className?: string;
}
const MultiSelect = ({ children, className = "" }: MultiSelectProps) => {
return <div className={cn("flex flex-wrap gap-2", className)}>{children}
</div>;
};
interface MultiSelectItemProps {
name: string;
value: string;
checked: boolean;
onCheckedChange: (name: string, value: boolean) => void;
children: React.ReactNode;
className?: string;
}
const MultiSelectItem = ({
name,
value,
checked,
onCheckedChange,
children,
className = "",
}: MultiSelectItemProps) => {
return (
<CheckboxPrimitive.Root
name={name}
id={value}
checked={checked}
onCheckedChange={(val) => onCheckedChange(name, !!val)}
className={cn(
`
inline-flex items-center rounded-md px-3 py-2 text-sm transition-colors
border border-slate-300
hover:bg-slate-100 dark:hover:bg-slate-800
focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2
data-[state=checked]:bg-slate-100 dark:data-[state=checked]:bg-slate-700`,
className,
)}
>
<CheckboxPrimitive.Indicator className="mr-2">
<Check className="h-4 w-4 animate-in zoom-in duration-200" />
</CheckboxPrimitive.Indicator>
{children}
</CheckboxPrimitive.Root>
);
};
export { MultiSelect, MultiSelectItem };

Some files were not shown because too many files have changed in this diff Show More