refactor: switch to using Bun (#718)
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
PlugZapIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeviceMetrics } from "./types.ts";
|
||||
import type { DeviceMetrics } from "./types.ts";
|
||||
|
||||
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
|
||||
|
||||
@@ -14,10 +14,6 @@ interface BatteryStatusProps {
|
||||
deviceMetrics?: DeviceMetrics | null;
|
||||
}
|
||||
|
||||
interface BatteryStatusProps {
|
||||
deviceMetrics?: DeviceMetrics | null;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
Icon: React.ElementType;
|
||||
className: string;
|
||||
@@ -89,10 +85,7 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||
} = statusConfigMap[statusKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 mt-0.5 "
|
||||
aria-label={t("batteryStatus.title")}
|
||||
>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<BatteryIcon size={22} className={iconClassName} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
@@ -1,356 +1,358 @@
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@components/UI/Command.tsx";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCommandState } from "cmdk";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BoxSelectIcon,
|
||||
BugIcon,
|
||||
CloudOff,
|
||||
EraserIcon,
|
||||
FactoryIcon,
|
||||
LayersIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
Pin,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
QrCodeIcon,
|
||||
RefreshCwIcon,
|
||||
SettingsIcon,
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
ArrowLeftRightIcon,
|
||||
BoxSelectIcon,
|
||||
BugIcon,
|
||||
CloudOff,
|
||||
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[];
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
commands: Command[];
|
||||
}
|
||||
export interface Command {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
action?: () => void;
|
||||
subItems?: SubItem[];
|
||||
tags?: string[];
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
action?: () => void;
|
||||
subItems?: SubItem[];
|
||||
tags?: string[];
|
||||
}
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
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 {
|
||||
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.disconnect"),
|
||||
icon: CloudOff,
|
||||
action() {
|
||||
connection?.disconnect().catch((error) => {
|
||||
console.error("Failed to disconnect:", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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.disconnect"),
|
||||
icon: CloudOff,
|
||||
action() {
|
||||
connection?.disconnect().catch((error) => {
|
||||
console.error("Failed to disconnect:", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
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]);
|
||||
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>
|
||||
);
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<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,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
}) => {
|
||||
const search = useCommandState((state) => state.search);
|
||||
if (!search) return null;
|
||||
const search = useCommandState((state) => state.search);
|
||||
if (!search) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandItem onSelect={action}>
|
||||
{icon}
|
||||
{label}
|
||||
</CommandItem>
|
||||
);
|
||||
return (
|
||||
<CommandItem onSelect={action}>
|
||||
{icon}
|
||||
{label}
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,15 +8,16 @@ import {
|
||||
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 type React from "react";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ThemeSwitcher from "./ThemeSwitcher.tsx";
|
||||
import BatteryStatus from "./BatteryStatus.tsx";
|
||||
import LanguageSwitcher from "./LanguageSwitcher.tsx";
|
||||
import ThemeSwitcher from "./ThemeSwitcher.tsx";
|
||||
import type { DeviceMetrics } from "./types.ts";
|
||||
import { Avatar } from "./UI/Avatar.tsx";
|
||||
import { Button } from "./UI/Button.tsx";
|
||||
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||
|
||||
interface DeviceInfoPanelProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -70,9 +71,10 @@ export const DeviceInfoPanel = ({
|
||||
id: "voltage",
|
||||
label: t("batteryVoltage.title"),
|
||||
icon: ZapIcon,
|
||||
value: voltage !== undefined
|
||||
? `${voltage?.toPrecision(3)} V`
|
||||
: t("unknown.notAvailable", "N/A"),
|
||||
value:
|
||||
voltage !== undefined
|
||||
? `${voltage?.toPrecision(3)} V`
|
||||
: t("unknown.notAvailable", "N/A"),
|
||||
},
|
||||
{
|
||||
id: "firmware",
|
||||
@@ -135,8 +137,7 @@ export const DeviceInfoPanel = ({
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
</div>
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -151,10 +152,7 @@ export const DeviceInfoPanel = ({
|
||||
{deviceInfoItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2.5 text-sm"
|
||||
>
|
||||
<div key={item.id} className="flex items-center gap-2.5 text-sm">
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={16}
|
||||
@@ -173,8 +171,7 @@ export const DeviceInfoPanel = ({
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
</div>
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -190,9 +187,7 @@ export const DeviceInfoPanel = ({
|
||||
const Icon = buttonItem.icon;
|
||||
if (buttonItem.render) {
|
||||
return (
|
||||
<Fragment key={buttonItem.id}>
|
||||
{buttonItem.render()}
|
||||
</Fragment>
|
||||
<Fragment key={buttonItem.id}>{buttonItem.render()}</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
+11
-17
@@ -1,8 +1,8 @@
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
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(() => ({
|
||||
@@ -26,9 +26,7 @@ describe("DeleteMessagesDialog", () => {
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
const closeButton = screen.queryByTestId("dialog-close-button");
|
||||
if (!closeButton) {
|
||||
throw new Error(
|
||||
@@ -41,15 +39,15 @@ describe("DeleteMessagesDialog", () => {
|
||||
});
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
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.getByText(/This action will clear all message history./),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Clear Messages" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear Messages" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the dialog when open is false", () => {
|
||||
@@ -60,18 +58,14 @@ describe("DeleteMessagesDialog", () => {
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the dismiss button is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
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} />,
|
||||
);
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface DeleteMessagesDialogProps {
|
||||
open: boolean;
|
||||
@@ -41,11 +41,7 @@ export const DeleteMessagesDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseDialog}
|
||||
name="dismiss"
|
||||
>
|
||||
<Button variant="outline" onClick={handleCloseDialog} name="dismiss">
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { GenericInput } from "@components/Form/FormInput.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
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";
|
||||
import { Label } from "../UI/Label.tsx";
|
||||
|
||||
export interface User {
|
||||
longName: string;
|
||||
@@ -76,15 +76,11 @@ export const DeviceNameDialog = ({
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("deviceName.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deviceName.description")}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t("deviceName.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="longName">
|
||||
{t("deviceName.longName")}
|
||||
</Label>
|
||||
<Label htmlFor="longName">{t("deviceName.longName")}</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
@@ -104,9 +100,7 @@ export const DeviceNameDialog = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="shortName">
|
||||
{t("deviceName.shortName")}
|
||||
</Label>
|
||||
<Label htmlFor="shortName">{t("deviceName.shortName")}</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
@@ -134,10 +128,7 @@ export const DeviceNameDialog = ({
|
||||
>
|
||||
{t("button.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
name="save"
|
||||
>
|
||||
<Button type="submit" name="save">
|
||||
{t("button.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.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";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -18,6 +17,7 @@ import { Protobuf } from "@meshtastic/core";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
@@ -25,10 +25,7 @@ export interface ImportDialogProps {
|
||||
loraConfig?: Protobuf.Config.Config_LoRaConfig;
|
||||
}
|
||||
|
||||
export const ImportDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportDialogProps) => {
|
||||
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
@@ -69,7 +66,7 @@ export const ImportDialog = ({
|
||||
setValidUrl(false);
|
||||
setChannelSet(undefined);
|
||||
}
|
||||
}, [importDialogInput]);
|
||||
}, [importDialogInput, t]);
|
||||
|
||||
const apply = () => {
|
||||
channelSet?.settings.map(
|
||||
@@ -77,9 +74,10 @@ export const ImportDialog = ({
|
||||
connection?.setChannel(
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index,
|
||||
role: index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
role:
|
||||
index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
settings: ch,
|
||||
}),
|
||||
);
|
||||
@@ -104,9 +102,7 @@ export const ImportDialog = ({
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("import.description")}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t("import.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>{t("import.channelSetUrl")}</Label>
|
||||
@@ -127,25 +123,21 @@ export const ImportDialog = ({
|
||||
checked={channelSet?.loraConfig?.usePreset ?? true}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
/* <Select
|
||||
{/* <Select
|
||||
label="Modem Preset"
|
||||
disabled
|
||||
value={channelSet?.loraConfig?.modemPreset}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
|
||||
</Select> */
|
||||
}
|
||||
</Select> */}
|
||||
</div>
|
||||
{
|
||||
/* <Select
|
||||
{/* <Select
|
||||
label="Region"
|
||||
disabled
|
||||
value={channelSet?.loraConfig?.region}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
|
||||
</Select> */
|
||||
}
|
||||
</Select> */}
|
||||
|
||||
<span className="text-md block font-medium text-text-primary">
|
||||
{t("import.channels")}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -7,9 +10,6 @@ import {
|
||||
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;
|
||||
@@ -26,16 +26,19 @@ export const LocationResponseDialog = ({
|
||||
const { getNode } = useDevice();
|
||||
|
||||
const from = getNode(location?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
const longName =
|
||||
from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const shortName = from?.user?.shortName ??
|
||||
const shortName =
|
||||
from?.user?.shortName ??
|
||||
(from
|
||||
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||
: t("unknown.shortName"));
|
||||
|
||||
const position = location?.data;
|
||||
|
||||
const hasCoordinates = position &&
|
||||
const hasCoordinates =
|
||||
position &&
|
||||
typeof position.latitudeI === "number" &&
|
||||
typeof position.longitudeI === "number" &&
|
||||
typeof position.altitude === "number";
|
||||
@@ -53,40 +56,38 @@ export const LocationResponseDialog = ({
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -8,9 +9,8 @@ import {
|
||||
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";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export interface ManagedModeDialogProps {
|
||||
open: boolean;
|
||||
@@ -36,7 +36,7 @@ export const ManagedModeDialog = ({
|
||||
<Trans
|
||||
i18nKey="managedMode.description"
|
||||
components={{
|
||||
"bold": <p className="font-bold inline" />,
|
||||
bold: <p className="font-bold inline" />,
|
||||
}}
|
||||
/>
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
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";
|
||||
@@ -18,6 +14,10 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
type BrowserFeature,
|
||||
useBrowserFeatureDetection,
|
||||
} from "@core/hooks/useBrowserFeatureDetection.ts";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "../UI/Typography/Link.tsx";
|
||||
@@ -45,24 +45,23 @@ interface FeatureErrorProps {
|
||||
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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;
|
||||
if (missingFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browserFeatures = missingFeatures.filter(
|
||||
(feature) => feature !== "Secure Context",
|
||||
@@ -70,11 +69,11 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const needsFeature =
|
||||
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
|
||||
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
|
||||
? "Web Bluetooth"
|
||||
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
|
||||
? "Web Serial"
|
||||
: undefined;
|
||||
: 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">
|
||||
@@ -97,9 +96,11 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
{needsFeature && needsSecureContext && " "}
|
||||
{needsSecureContext && (
|
||||
<Trans
|
||||
i18nKey={browserFeatures.length > 0
|
||||
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
|
||||
: "newDeviceDialog.validation.requiresSecureContext"}
|
||||
i18nKey={
|
||||
browserFeatures.length > 0
|
||||
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
|
||||
: "newDeviceDialog.validation.requiresSecureContext"
|
||||
}
|
||||
components={{
|
||||
"0": (
|
||||
<Link
|
||||
@@ -117,10 +118,7 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const NewDeviceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NewDeviceProps) => {
|
||||
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
@@ -135,14 +133,16 @@ export const NewDeviceDialog = ({
|
||||
id: "BLE",
|
||||
label: t("newDeviceDialog.tabBluetooth"),
|
||||
element: BLE,
|
||||
isDisabled: unsupported.includes("Web Bluetooth") ||
|
||||
isDisabled:
|
||||
unsupported.includes("Web Bluetooth") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
{
|
||||
id: "Serial",
|
||||
label: t("newDeviceDialog.tabSerial"),
|
||||
element: Serial,
|
||||
isDisabled: unsupported.includes("Web Serial") ||
|
||||
isDisabled:
|
||||
unsupported.includes("Web Serial") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
];
|
||||
@@ -165,19 +165,11 @@ export const NewDeviceDialog = ({
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
{tab.id !== "HTTP" && tab.isDisabled ? (
|
||||
<ErrorMessage missingFeatures={unsupported} tabId={tab.id} />
|
||||
) : (
|
||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||
)}
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
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 { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -25,7 +16,23 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
|
||||
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
BellIcon,
|
||||
BellOffIcon,
|
||||
@@ -35,16 +42,8 @@ import {
|
||||
TrashIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
@@ -72,21 +71,29 @@ export const NodeDetailsDialog = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
setIsFavoriteState(node?.isFavorite);
|
||||
setIsIgnoredState(node?.isIgnored);
|
||||
}, [node]);
|
||||
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
navigate({ to: `/messages/direct/${node.num}` });
|
||||
setDialogOpen("nodeDetails", false);
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("toast.requestingPosition.title", { ns: "ui" }),
|
||||
@@ -94,13 +101,15 @@ export const NodeDetailsDialog = ({
|
||||
connection?.requestPosition(node.num).then(() =>
|
||||
toast({
|
||||
title: t("toast.positionRequestSent.title", { ns: "ui" }),
|
||||
})
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
|
||||
@@ -108,13 +117,15 @@ export const NodeDetailsDialog = ({
|
||||
connection?.traceRoute(node.num).then(() =>
|
||||
toast({
|
||||
title: t("toast.tracerouteSent.title", { ns: "ui" }),
|
||||
})
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleNodeRemove() {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNodeNumToBeRemoved(node?.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
@@ -122,14 +133,18 @@ export const NodeDetailsDialog = ({
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
|
||||
setIsFavoriteState(!isFavoriteState);
|
||||
}
|
||||
|
||||
function handleToggleIgnored() {
|
||||
if (!node) return;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
|
||||
setIsIgnoredState(!isIgnoredState);
|
||||
@@ -202,7 +217,7 @@ export const NodeDetailsDialog = ({
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-start"></div>
|
||||
<div className="flex flex-1 justify-start" />
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
@@ -255,7 +270,10 @@ export const NodeDetailsDialog = ({
|
||||
<p className="text-lg font-semibold">
|
||||
{t("nodeDetails.details")}
|
||||
</p>
|
||||
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
|
||||
<p>
|
||||
{t("nodeDetails.nodeNumber")}
|
||||
{node.num}
|
||||
</p>
|
||||
<p>
|
||||
{t("nodeDetails.nodeHexPrefix")}
|
||||
{numberToHexUnpadded(node.num)}
|
||||
@@ -268,21 +286,25 @@ export const NodeDetailsDialog = ({
|
||||
</p>
|
||||
<p>
|
||||
{t("nodeDetails.lastHeard")}
|
||||
{node.lastHeard === 0
|
||||
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
|
||||
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
||||
{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, " ")}
|
||||
{(
|
||||
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]}
|
||||
deviceType={
|
||||
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,36 +315,35 @@ export const NodeDetailsDialog = ({
|
||||
{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>}
|
||||
{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"
|
||||
@@ -338,14 +359,13 @@ export const NodeDetailsDialog = ({
|
||||
<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>
|
||||
),
|
||||
)}
|
||||
{deviceMetricsMap
|
||||
.filter((metric) => metric.value !== undefined)
|
||||
.map((metric) => (
|
||||
<p key={metric.key}>
|
||||
{metric.label}: {metric.format(metric?.value ?? 0)}
|
||||
</p>
|
||||
))}
|
||||
{node.deviceMetrics.uptimeSeconds && (
|
||||
<p>
|
||||
{t("nodeDetails.uptime")}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -13,6 +11,8 @@ import { fromByteArray } from "base64-js";
|
||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
|
||||
export interface PkiBackupDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,7 +30,9 @@ export const PkiBackupDialog = ({
|
||||
|
||||
const decodeKeyData = React.useCallback(
|
||||
(key: Uint8Array<ArrayBufferLike>) => {
|
||||
if (!key) return "";
|
||||
if (!key) {
|
||||
return "";
|
||||
}
|
||||
return fromByteArray(key ?? new Uint8Array(0));
|
||||
},
|
||||
[],
|
||||
@@ -41,20 +43,20 @@ export const PkiBackupDialog = ({
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const renderPrintWindow = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
if (!privateKey || !publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const printWindow = globalThis.open("", "_blank");
|
||||
if (printWindow) {
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>${
|
||||
t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
})
|
||||
}</title>
|
||||
<title>${t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
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; }
|
||||
@@ -62,13 +64,11 @@ export const PkiBackupDialog = ({
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${
|
||||
t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
})
|
||||
}</h1>
|
||||
<h1>${t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
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>
|
||||
@@ -82,10 +82,12 @@ export const PkiBackupDialog = ({
|
||||
printWindow.print();
|
||||
closeDialog();
|
||||
}
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
|
||||
|
||||
const createDownloadKeyFile = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
if (!privateKey || !publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedPrivateKey = decodeKeyData(privateKey);
|
||||
const decodedPublicKey = decodeKeyData(publicKey);
|
||||
@@ -116,7 +118,7 @@ export const PkiBackupDialog = ({
|
||||
document.body.removeChild(link);
|
||||
closeDialog();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -124,9 +126,7 @@ export const PkiBackupDialog = ({
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("pkiBackup.secureBackup")}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t("pkiBackup.secureBackup")}</DialogDescription>
|
||||
<DialogDescription>
|
||||
<span className="font-bold break-before-auto">
|
||||
{t("pkiBackup.loseKeysWarning")}
|
||||
|
||||
@@ -34,8 +34,7 @@ export const PkiRegenerateDialog = ({
|
||||
const { t } = useTranslation("dialog");
|
||||
const dialogText = {
|
||||
title: text.title || t("pkiRegenerate.title"),
|
||||
description: text.description ||
|
||||
t("pkiRegenerate.description"),
|
||||
description: text.description || t("pkiRegenerate.description"),
|
||||
button: text.button || t("button.regenerate"),
|
||||
};
|
||||
return (
|
||||
@@ -44,9 +43,7 @@ export const PkiRegenerateDialog = ({
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogText.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dialogText.description}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{dialogText.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -14,8 +13,9 @@ 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";
|
||||
import { QRCode } from "react-qrcode-logo";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
|
||||
export interface QRDialogProps {
|
||||
open: boolean;
|
||||
@@ -67,9 +67,7 @@ export const QRDialog = ({
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("qr.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("qr.description")}
|
||||
</DialogDescription>
|
||||
<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">
|
||||
@@ -80,13 +78,11 @@ export const QRDialog = ({
|
||||
{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}`}
|
||||
? t("page.broadcastLabel", { ns: "channels" })
|
||||
: `${t("page.channelIndex", {
|
||||
ns: "channels",
|
||||
index: channel.index,
|
||||
})}${channel.index}`}
|
||||
</Label>
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
@@ -94,9 +90,7 @@ export const QRDialog = ({
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) =>
|
||||
c !== channel.index
|
||||
),
|
||||
selectedChannels.filter((c) => c !== channel.index),
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([
|
||||
@@ -140,10 +134,7 @@ export const QRDialog = ({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Label>{t("qr.sharableUrl")}</Label>
|
||||
<Input
|
||||
value={qrCodeUrl}
|
||||
disabled
|
||||
/>
|
||||
<Input value={qrCodeUrl} disabled />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -10,18 +10,15 @@ import {
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RebootDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RebootDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RebootDialogProps) => {
|
||||
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
@@ -32,12 +29,8 @@ export const RebootDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("reboot.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("reboot.description")}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("reboot.title")}</DialogTitle>
|
||||
<DialogDescription>{t("reboot.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 p-4">
|
||||
<Input
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||
import {
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
ClassAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { JSX } from "react/jsx-runtime";
|
||||
import type { JSX } from "react/jsx-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||
|
||||
const rebootOtaMock = vi.fn();
|
||||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||
@@ -25,10 +25,9 @@ vi.mock("@components/UI/Button.tsx", async () => {
|
||||
return {
|
||||
...actual,
|
||||
Button: (
|
||||
props:
|
||||
& JSX.IntrinsicAttributes
|
||||
& ClassAttributes<HTMLButtonElement>
|
||||
& ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
props: JSX.IntrinsicAttributes &
|
||||
ClassAttributes<HTMLButtonElement> &
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
@@ -38,10 +37,9 @@ vi.mock("@components/UI/Input.tsx", async () => {
|
||||
return {
|
||||
...actual,
|
||||
Input: (
|
||||
props:
|
||||
& JSX.IntrinsicAttributes
|
||||
& ClassAttributes<HTMLInputElement>
|
||||
& InputHTMLAttributes<HTMLInputElement>,
|
||||
props: JSX.IntrinsicAttributes &
|
||||
ClassAttributes<HTMLInputElement> &
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
@@ -76,8 +74,9 @@ describe("RebootOTADialog", () => {
|
||||
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.getByRole("heading", { name: /schedule reboot/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,6 +9,8 @@ import {
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RebootOTADialogProps {
|
||||
@@ -20,9 +20,10 @@ export interface RebootOTADialogProps {
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootOTADialog = (
|
||||
{ open, onOpenChange }: RebootOTADialogProps,
|
||||
) => {
|
||||
export const RebootOTADialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RebootOTADialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
@@ -39,13 +40,15 @@ export const RebootOTADialog = (
|
||||
setInputValue(val);
|
||||
|
||||
const parsed = Number(val);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
setTime(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebootWithTimeout = async () => {
|
||||
if (!connection) return;
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
setIsScheduled(true);
|
||||
|
||||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
|
||||
@@ -63,7 +66,9 @@ export const RebootOTADialog = (
|
||||
};
|
||||
|
||||
const handleInstantReboot = async () => {
|
||||
if (!connection) return;
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
|
||||
onOpenChange(false);
|
||||
@@ -74,12 +79,8 @@ export const RebootOTADialog = (
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("rebootOta.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rebootOta.description")}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("rebootOta.title")}</DialogTitle>
|
||||
<DialogDescription>{t("rebootOta.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 p-2 items-center relative">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
vi.mock("@core/stores/messageStore");
|
||||
vi.mock("./useRefreshKeysDialog");
|
||||
@@ -12,8 +12,10 @@ const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
const getInitialState = () =>
|
||||
useDeviceStore.getInitialState?.() ??
|
||||
{ devices: new Map(), remoteDevices: new Map() };
|
||||
useDeviceStore.getInitialState?.() ?? {
|
||||
devices: new Map(),
|
||||
remoteDevices: new Map(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState(getInitialState(), true);
|
||||
@@ -31,7 +33,9 @@ test("does not render dialog if no error exists for active chat", () => {
|
||||
useDeviceStore.getState().addDevice(deviceId);
|
||||
|
||||
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
|
||||
if (!currentDeviceState) throw new Error("Device not found");
|
||||
if (!currentDeviceState) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
mockUseRefreshKeysDialog.mockReturnValue({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -5,21 +6,21 @@ import {
|
||||
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";
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
export interface RefreshKeysDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = (
|
||||
{ open, onOpenChange }: RefreshKeysDialogProps,
|
||||
) => {
|
||||
export const RefreshKeysDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RefreshKeysDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { activeChat } = useMessageStore();
|
||||
const { nodeErrors, getNode } = useDevice();
|
||||
@@ -40,9 +41,9 @@ export const RefreshKeysDialog = (
|
||||
}),
|
||||
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
|
||||
nodeWithError?.user?.longName ?? ""
|
||||
} (${nodeWithError?.user?.shortName ?? ""})${
|
||||
t("refreshKeys.description.keyMismatchReasonSuffix")
|
||||
}`,
|
||||
} (${nodeWithError?.user?.shortName ?? ""})${t(
|
||||
"refreshKeys.description.keyMismatchReasonSuffix",
|
||||
)}`,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -69,9 +70,7 @@ export const RefreshKeysDialog = (
|
||||
<p className="font-bold mb-0.5">
|
||||
{t("refreshKeys.label.acceptNewKeys")}
|
||||
</p>
|
||||
<p>
|
||||
{t("refreshKeys.description.acceptNewKeys")}
|
||||
</p>
|
||||
<p>{t("refreshKeys.description.acceptNewKeys")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,7 +9,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
|
||||
export interface RemoveNodeDialogProps {
|
||||
open: boolean;
|
||||
@@ -38,9 +38,7 @@ export const RemoveNodeDialog = ({
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("removeNode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("removeNode.description")}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t("removeNode.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
|
||||
@@ -10,18 +10,15 @@ import {
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ShutdownDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ShutdownDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ShutdownDialogProps) => {
|
||||
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
@@ -32,12 +29,8 @@ export const ShutdownDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("shutdown.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("shutdown.description")}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("shutdown.title")}</DialogTitle>
|
||||
<DialogDescription>{t("shutdown.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 p-4">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -7,11 +12,6 @@ import {
|
||||
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;
|
||||
@@ -31,9 +31,11 @@ export const TracerouteResponseDialog = ({
|
||||
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 ??
|
||||
const fromLongName =
|
||||
from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const fromShortName = from?.user?.shortName ??
|
||||
const fromShortName =
|
||||
from?.user?.shortName ??
|
||||
(from
|
||||
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||
: t("unknown.shortName"));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -8,11 +10,9 @@ import {
|
||||
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 { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RouterRoleDialogProps {
|
||||
@@ -20,9 +20,10 @@ export interface RouterRoleDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UnsafeRolesDialog = (
|
||||
{ open, onOpenChange }: RouterRoleDialogProps,
|
||||
) => {
|
||||
export const UnsafeRolesDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RouterRoleDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
+12
-15
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
UNSAFE_ROLES,
|
||||
useUnsafeRolesDialog,
|
||||
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
@@ -7,18 +13,11 @@ import {
|
||||
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")
|
||||
>();
|
||||
const actual =
|
||||
await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
@@ -75,9 +74,8 @@ describe("useUnsafeRolesDialog", () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const safeRole = "SAFE_ROLE";
|
||||
|
||||
const validationResult = await result.current.validateRoleSelection(
|
||||
safeRole,
|
||||
);
|
||||
const validationResult =
|
||||
await result.current.validateRoleSelection(safeRole);
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||
@@ -151,9 +149,8 @@ describe("useUnsafeRolesDialog", () => {
|
||||
mockDevice.setDialogOpen.mockClear();
|
||||
(eventBus.on as Mock).mockClear();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
unsafeRole,
|
||||
);
|
||||
const validationPromise =
|
||||
result.current.validateRoleSelection(unsafeRole);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"];
|
||||
export type UnsafeRole = typeof UNSAFE_ROLES[number];
|
||||
export type UnsafeRole = (typeof UNSAFE_ROLES)[number];
|
||||
|
||||
export const useUnsafeRolesDialog = () => {
|
||||
const { setDialogOpen } = useDevice();
|
||||
@@ -21,9 +21,11 @@ export const useUnsafeRolesDialog = () => {
|
||||
setDialogOpen("unsafeRoles", true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleResponse = (
|
||||
{ action }: { action: "confirm" | "dismiss" },
|
||||
) => {
|
||||
const handleResponse = ({
|
||||
action,
|
||||
}: {
|
||||
action: "confirm" | "dismiss";
|
||||
}) => {
|
||||
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||
resolve(action === "confirm");
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import {
|
||||
DynamicFormField,
|
||||
type FieldProps,
|
||||
} from "@components/Form/DynamicFormField.tsx";
|
||||
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { dotPaths } from "@core/utils/dotPath.ts";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
type Control,
|
||||
type DefaultValues,
|
||||
@@ -13,16 +18,11 @@ import {
|
||||
get,
|
||||
type Path,
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
type UseFormReturn,
|
||||
useForm,
|
||||
} 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";
|
||||
import type { ZodType } from "zod/v4";
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T>;
|
||||
@@ -86,41 +86,34 @@ export function DynamicForm<T extends FieldValues>({
|
||||
formId,
|
||||
}: DynamicFormProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
addError,
|
||||
removeError,
|
||||
} = useAppStore();
|
||||
const { addError, removeError } = useAppStore();
|
||||
|
||||
const internalMethods = useForm<T>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues,
|
||||
resolver: validationSchema
|
||||
? createZodResolver(validationSchema)
|
||||
: undefined,
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values,
|
||||
});
|
||||
|
||||
const methods = propMethods ?? internalMethods;
|
||||
|
||||
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?.(internalMethods);
|
||||
}
|
||||
}, [onFormInit, propMethods, methods]);
|
||||
}, [onFormInit, propMethods, internalMethods]);
|
||||
|
||||
useEffect(() => {
|
||||
const errorKeys = Object.keys(formState.errors);
|
||||
if (formId) {
|
||||
if (
|
||||
errorKeys.length === 0
|
||||
) {
|
||||
if (errorKeys.length === 0) {
|
||||
dotPaths(getValues()).forEach((key) => {
|
||||
removeError(key);
|
||||
});
|
||||
@@ -132,19 +125,27 @@ export function DynamicForm<T extends FieldValues>({
|
||||
addError(formId, "");
|
||||
}
|
||||
}
|
||||
}, [formState.errors]);
|
||||
}, [formState.errors, addError, formId, getValues, removeError]);
|
||||
|
||||
const isDisabled = (
|
||||
disabledBy?: DisabledBy<T>[],
|
||||
disabled?: boolean,
|
||||
): boolean => {
|
||||
if (disabled) return true;
|
||||
if (!disabledBy) return false;
|
||||
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 (value === "always") {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return field.invert ? value : !value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return field.invert
|
||||
? field.selector !== value
|
||||
@@ -181,14 +182,16 @@ export function DynamicForm<T extends FieldValues>({
|
||||
fieldName={field.name}
|
||||
description={field.description}
|
||||
valid={!error}
|
||||
validationText={error
|
||||
? String(
|
||||
t([`formValidation.${error.type}`, error.message], {
|
||||
returnObjects: false,
|
||||
...error.params,
|
||||
}),
|
||||
)
|
||||
: ""}
|
||||
validationText={
|
||||
error
|
||||
? String(
|
||||
t([`formValidation.${error.type}`, error.message], {
|
||||
returnObjects: false,
|
||||
...error.params,
|
||||
}),
|
||||
)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
@@ -203,11 +206,7 @@ export function DynamicForm<T extends FieldValues>({
|
||||
</div>
|
||||
))}
|
||||
{hasSubmitButton && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!formState.isValid}
|
||||
>
|
||||
<Button type="submit" variant="outline" disabled={!formState.isValid}>
|
||||
{t("button.submit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
type MultiSelectFieldProps,
|
||||
MultiSelectInput,
|
||||
} from "./FormMultiSelect.tsx";
|
||||
import {
|
||||
GenericInput,
|
||||
type InputFieldProps,
|
||||
@@ -19,6 +15,10 @@ import {
|
||||
ToggleInput,
|
||||
} from "@components/Form/FormToggle.tsx";
|
||||
import type { Control, FieldValues } from "react-hook-form";
|
||||
import {
|
||||
type MultiSelectFieldProps,
|
||||
MultiSelectInput,
|
||||
} from "./FormMultiSelect.tsx";
|
||||
|
||||
export type FieldProps<T> =
|
||||
| InputFieldProps<T>
|
||||
|
||||
@@ -58,7 +58,9 @@ export function GenericInput<T extends FieldValues>({
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
if (field.inputChange) {
|
||||
field.inputChange(e);
|
||||
}
|
||||
|
||||
controllerField.onChange(
|
||||
field.type === "number"
|
||||
@@ -76,14 +78,17 @@ export function GenericInput<T extends FieldValues>({
|
||||
<Input
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number"
|
||||
? String(controllerField.value)
|
||||
: controllerField.value}
|
||||
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"}
|
||||
showPasswordToggle={
|
||||
field.properties?.showPasswordToggle || field.type === "password"
|
||||
}
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,11 +2,11 @@ import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
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";
|
||||
@@ -15,9 +15,7 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
isChecked: (name: string) => boolean;
|
||||
value: string[];
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue:
|
||||
| { [s: string]: string | number }
|
||||
| typeof FLAGS_CONFIG;
|
||||
enumValue: { [s: string]: string | number } | typeof FLAGS_CONFIG;
|
||||
formatEnumName?: boolean;
|
||||
};
|
||||
}
|
||||
@@ -39,7 +37,7 @@ export function MultiSelectInput<T extends FieldValues>({
|
||||
([key, configOrValue]) => {
|
||||
if (isNewConfigStructure) {
|
||||
const config =
|
||||
configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
|
||||
configOrValue as (typeof FLAGS_CONFIG)[keyof typeof FLAGS_CONFIG];
|
||||
return {
|
||||
key,
|
||||
display: t(config.i18nKey),
|
||||
|
||||
@@ -2,11 +2,11 @@ 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";
|
||||
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
|
||||
import type { ButtonVariant } from "../UI/Button.tsx";
|
||||
|
||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "passwordGenerator";
|
||||
@@ -38,24 +38,22 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
|
||||
useEffect(() => {
|
||||
trigger(field.name);
|
||||
}, [field.devicePSKBitCount, field.name, trigger]);
|
||||
}, [field.name, trigger]);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={(
|
||||
{
|
||||
field: { value, onChange, ...rest },
|
||||
},
|
||||
) => (
|
||||
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);
|
||||
if (field.inputChange) {
|
||||
field.inputChange(e);
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
selectChange={field.selectChange ?? (() => {})}
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select";
|
||||
@@ -73,10 +73,14 @@ export function SelectInput<T extends FieldValues>({
|
||||
|
||||
if (field.validate) {
|
||||
const isValid = await field.validate(selectedKey);
|
||||
if (!isValid) return;
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.selectChange) field.selectChange(newValue, selectedKey);
|
||||
if (field.selectChange) {
|
||||
field.selectChange(newValue, selectedKey);
|
||||
}
|
||||
onChange(Number.parseInt(newValue));
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
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";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "toggle";
|
||||
@@ -22,9 +22,7 @@ export function ToggleInput<T extends FieldValues>({
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={(
|
||||
{ field: { value, onChange, ...rest } },
|
||||
) => (
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(v) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ZodType } from "zod/v4";
|
||||
import type {
|
||||
FieldError,
|
||||
FieldValues,
|
||||
@@ -6,6 +5,7 @@ import type {
|
||||
ResolverOptions,
|
||||
ResolverResult,
|
||||
} from "react-hook-form";
|
||||
import type { ZodType } from "zod/v4";
|
||||
|
||||
export function createZodResolver<T extends FieldValues>(
|
||||
schema: ZodType<T, unknown>,
|
||||
@@ -32,18 +32,18 @@ export function createZodResolver<T extends FieldValues>(
|
||||
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 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 newCode =
|
||||
code.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) +
|
||||
(suffix ? `.${suffix}` : "");
|
||||
|
||||
const fieldError: FieldError & { params?: Record<string, unknown> } = {
|
||||
type: newCode,
|
||||
|
||||
@@ -11,6 +11,5 @@ export const KeyBackupReminder = () => {
|
||||
onAccept: () => setDialogOpen("pkiBackup", true),
|
||||
enabled: true,
|
||||
});
|
||||
// deno-lint-ignore jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Check, Languages } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LangCode, supportedLanguages } from "@app/i18n-config.ts";
|
||||
import { type LangCode, supportedLanguages } from "@app/i18n-config.ts";
|
||||
import useLang from "@core/hooks/useLang.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Check, Languages } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "./UI/Button.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -9,8 +12,6 @@ import {
|
||||
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;
|
||||
@@ -22,9 +23,12 @@ export default function LanguageSwitcher({
|
||||
const { i18n } = useTranslation("ui");
|
||||
const { set: setLanguage, currentLanguage } = useLang();
|
||||
|
||||
const handleLanguageChange = async (languageCode: LangCode) => {
|
||||
await setLanguage(languageCode, true);
|
||||
};
|
||||
const handleLanguageChange = useCallback(
|
||||
async (languageCode: LangCode) => {
|
||||
await setLanguage(languageCode, true);
|
||||
},
|
||||
[setLanguage],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||
import { useEffect, useRef } from "react";
|
||||
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) => {
|
||||
export const BaseMap = ({ 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);
|
||||
if (map && onLoad) {
|
||||
onLoad(map);
|
||||
}
|
||||
}, [onLoad]);
|
||||
|
||||
return (
|
||||
@@ -45,14 +47,12 @@ export const Map = ({ children, onLoad }: MapProps) => {
|
||||
}}
|
||||
/>
|
||||
{/* { Disabled for now until we can use i18n for the geolocate control} */}
|
||||
{
|
||||
/* <GeolocateControl
|
||||
{/* <GeolocateControl
|
||||
position="top-right"
|
||||
i18nIsDynamicList
|
||||
positionOptions={{ enableHighAccuracy: true }}
|
||||
trackUserLocation
|
||||
/> */
|
||||
}
|
||||
/> */}
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
<ScaleControl />
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { makeChannelSchema } from "@app/validation/channel.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -8,8 +9,7 @@ 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";
|
||||
import type { infer as zodInfer } from "zod/v4";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel.Channel;
|
||||
@@ -20,9 +20,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [pass, setPass] = useState<string>(
|
||||
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
);
|
||||
@@ -30,12 +29,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
channel?.settings?.psk.length ?? 16,
|
||||
);
|
||||
|
||||
const ChannelValidationSchema = useMemo(
|
||||
() => {
|
||||
return makeChannelSchema(byteCount);
|
||||
},
|
||||
[byteCount],
|
||||
);
|
||||
const ChannelValidationSchema = useMemo(() => {
|
||||
return makeChannelSchema(byteCount);
|
||||
}, [byteCount]);
|
||||
|
||||
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
|
||||
|
||||
@@ -52,10 +48,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
},
|
||||
});
|
||||
connection?.setChannel(channel).then(() => {
|
||||
console.debug(t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
channelName: channel.settings?.name,
|
||||
}));
|
||||
console.debug(
|
||||
t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
channelName: channel.settings?.name,
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
@@ -108,7 +106,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
...channel?.settings?.moduleSettings,
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
@@ -127,12 +125,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
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,
|
||||
},
|
||||
enumValue:
|
||||
channel.index === 0
|
||||
? { [t("role.options.primary")]: 1 }
|
||||
: {
|
||||
[t("role.options.disabled")]: 0,
|
||||
[t("role.options.secondary")]: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -182,79 +181,36 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type BluetoothValidation,
|
||||
BluetoothValidationSchema,
|
||||
@@ -8,14 +9,16 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ configCase: "bluetooth" });
|
||||
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type DeviceValidation,
|
||||
DeviceValidationSchema,
|
||||
} from "@app/validation/config/device.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
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";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DeviceConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DeviceValidation>;
|
||||
}
|
||||
export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
||||
useWaitForConfig({ configCase: "device" });
|
||||
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
@@ -113,8 +116,8 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
||||
properties: {
|
||||
fieldLength: {
|
||||
max: 64,
|
||||
currentValueLength: getEffectiveConfig("device")?.tzdef
|
||||
?.length,
|
||||
currentValueLength:
|
||||
getEffectiveConfig("device")?.tzdef?.length,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type DisplayValidation,
|
||||
DisplayValidationSchema,
|
||||
@@ -8,14 +9,15 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ configCase: "display" });
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type LoRaValidation,
|
||||
LoRaValidationSchema,
|
||||
@@ -8,14 +9,16 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ configCase: "lora" });
|
||||
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type NetworkValidation,
|
||||
NetworkValidationSchema,
|
||||
@@ -8,18 +9,20 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ configCase: "network" });
|
||||
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
@@ -72,24 +75,28 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||
),
|
||||
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||
},
|
||||
enabledProtocols: config.network?.enabledProtocols ??
|
||||
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}
|
||||
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"),
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
type FlagName,
|
||||
usePositionFlags,
|
||||
} from "@core/hooks/usePositionFlags.ts";
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type PositionValidation,
|
||||
PositionValidationSchema,
|
||||
@@ -11,16 +8,22 @@ import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
type FlagName,
|
||||
usePositionFlags,
|
||||
} from "@core/hooks/usePositionFlags.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ configCase: "position" });
|
||||
|
||||
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type PowerValidation,
|
||||
PowerValidationSchema,
|
||||
@@ -8,14 +9,16 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ configCase: "power" });
|
||||
|
||||
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
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 { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type ParsedSecurity,
|
||||
type RawSecurity,
|
||||
RawSecuritySchema,
|
||||
} from "@app/validation/config/security.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type DefaultValues, useForm } from "react-hook-form";
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SecurityConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RawSecurity>;
|
||||
}
|
||||
export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||
useWaitForConfig({ configCase: "security" });
|
||||
|
||||
const {
|
||||
config,
|
||||
setWorkingConfig,
|
||||
@@ -43,19 +46,11 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||
privateKey: fromByteArray(
|
||||
securityConfig?.privateKey ?? new Uint8Array(0),
|
||||
),
|
||||
publicKey: fromByteArray(
|
||||
securityConfig?.publicKey ?? 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),
|
||||
),
|
||||
fromByteArray(securityConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
|
||||
fromByteArray(securityConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
|
||||
fromByteArray(securityConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -73,15 +68,15 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||
onFormInit?.(formMethods);
|
||||
}, [onFormInit, formMethods]);
|
||||
|
||||
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [managedModeDialogOpen, setManagedModeDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const onSubmit = (data: RawSecurity) => {
|
||||
if (!formState.isReady) return;
|
||||
if (!formState.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ParsedSecurity = {
|
||||
...data,
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.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 { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
|
||||
|
||||
export const BLE = (
|
||||
{ closeDialog }: TabElementProps,
|
||||
) => {
|
||||
export const BLE = ({ closeDialog }: TabElementProps) => {
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
@@ -77,10 +75,12 @@ export const BLE = (
|
||||
if (exists === -1) {
|
||||
setBleDevices(bleDevices.concat(device));
|
||||
}
|
||||
}).catch((error: Error) => {
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error("Error requesting device:", error);
|
||||
setConnectionInProgress(false);
|
||||
}).finally(() => {
|
||||
})
|
||||
.finally(() => {
|
||||
setConnectionInProgress(false);
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores/appStore.ts", () => ({
|
||||
@@ -35,8 +35,9 @@ describe("HTTP Component", () => {
|
||||
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.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -45,8 +46,9 @@ describe("HTTP Component", () => {
|
||||
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();
|
||||
expect(
|
||||
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles HTTPS switch and updates prefix", () => {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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 { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.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 { AlertTriangle } from "lucide-react";
|
||||
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 {
|
||||
@@ -21,9 +21,7 @@ interface FormData {
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export const HTTP = (
|
||||
{ closeDialog }: TabElementProps,
|
||||
) => {
|
||||
export const HTTP = ({ closeDialog }: TabElementProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const isURLHTTPS = location.protocol === "https:";
|
||||
@@ -35,11 +33,11 @@ export const HTTP = (
|
||||
const { control, handleSubmit, register } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
ip: ["client.meshtastic.org", "localhost"].includes(
|
||||
globalThis.location.hostname,
|
||||
)
|
||||
globalThis.location.hostname,
|
||||
)
|
||||
? "meshtastic.local"
|
||||
: globalThis.location.host,
|
||||
tls: isURLHTTPS ? true : false,
|
||||
tls: !!isURLHTTPS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,9 +45,10 @@ export const HTTP = (
|
||||
field: { value: tlsValue, onChange: setTLS },
|
||||
} = useController({ name: "tls", control });
|
||||
|
||||
const [connectionError, setConnectionError] = useState<
|
||||
{ host: string; secure: boolean } | null
|
||||
>(null);
|
||||
const [connectionError, setConnectionError] = useState<{
|
||||
host: string;
|
||||
secure: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setConnectionInProgress(true);
|
||||
@@ -85,9 +84,11 @@ export const HTTP = (
|
||||
<div>
|
||||
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
|
||||
<Input
|
||||
prefix={tlsValue
|
||||
? `${t("newDeviceDialog.https")}://`
|
||||
: `${t("newDeviceDialog.http")}://`}
|
||||
prefix={
|
||||
tlsValue
|
||||
? `${t("newDeviceDialog.https")}://`
|
||||
: `${t("newDeviceDialog.http")}://`
|
||||
}
|
||||
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
|
||||
className="text-slate-900 dark:text-slate-100"
|
||||
{...register("ip")}
|
||||
@@ -136,9 +137,10 @@ export const HTTP = (
|
||||
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
|
||||
{connectionError.secure
|
||||
? t(
|
||||
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
|
||||
)
|
||||
: ""}.{" "}
|
||||
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
|
||||
)
|
||||
: ""}
|
||||
.{" "}
|
||||
<Link
|
||||
href="https://meshtastic.org/docs/software/web-client/#http"
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
@@ -151,10 +153,7 @@ export const HTTP = (
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
>
|
||||
<Button type="submit" variant="default">
|
||||
<span>
|
||||
{connectionInProgress
|
||||
? t("newDeviceDialog.connecting")
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.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";
|
||||
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
|
||||
|
||||
export const Serial = (
|
||||
{ closeDialog }: TabElementProps,
|
||||
) => {
|
||||
export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
@@ -54,13 +52,13 @@ export const Serial = (
|
||||
disabled={connectionInProgress}
|
||||
>
|
||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||
{serialPorts.map((port, index) => {
|
||||
{serialPorts.map((port) => {
|
||||
const { usbProductId, usbVendorId } = port.getInfo();
|
||||
const vendor = usbVendorId ?? t("unknown.shortName");
|
||||
const product = usbProductId ?? t("unknown.shortName");
|
||||
return (
|
||||
<Button
|
||||
key={`${vendor}-${product}-${index}`}
|
||||
key={`${vendor}-${product}`}
|
||||
disabled={port.readable !== null}
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
@@ -70,7 +68,6 @@ export const Serial = (
|
||||
}}
|
||||
>
|
||||
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
|
||||
index: index,
|
||||
vendorId: vendor,
|
||||
productId: product,
|
||||
})}
|
||||
@@ -86,14 +83,18 @@ export const Serial = (
|
||||
<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);
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
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 { Protobuf } from "@meshtastic/core";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
Dot,
|
||||
LockIcon,
|
||||
@@ -15,16 +24,7 @@ import {
|
||||
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;
|
||||
@@ -60,23 +60,21 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
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")}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
@@ -105,9 +103,11 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
<Star
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={node.isFavorite
|
||||
? t("nodeDetail.favorite.label")
|
||||
: t("nodeDetail.notFavorite.label")}
|
||||
aria-label={
|
||||
node.isFavorite
|
||||
? t("nodeDetail.favorite.label")
|
||||
: t("nodeDetail.notFavorite.label")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import type { Message } from "@core/stores/messageStore/types.ts";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { Message } from "@core/stores/messageStore/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
|
||||
@@ -41,10 +41,7 @@ export const MessageActionsMenu = ({
|
||||
const iconSizeClass = "size-4";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(hoverIconBarClass)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={cn(hoverIconBarClass)}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -6,13 +7,10 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
|
||||
import { Types } from "@meshtastic/core";
|
||||
import { MessageInput, type MessageInputProps } from "./MessageInput.tsx";
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: vi.fn((
|
||||
{ type, className, children, onClick, onSubmit, ...rest },
|
||||
) => (
|
||||
Button: vi.fn(({ type, className, children, onClick, onSubmit, ...rest }) => (
|
||||
<button
|
||||
type={type}
|
||||
className={className}
|
||||
@@ -26,11 +24,8 @@ vi.mock("@components/UI/Button.tsx", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Input.tsx", () => ({
|
||||
Input: vi.fn((
|
||||
{ autoFocus, minLength, name, placeholder, value, onChange },
|
||||
) => (
|
||||
Input: vi.fn(({ minLength, name, placeholder, value, onChange }) => (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
minLength={minLength}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
@@ -225,16 +220,14 @@ describe("MessageInput", () => {
|
||||
it("should work with broadcast destination for drafts", () => {
|
||||
const broadcastDest: Types.Destination = "broadcast";
|
||||
mockGetDraft.mockImplementation((key) =>
|
||||
key === broadcastDest ? "Broadcast draft" : ""
|
||||
key === broadcastDest ? "Broadcast draft" : "",
|
||||
);
|
||||
|
||||
renderComponent({ to: broadcastDest });
|
||||
|
||||
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
|
||||
expect(
|
||||
(screen.getByTestId(
|
||||
"message-input-field",
|
||||
) as HTMLInputElement).value,
|
||||
(screen.getByTestId("message-input-field") as HTMLInputElement).value,
|
||||
).toBe("Broadcast draft");
|
||||
|
||||
const inputElement = screen.getByTestId(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
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 {
|
||||
@@ -12,11 +12,7 @@ export interface MessageInputProps {
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export const MessageInput = ({
|
||||
onSend,
|
||||
to,
|
||||
maxBytes,
|
||||
}: MessageInputProps) => {
|
||||
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
|
||||
const { setDraft, getDraft, clearDraft } = useMessageStore();
|
||||
const { t } = useTranslation("messages");
|
||||
|
||||
@@ -25,7 +21,7 @@ export const MessageInput = ({
|
||||
const initialDraft = getDraft(to);
|
||||
const [localDraft, setLocalDraft] = useState(initialDraft);
|
||||
const [messageBytes, setMessageBytes] = useState(() =>
|
||||
calculateBytes(initialDraft)
|
||||
calculateBytes(initialDraft),
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -41,7 +37,9 @@ export const MessageInput = ({
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!localDraft.trim()) return;
|
||||
if (!localDraft.trim()) {
|
||||
return;
|
||||
}
|
||||
// Reset bytes *before* sending (consider if onSend failure needs different handling)
|
||||
setMessageBytes(0);
|
||||
|
||||
@@ -56,9 +54,8 @@ export const MessageInput = ({
|
||||
<div className="flex gap-2">
|
||||
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
|
||||
<div className="flex grow gap-1">
|
||||
<label className="w-full">
|
||||
<label className="w-full" htmlFor="messageInput">
|
||||
<Input
|
||||
autoFocus
|
||||
minLength={1}
|
||||
name="messageInput"
|
||||
placeholder={t("sendMessage.placeholder")}
|
||||
@@ -70,15 +67,13 @@ export const MessageInput = ({
|
||||
|
||||
<label
|
||||
data-testid="byte-counter"
|
||||
htmlFor="messageInput"
|
||||
className="flex items-center w-20 p-1 text-sm place-content-end"
|
||||
>
|
||||
{messageBytes}/{maxBytes}
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
>
|
||||
<Button type="submit" variant="default">
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
@@ -6,18 +7,18 @@ import {
|
||||
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 type { Message } from "@core/stores/messageStore/types.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { type Protobuf, Types } from "@meshtastic/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
|
||||
|
||||
interface MessageStatusInfo {
|
||||
@@ -27,12 +28,13 @@ interface MessageStatusInfo {
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
const StatusTooltip = (
|
||||
{ statusInfo, children }: {
|
||||
statusInfo: MessageStatusInfo;
|
||||
children: ReactNode;
|
||||
},
|
||||
) => (
|
||||
const StatusTooltip = ({
|
||||
statusInfo,
|
||||
children,
|
||||
}: {
|
||||
statusInfo: MessageStatusInfo;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
@@ -77,16 +79,20 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
[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 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,
|
||||
() =>
|
||||
(state: MessageState): MessageStatusInfo =>
|
||||
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
|
||||
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
|
||||
);
|
||||
|
||||
@@ -103,8 +109,8 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
const longName = messageUser?.user?.longName;
|
||||
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
||||
const derivedDisplayName = longName || derivedShortName;
|
||||
const isFavorite = messageUser?.num !== myNodeNum &&
|
||||
messageUser?.isFavorite;
|
||||
const isFavorite =
|
||||
messageUser?.num !== myNodeNum && messageUser?.isFavorite;
|
||||
return {
|
||||
displayName: derivedDisplayName,
|
||||
shortName: derivedShortName,
|
||||
@@ -116,7 +122,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
const StatusIconComponent = messageStatusInfo.icon;
|
||||
|
||||
const messageDate = useMemo(
|
||||
() => message.date ? new Date(message.date) : null,
|
||||
() => (message.date ? new Date(message.date) : null),
|
||||
[message.date],
|
||||
);
|
||||
const locale = i18n.language;
|
||||
@@ -199,11 +205,9 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions Menu Placeholder */}
|
||||
{
|
||||
/* <div className="absolute top-1 right-1">
|
||||
{/* <div className="absolute top-1 right-1">
|
||||
<MessageActionsMenu onReply={() => console.log("Reply")} />
|
||||
</div> */
|
||||
}
|
||||
</div> */}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores/deviceStore");
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("TraceRoute", () => {
|
||||
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
|
||||
id: "source-node",
|
||||
isLicensed: false,
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||
} as Protobuf.Mesh.NodeInfo["user"],
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("TraceRoute", () => {
|
||||
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
|
||||
id: "destination-node",
|
||||
isLicensed: false,
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||
} as Protobuf.Mesh.NodeInfo["user"],
|
||||
};
|
||||
|
||||
@@ -123,13 +123,7 @@ describe("TraceRoute", () => {
|
||||
});
|
||||
|
||||
it("renders '??' for missing SNR values", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={fromUser}
|
||||
to={toUser}
|
||||
route={[1]}
|
||||
/>,
|
||||
);
|
||||
render(<TraceRoute from={fromUser} to={toUser} route={[1]} />);
|
||||
|
||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||
// Check for translated '??' placeholder
|
||||
|
||||
@@ -22,9 +22,7 @@ interface RoutePathProps {
|
||||
snr?: number[];
|
||||
}
|
||||
|
||||
const RoutePath = (
|
||||
{ title, from, to, path, snr }: RoutePathProps,
|
||||
) => {
|
||||
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
|
||||
const { getNode } = useDevice();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type AmbientLightingValidation,
|
||||
AmbientLightingValidationSchema,
|
||||
@@ -8,17 +8,19 @@ import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
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,
|
||||
) => {
|
||||
export const AmbientLighting = ({
|
||||
onFormInit,
|
||||
}: AmbientLightingModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type AudioValidation,
|
||||
AudioValidationSchema,
|
||||
@@ -8,15 +9,16 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "audio" });
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type CannedMessageValidation,
|
||||
CannedMessageValidationSchema,
|
||||
@@ -8,17 +9,19 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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,
|
||||
) => {
|
||||
export const CannedMessage = ({
|
||||
onFormInit,
|
||||
}: CannedMessageModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
@@ -92,8 +95,9 @@ export const CannedMessage = (
|
||||
label: t("cannedMessage.inputbrokerEventCw.label"),
|
||||
description: t("cannedMessage.inputbrokerEventCw.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig
|
||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
enumValue:
|
||||
Protobuf.ModuleConfig
|
||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -102,8 +106,9 @@ export const CannedMessage = (
|
||||
label: t("cannedMessage.inputbrokerEventCcw.label"),
|
||||
description: t("cannedMessage.inputbrokerEventCcw.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig
|
||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
enumValue:
|
||||
Protobuf.ModuleConfig
|
||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -112,8 +117,9 @@ export const CannedMessage = (
|
||||
label: t("cannedMessage.inputbrokerEventPress.label"),
|
||||
description: t("cannedMessage.inputbrokerEventPress.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig
|
||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
enumValue:
|
||||
Protobuf.ModuleConfig
|
||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type DetectionSensorValidation,
|
||||
DetectionSensorValidationSchema,
|
||||
@@ -8,17 +8,20 @@ import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
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,
|
||||
) => {
|
||||
export const DetectionSensor = ({
|
||||
onFormInit,
|
||||
}: DetectionSensorModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
@@ -135,8 +138,9 @@ export const DetectionSensor = (
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig
|
||||
.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||
enumValue:
|
||||
Protobuf.ModuleConfig
|
||||
.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type ExternalNotificationValidation,
|
||||
ExternalNotificationValidationSchema,
|
||||
@@ -8,17 +9,19 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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,
|
||||
) => {
|
||||
export const ExternalNotification = ({
|
||||
onFormInit,
|
||||
}: ExternalNotificationModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "externalNotification" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type MqttValidation,
|
||||
MqttValidationSchema,
|
||||
@@ -8,15 +8,18 @@ import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "mqtt" });
|
||||
|
||||
const {
|
||||
config,
|
||||
moduleConfig,
|
||||
@@ -55,10 +58,12 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||
) => {
|
||||
return cfg
|
||||
? {
|
||||
...cfg,
|
||||
mapReportSettings: cfg.mapReportSettings ??
|
||||
{ publishIntervalSecs: 0, positionPrecision: 10 },
|
||||
}
|
||||
...cfg,
|
||||
mapReportSettings: cfg.mapReportSettings ?? {
|
||||
publishIntervalSecs: 0,
|
||||
positionPrecision: 10,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@@ -202,78 +207,77 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||
{
|
||||
type: "select",
|
||||
name: "mapReportSettings.positionPrecision",
|
||||
label: t(
|
||||
"mqtt.mapReportSettings.positionPrecision.label",
|
||||
),
|
||||
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,
|
||||
},
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type NeighborInfoValidation,
|
||||
NeighborInfoValidationSchema,
|
||||
@@ -8,15 +8,18 @@ import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "neighborInfo" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type PaxcounterValidation,
|
||||
PaxcounterValidationSchema,
|
||||
@@ -8,15 +9,17 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "paxcounter" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type RangeTestValidation,
|
||||
RangeTestValidationSchema,
|
||||
@@ -8,21 +9,24 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "rangeTest" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: RangeTestValidation) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type SerialValidation,
|
||||
SerialValidationSchema,
|
||||
@@ -8,15 +9,17 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "serial" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type StoreForwardValidation,
|
||||
StoreForwardValidationSchema,
|
||||
@@ -8,15 +9,17 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "storeForward" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type TelemetryValidation,
|
||||
TelemetryValidationSchema,
|
||||
@@ -8,15 +9,17 @@ import {
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.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) => {
|
||||
useWaitForConfig({ moduleConfigCase: "telemetry" });
|
||||
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
import Footer from "@components/UI/Footer.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
|
||||
export interface ActionItem {
|
||||
key: string;
|
||||
@@ -90,7 +90,9 @@ export const PageLayout = ({
|
||||
aria-busy={action.isLoading}
|
||||
>
|
||||
{action.icon &&
|
||||
(action.isLoading ? <Spinner size="md" /> : (
|
||||
(action.isLoading ? (
|
||||
<Spinner size="md" />
|
||||
) : (
|
||||
<action.icon
|
||||
className={cn("h-5 w-5", action.iconClasses)}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
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 { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import type { Page } from "@core/stores/deviceStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
CircleChevronLeft,
|
||||
LayersIcon,
|
||||
@@ -15,13 +17,10 @@ import {
|
||||
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 type React from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export interface SidebarProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -89,7 +88,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
|
||||
|
||||
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
|
||||
Math.max(getNodesLength() - 1, 0)
|
||||
Math.max(getNodesLength() - 1, 0),
|
||||
);
|
||||
|
||||
const [_, startNodeCountTransition] = useTransition();
|
||||
@@ -189,37 +188,36 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
</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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,8 @@ 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";
|
||||
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||
|
||||
type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
|
||||
import { getColorFromText, isLightColor } from "@app/core/utils/color";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
@@ -7,15 +6,10 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type RGBColor = {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
};
|
||||
|
||||
interface AvatarProps {
|
||||
text: string | number;
|
||||
size?: "sm" | "lg";
|
||||
@@ -24,49 +18,6 @@ interface AvatarProps {
|
||||
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",
|
||||
@@ -83,7 +34,7 @@ export const Avatar = ({
|
||||
|
||||
const safeText = text?.toString().toUpperCase();
|
||||
const bgColor = getColorFromText(safeText);
|
||||
const isLight = ColorUtils.isLight(bgColor);
|
||||
const isLight = isLightColor(bgColor);
|
||||
const textColor = isLight ? "#000000" : "#FFFFFF";
|
||||
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
|
||||
|
||||
@@ -100,48 +51,42 @@ export const Avatar = ({
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
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",
|
||||
@@ -19,8 +19,7 @@ const buttonVariants = cva(
|
||||
"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",
|
||||
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",
|
||||
@@ -39,8 +38,7 @@ const buttonVariants = cva(
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
icon?: React.ReactNode;
|
||||
iconAlignment?: "left" | "right";
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
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";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@components/UI/Label.tsx", () => ({
|
||||
Label: (
|
||||
{ children, className, htmlFor, id }: {
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
htmlFor: string;
|
||||
id: string;
|
||||
},
|
||||
) => (
|
||||
Label: ({
|
||||
children,
|
||||
className,
|
||||
htmlFor,
|
||||
id,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
htmlFor: string;
|
||||
id: string;
|
||||
}) => (
|
||||
<label
|
||||
data-testid="label-component"
|
||||
className={className}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
import { Check } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
|
||||
interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
@@ -16,10 +15,9 @@ interface CheckboxProps {
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
checked = false,
|
||||
onChange,
|
||||
className,
|
||||
labelClassName,
|
||||
id: propId,
|
||||
children,
|
||||
disabled = false,
|
||||
@@ -30,71 +28,49 @@ export function Checkbox({
|
||||
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);
|
||||
const handleToggle = (): void => {
|
||||
if (!disabled) {
|
||||
onChange?.(!checked);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex items-center gap-3",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={handleToggle}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
name={name}
|
||||
className="sr-only peer"
|
||||
{...rest}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors",
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2",
|
||||
{ "border-slate-500 bg-slate-500": checked },
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<div className="animate-fade-in">
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Dialog, DialogContent } from "@components/UI/Dialog.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
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;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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 newGithubIssueUrl from "@core/utils/github.ts";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export function ErrorPage({ error }: { error: Error }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">
|
||||
@@ -19,12 +19,8 @@ export function ErrorPage({ error }: { error: Error }) {
|
||||
<Heading as="h2" className="text-text-primary">
|
||||
{t("errorPage.title")}
|
||||
</Heading>
|
||||
<P>
|
||||
{t("errorPage.description1")}
|
||||
</P>
|
||||
<P>
|
||||
{t("errorPage.description2")}
|
||||
</P>
|
||||
<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>
|
||||
|
||||
@@ -27,12 +27,14 @@ const Footer = ({ className, ...props }: FooterProps) => {
|
||||
<Trans
|
||||
i18nKey="footer.text"
|
||||
components={[
|
||||
// biome-ignore lint/a11y/useAnchorContent: because I want to use the link component
|
||||
<a
|
||||
key="vercel"
|
||||
rel="noopener noreferrer"
|
||||
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
|
||||
className="hover:underline text-link"
|
||||
/>,
|
||||
// biome-ignore lint/a11y/useAnchorContent: because I want to use the link component
|
||||
<a
|
||||
key="legal"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ActionButton {
|
||||
@@ -33,58 +33,62 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Generator = (
|
||||
{
|
||||
type,
|
||||
devicePSKBitCount,
|
||||
id = "pskInput",
|
||||
variant,
|
||||
value,
|
||||
actionButtons,
|
||||
bits,
|
||||
selectChange,
|
||||
inputChange,
|
||||
disabled,
|
||||
showPasswordToggle,
|
||||
showCopyButton,
|
||||
...props
|
||||
}: GeneratorProps,
|
||||
) => {
|
||||
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",
|
||||
},
|
||||
];
|
||||
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;
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
if (!setValue) return;
|
||||
if (!setValue) {
|
||||
return;
|
||||
}
|
||||
inputRef.current.value = "";
|
||||
setValue.call(inputRef.current, value);
|
||||
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
|
||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||
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 * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const cnInvalidBase = "border-2 border-red-500 dark:border-red-500";
|
||||
@@ -35,8 +35,10 @@ type InputActionType = {
|
||||
};
|
||||
|
||||
export interface InputProps
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"prefix" | "suffix"
|
||||
>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
@@ -127,7 +129,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const actions = potentialActions.filter((action) => action.condition);
|
||||
|
||||
const inputType = showPasswordToggle
|
||||
? (isVisible ? "text" : "password")
|
||||
? isVisible
|
||||
? "text"
|
||||
: "password"
|
||||
: type;
|
||||
|
||||
const hasPrefix = !!prefix;
|
||||
@@ -197,7 +201,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
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 &&
|
||||
action.id === "copy-value" &&
|
||||
isCopied &&
|
||||
"text-green-600 dark:text-green-500",
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from "../../core/utils/cn.ts";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../../core/utils/cn.ts";
|
||||
|
||||
interface MultiSelectProps {
|
||||
children: React.ReactNode;
|
||||
@@ -8,8 +8,9 @@ interface MultiSelectProps {
|
||||
}
|
||||
|
||||
const MultiSelect = ({ children, className = "" }: MultiSelectProps) => {
|
||||
return <div className={cn("flex flex-wrap gap-2", className)}>{children}
|
||||
</div>;
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-2", className)}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MultiSelectItemProps {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
export interface SidebarButtonProps {
|
||||
label: string;
|
||||
@@ -44,10 +44,7 @@ export const SidebarButton = ({
|
||||
disabled={disabled}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={isButtonCollapsed ? 20 : 18}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<Icon size={isButtonCollapsed ? 20 : 18} className="flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type React from "react";
|
||||
|
||||
interface SidebarSectionProps {
|
||||
label: string;
|
||||
@@ -16,13 +16,7 @@ export const SidebarSection = ({
|
||||
}: SidebarSectionProps) => {
|
||||
const { isCollapsed } = useSidebar();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"py-2",
|
||||
isCollapsed ? "px-0" : "px-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("py-2", isCollapsed ? "px-0" : "px-4", className)}>
|
||||
<Heading
|
||||
as="h3"
|
||||
className={cn(
|
||||
@@ -38,9 +32,7 @@ export const SidebarSection = ({
|
||||
{label}
|
||||
</Heading>
|
||||
|
||||
<div className="space-y-1">
|
||||
{children}
|
||||
</div>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useId, useState } from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { useId, useState } from "react";
|
||||
|
||||
export interface SliderProps {
|
||||
value: number[];
|
||||
@@ -9,6 +9,7 @@ export interface SliderProps {
|
||||
max: number;
|
||||
onValueChange?: (value: number[]) => void;
|
||||
onValueCommit?: (value: number[]) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
trackClassName?: string;
|
||||
@@ -21,6 +22,7 @@ export function Slider({
|
||||
step = 1,
|
||||
min = 0,
|
||||
max,
|
||||
id,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
disabled = false,
|
||||
@@ -28,15 +30,19 @@ export function Slider({
|
||||
trackClassName,
|
||||
rangeClassName,
|
||||
thumbClassName,
|
||||
|
||||
...props
|
||||
}: SliderProps) {
|
||||
const [internalValue, setInternalValue] = useState<number[]>(value);
|
||||
const isControlled = value !== undefined;
|
||||
const currentValue = isControlled ? value! : internalValue;
|
||||
const id = useId();
|
||||
const currentValue = isControlled ? value : internalValue;
|
||||
const generatedId = useId();
|
||||
const internalId = id ? id : generatedId;
|
||||
|
||||
const handleValueChange = (newValue: number[]) => {
|
||||
if (!isControlled) setInternalValue(newValue);
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onValueChange?.(newValue);
|
||||
};
|
||||
|
||||
@@ -50,6 +56,7 @@ export function Slider({
|
||||
"relative flex items-center select-none touch-none",
|
||||
className,
|
||||
)}
|
||||
id={internalId}
|
||||
value={currentValue}
|
||||
step={step}
|
||||
min={min}
|
||||
@@ -72,14 +79,14 @@ export function Slider({
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{currentValue.map((_, i) => (
|
||||
{currentValue.map((_) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={`${id}-thumb-${i}`}
|
||||
key={`${internalId}-thumb`}
|
||||
className={cn(
|
||||
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
|
||||
thumbClassName,
|
||||
)}
|
||||
aria-label={`Thumb ${i + 1}`}
|
||||
aria-label={`Thumb ${internalId}`}
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -13,7 +13,6 @@ const sizeClasses = {
|
||||
export function Spinner({ className, size = "md", ...props }: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
aria-label="Loading..."
|
||||
className={cn(
|
||||
"flex items-center justify-center fade-in-50 fade-out-50",
|
||||
className,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
|
||||
@@ -41,8 +41,8 @@ const toastVariants = cva(
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
& React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root>
|
||||
& VariantProps<typeof toastVariants>
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user