Merge branch 'master' into issue-249-cors
This commit is contained in:
@@ -6,6 +6,8 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { JSX } from "react";
|
||||
import { NodeDetailsDialog } from "./NodeDetailsDialog";
|
||||
|
||||
export const DialogManager = (): JSX.Element => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
@@ -56,6 +58,12 @@ export const DialogManager = (): JSX.Element => {
|
||||
setDialogOpen("pkiBackup", open);
|
||||
}}
|
||||
/>
|
||||
<NodeDetailsDialog
|
||||
open={dialog.nodeDetails}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("nodeDetails", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useDevice } from "@app/core/stores/deviceStore";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog";
|
||||
import type { Protobuf, Types } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface LocationResponseDialogProps {
|
||||
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const LocationResponseDialog = ({
|
||||
location,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LocationResponseDialogProps): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
const from = nodes.get(location?.from ?? 0);
|
||||
const longName =
|
||||
from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName =
|
||||
from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{location?.data.latitudeI / 1e7},{" "}
|
||||
{location?.data.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
<p>Altitude: {location?.data.altitude}m</p>
|
||||
</span>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { useDevice } from "@app/core/stores/deviceStore";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/UI/Accordion";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { DeviceImage } from "../generic/DeviceImage";
|
||||
import { TimeAgo } from "../generic/TimeAgo";
|
||||
import { Uptime } from "../generic/Uptime";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { nodeNumDetails } = useAppStore();
|
||||
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
|
||||
|
||||
return device ? (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
||||
{device.user?.shortName ?? "UNK"})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<DeviceImage
|
||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-gray-200 dark:border-gray-800"
|
||||
deviceType={
|
||||
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
|
||||
}
|
||||
/>
|
||||
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
Details:
|
||||
</p>
|
||||
<p>
|
||||
Hardware:{" "}
|
||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
</p>
|
||||
<p>Node Number: {device.num}</p>
|
||||
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
|
||||
<p>
|
||||
Role:{" "}
|
||||
{
|
||||
Protobuf.Config.Config_DeviceConfig_Role[
|
||||
device.user?.role ?? 0
|
||||
]
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
Last Heard:{" "}
|
||||
{device.lastHeard === 0 ? (
|
||||
"Never"
|
||||
) : (
|
||||
<TimeAgo timestamp={device.lastHeard * 1000} />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device.position ? (
|
||||
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
Position:
|
||||
</p>
|
||||
{device.position.latitudeI && device.position.longitudeI ? (
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7}&mlon=${device.position.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{device.position.latitudeI / 1e7},{" "}
|
||||
{device.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
{device.position.altitude ? (
|
||||
<p>Altitude: {device.position.altitude}m</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{device.deviceMetrics ? (
|
||||
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
Device Metrics:
|
||||
</p>
|
||||
{device.deviceMetrics.airUtilTx ? (
|
||||
<p>
|
||||
Air TX utilization:{" "}
|
||||
{device.deviceMetrics.airUtilTx.toFixed(2)}%
|
||||
</p>
|
||||
) : null}
|
||||
{device.deviceMetrics.channelUtilization ? (
|
||||
<p>
|
||||
Channel utilization:{" "}
|
||||
{device.deviceMetrics.channelUtilization.toFixed(2)}%
|
||||
</p>
|
||||
) : null}
|
||||
{device.deviceMetrics.batteryLevel ? (
|
||||
<p>
|
||||
Battery level:{" "}
|
||||
{device.deviceMetrics.batteryLevel.toFixed(2)}%
|
||||
</p>
|
||||
) : null}
|
||||
{device.deviceMetrics.voltage ? (
|
||||
<p>Voltage: {device.deviceMetrics.voltage.toFixed(2)}V</p>
|
||||
) : null}
|
||||
{device.deviceMetrics.uptimeSeconds ? (
|
||||
<p>
|
||||
Uptime:{" "}
|
||||
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{device ? (
|
||||
<div className="mt-5 w-full max-w-[464px] bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<Accordion className="AccordionRoot" type="single" collapsible>
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
All Raw Metrics:
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(device, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null;
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { toast } from "@app/core/hooks/useToast";
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { useDevice } from "@app/core/stores/deviceStore";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { Button } from "../UI/Button";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const NodeOptionsDialog = ({
|
||||
node,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeOptionsDialogProps): JSX.Element => {
|
||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
||||
const {
|
||||
setNodeNumToBeRemoved,
|
||||
setNodeNumDetails,
|
||||
setChatType,
|
||||
setActiveChat,
|
||||
} = useAppStore();
|
||||
const longName =
|
||||
node?.user?.longName ??
|
||||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
||||
const shortName =
|
||||
node?.user?.shortName ??
|
||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) return;
|
||||
setChatType("direct");
|
||||
setActiveChat(node.num);
|
||||
setActivePage("messages");
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Requesting position, please wait...",
|
||||
});
|
||||
connection?.requestPosition(node.num).then(() =>
|
||||
toast({
|
||||
title: "Position request sent.",
|
||||
}),
|
||||
);
|
||||
onOpenChange();
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
connection?.traceRoute(node.num).then(() =>
|
||||
toast({
|
||||
title: "Traceroute sent.",
|
||||
}),
|
||||
);
|
||||
onOpenChange();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div>
|
||||
<Button onClick={handleDirectMessage}>Direct Message</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleRequestPosition}>Request Position</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleTraceroute}>Trace Route</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNodeNumDetails(node.num);
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}}
|
||||
>
|
||||
More Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useDevice } from "@app/core/stores/deviceStore";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog";
|
||||
import type { Protobuf, Types } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import type { JSX } from "react";
|
||||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute";
|
||||
|
||||
export interface TracerouteResponseDialogProps {
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const TracerouteResponseDialog = ({
|
||||
traceroute,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TracerouteResponseDialogProps): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = traceroute?.data.snrTowards ?? [];
|
||||
const snrBack = traceroute?.data.snrBack ?? [];
|
||||
const from = nodes.get(traceroute?.from ?? 0);
|
||||
const longName =
|
||||
from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName =
|
||||
from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
const to = nodes.get(traceroute?.to ?? 0);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<TraceRoute
|
||||
route={route}
|
||||
routeBack={routeBack}
|
||||
from={from}
|
||||
to={to}
|
||||
snrTowards={snrTowards}
|
||||
snrBack={snrBack}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
type MultiSelectFieldProps,
|
||||
MultiSelectInput,
|
||||
} from "@app/components/Form/FormMultiSelect";
|
||||
import {
|
||||
GenericInput,
|
||||
type InputFieldProps,
|
||||
@@ -19,6 +23,7 @@ import type { Control, FieldValues } from "react-hook-form";
|
||||
export type FieldProps<T> =
|
||||
| InputFieldProps<T>
|
||||
| SelectFieldProps<T>
|
||||
| MultiSelectFieldProps<T>
|
||||
| ToggleFieldProps<T>
|
||||
| PasswordGeneratorProps<T>;
|
||||
|
||||
@@ -58,6 +63,8 @@ export function DynamicFormField<T extends FieldValues>({
|
||||
/>
|
||||
);
|
||||
case "multiSelect":
|
||||
return <div>tmp</div>;
|
||||
return (
|
||||
<MultiSelectInput field={field} control={control} disabled={disabled} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect";
|
||||
|
||||
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "multiSelect";
|
||||
placeholder?: string;
|
||||
onValueChange: (name: string) => void;
|
||||
isChecked: (name: string) => boolean;
|
||||
value: string[];
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
};
|
||||
formatEnumName?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function MultiSelectInput<T extends FieldValues>({
|
||||
field,
|
||||
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||
field.properties;
|
||||
|
||||
// Make sure to filter out the UNSET value, as it shouldn't be shown in the UI
|
||||
const optionsEnumValues = enumValue
|
||||
? Object.entries(enumValue)
|
||||
.filter((value) => typeof value[1] === "number")
|
||||
.filter((value) => value[0] !== "UNSET")
|
||||
: [];
|
||||
|
||||
const formatName = (name: string) => {
|
||||
if (!formatEnumName) return name;
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect {...remainingProperties}>
|
||||
{optionsEnumValues.map(([name, value]) => (
|
||||
<MultiSelectItem
|
||||
key={name}
|
||||
name={name}
|
||||
value={value.toString()}
|
||||
checked={field.isChecked(name)}
|
||||
onCheckedChange={() => field.onValueChange(name)}
|
||||
>
|
||||
{formatEnumName ? formatName(name) : name}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelect>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select" | "multiSelect";
|
||||
type: "select";
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{optionsEnumValues.map(([name, value]) => (
|
||||
<SelectItem key={name + value} value={value.toString()}>
|
||||
<SelectItem key={name} value={value.toString()}>
|
||||
{formatEnumName
|
||||
? name
|
||||
.replace(/_/g, " ")
|
||||
|
||||
@@ -46,7 +46,7 @@ export const Device = (): JSX.Element => {
|
||||
"Lost and Found":
|
||||
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
|
||||
"TAK Tracker":
|
||||
Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
|
||||
Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
|
||||
},
|
||||
formatEnumName: true,
|
||||
},
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
import {
|
||||
type FlagName,
|
||||
usePositionFlags,
|
||||
} from "@app/core/hooks/usePositionFlags";
|
||||
import type { PositionValidation } from "@app/validation/config/position.tsx";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const Position = (): JSX.Element => {
|
||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
||||
export const Position = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
config.position.positionFlags ?? 0,
|
||||
);
|
||||
|
||||
const onSubmit = (data: PositionValidation) => {
|
||||
setWorkingConfig(
|
||||
return setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
payloadVariant: {
|
||||
case: "position",
|
||||
value: data,
|
||||
value: { ...data, positionFlags: flagsValue },
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onPositonFlagChange = useCallback(
|
||||
(name: string) => {
|
||||
return toggleFlag(name as FlagName);
|
||||
},
|
||||
[toggleFlag],
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicForm<PositionValidation>
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={(data) => {
|
||||
data.positionFlags = flagsValue;
|
||||
return onSubmit(data);
|
||||
}}
|
||||
defaultValues={config.position}
|
||||
fieldGroups={[
|
||||
{
|
||||
@@ -53,10 +71,16 @@ export const Position = (): JSX.Element => {
|
||||
{
|
||||
type: "multiSelect",
|
||||
name: "positionFlags",
|
||||
value: activeFlags,
|
||||
isChecked: (name: string) =>
|
||||
activeFlags.includes(name as FlagName),
|
||||
onValueChange: onPositonFlagChange,
|
||||
label: "Position Flags",
|
||||
description: "Configuration options for Position messages",
|
||||
placeholder: "Select position flags...",
|
||||
description:
|
||||
"Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.",
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags,
|
||||
enumValue: getAllFlags(),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,8 +23,8 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
window.location.hostname,
|
||||
)
|
||||
? "meshtastic.local"
|
||||
: window.location.hostname,
|
||||
tls: false,
|
||||
: window.location.host,
|
||||
tls: location.protocol === "https:",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Separator } from "@app/components/UI/Seperator";
|
||||
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { formatQuantity } from "@app/core/utils/string";
|
||||
import { Avatar } from "@components/UI/Avatar";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
@@ -25,12 +26,12 @@ export interface NodeDetailProps {
|
||||
|
||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
||||
const hardwareType = Protobuf.Mesh.HardwareModel[
|
||||
node.user?.hwModel ?? 0
|
||||
].replaceAll("_", " ");
|
||||
const hwModel = node.user?.hwModel ?? 0;
|
||||
const hardwareType =
|
||||
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
|
||||
|
||||
return (
|
||||
<div className="dark:text-black">
|
||||
<div className="dark:text-black p-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={node.user?.shortName} />
|
||||
@@ -132,7 +133,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
className="ml-2 mr-1"
|
||||
aria-label="Elevation"
|
||||
/>
|
||||
<div>{node.position?.altitude} ft</div>
|
||||
<div>
|
||||
{formatQuantity(node.position?.altitude, {
|
||||
one: "meter",
|
||||
other: "meters",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,75 +1,88 @@
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import {
|
||||
type MessageWithState,
|
||||
useDevice,
|
||||
} from "@app/core/stores/deviceStore.ts";
|
||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||
import type { Protobuf, Types } from "@meshtastic/js";
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: MessageWithState[];
|
||||
channel: Types.ChannelNumber;
|
||||
to: Types.Destination;
|
||||
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
|
||||
}
|
||||
|
||||
const EmptyState = () => (
|
||||
<div className="flex flex-col place-content-center place-items-center p-8 text-white">
|
||||
<InboxIcon className="h-8 w-8 mb-2" />
|
||||
<span className="text-sm">No Messages</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ChannelChat = ({
|
||||
messages,
|
||||
channel,
|
||||
to,
|
||||
traceroutes,
|
||||
}: ChannelChatProps): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
const isNearBottom =
|
||||
scrollContainer.scrollHeight -
|
||||
scrollContainer.scrollTop -
|
||||
scrollContainer.clientHeight <
|
||||
100;
|
||||
if (isNearBottom) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (!messages?.length) {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full container mx-auto">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState />
|
||||
</div>
|
||||
<div className="flex-shrink-0 p-4 w-full dark:bg-gray-900">
|
||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="flex flex-grow">
|
||||
<div className="flex flex-grow flex-col">
|
||||
{messages ? (
|
||||
messages.map((message, index) => (
|
||||
<div className="flex flex-col h-full w-full container mx-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
|
||||
{messages.map((message, index) => {
|
||||
return (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
lastMsgSameUser={
|
||||
index === 0
|
||||
? false
|
||||
: messages[index - 1].from === message.from
|
||||
}
|
||||
sender={nodes.get(message.from)}
|
||||
lastMsgSameUser={
|
||||
index > 0 && messages[index - 1].from === message.from
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="m-auto">
|
||||
<InboxIcon className="m-auto" />
|
||||
<Subtle>No Messages</Subtle>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-grow flex-col border-slate-400 border-l ${traceroutes === undefined ? "hidden" : ""}`}
|
||||
>
|
||||
{to === "broadcast" ? null : traceroutes ? (
|
||||
traceroutes.map((traceroute, index) => (
|
||||
<TraceRoute
|
||||
key={traceroute.id}
|
||||
from={nodes.get(traceroute.from)}
|
||||
to={nodes.get(traceroute.to)}
|
||||
route={traceroute.data.route}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="m-auto">
|
||||
<InboxIcon className="m-auto" />
|
||||
<Subtle>No Traceroutes</Subtle>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-3 pr-3 pt-3 pb-1">
|
||||
<MessageInput to={to} channel={channel} />
|
||||
<div className="flex-shrink-0 mt-2 p-4 w-full dark:bg-gray-900">
|
||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,73 +1,168 @@
|
||||
import type { MessageWithState } from "@app/core/stores/deviceStore.ts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@app/components/UI/Tooltip";
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import {
|
||||
type MessageWithState,
|
||||
useDeviceStore,
|
||||
} from "@app/core/stores/deviceStore.ts";
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
import { Avatar } from "@components/UI/Avatar";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleEllipsisIcon,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export interface MessageProps {
|
||||
const MESSAGE_STATES = {
|
||||
ACK: "ack",
|
||||
WAITING: "waiting",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
type MessageState = MessageWithState["state"];
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
message: MessageWithState;
|
||||
sender?: Protobuf.Mesh.NodeInfo;
|
||||
sender: Protobuf.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
return lastMsgSameUser ? (
|
||||
<div className="ml-5 flex">
|
||||
{message.state === "ack" ? (
|
||||
<CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
|
||||
) : message.state === "waiting" ? (
|
||||
<CircleEllipsisIcon size={16} className="my-auto text-textSecondary" />
|
||||
) : (
|
||||
<AlertCircleIcon size={16} className="my-auto text-textSecondary" />
|
||||
)}
|
||||
<span
|
||||
className={`ml-4 border-l-2 border-l-backgroundPrimary pl-2 ${
|
||||
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
|
||||
}`}
|
||||
interface StatusTooltipProps {
|
||||
state: MessageState;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
state: MessageState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_TEXT_MAP: Record<MessageState, string> = {
|
||||
[MESSAGE_STATES.ACK]: "Message delivered",
|
||||
[MESSAGE_STATES.WAITING]: "Waiting for delivery",
|
||||
[MESSAGE_STATES.FAILED]: "Delivery failed",
|
||||
} as const;
|
||||
|
||||
const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
|
||||
[MESSAGE_STATES.ACK]: CheckCircle2,
|
||||
[MESSAGE_STATES.WAITING]: CircleEllipsis,
|
||||
[MESSAGE_STATES.FAILED]: AlertCircle,
|
||||
} as const;
|
||||
|
||||
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state];
|
||||
|
||||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{message.data}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mt-2 gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 cursor-pointer">
|
||||
<Avatar text={sender?.user?.shortName ?? "UNK"} />
|
||||
</div>
|
||||
<span className="cursor-pointer font-medium text-textPrimary">
|
||||
{sender?.user?.longName ?? "UNK"}
|
||||
</span>
|
||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||
{message.rxTime.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||
{message.rxTime.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-1 flex">
|
||||
{message.state === "ack" ? (
|
||||
<CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
|
||||
) : message.state === "waiting" ? (
|
||||
<CircleEllipsisIcon
|
||||
size={16}
|
||||
className="my-auto text-textSecondary"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircleIcon size={16} className="my-auto text-textSecondary" />
|
||||
{getStatusText(state)}
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const iconClass = cn(
|
||||
className,
|
||||
"text-gray-500 dark:text-gray-400 w-4 h-4 flex-shrink-0",
|
||||
);
|
||||
|
||||
const Icon = STATUS_ICON_MAP[state];
|
||||
return (
|
||||
<StatusTooltip state={state}>
|
||||
<Icon
|
||||
className={iconClass}
|
||||
{...otherProps}
|
||||
color={isFailed ? "red" : "currentColor"}
|
||||
/>
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
const isAcknowledged = state === MESSAGE_STATES.ACK;
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const isWaiting = state === MESSAGE_STATES.WAITING;
|
||||
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged
|
||||
? "text-black dark:text-white"
|
||||
: "text-black dark:text-gray-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = ({
|
||||
date,
|
||||
className,
|
||||
}: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 flex-shrink-0", className)}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
() =>
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from],
|
||||
);
|
||||
const messageUser = sender?.user;
|
||||
|
||||
const messageTextClass = getMessageTextStyles(message.state);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-wrap items-start py-1",
|
||||
isDeviceUser && "items-end",
|
||||
)}
|
||||
<span
|
||||
className={`ml-4 border-l-2 border-l-backgroundPrimary pl-2 ${
|
||||
message.state === "ack" ? "text-textPrimary" : "text-textSecondary"
|
||||
}`}
|
||||
>
|
||||
{message.data}
|
||||
</span>
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser ? (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<TimeDisplay date={message.rxTime} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
|
||||
{message.data}
|
||||
</div>
|
||||
<StatusIcon state={message.state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,16 +4,24 @@ import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
type JSX,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface MessageInputProps {
|
||||
to: Types.Destination;
|
||||
channel: Types.ChannelNumber;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export const MessageInput = ({
|
||||
to,
|
||||
channel,
|
||||
maxBytes,
|
||||
}: MessageInputProps): JSX.Element => {
|
||||
const {
|
||||
connection,
|
||||
@@ -24,6 +32,7 @@ export const MessageInput = ({
|
||||
} = useDevice();
|
||||
const myNodeNum = hardware.myNodeNum;
|
||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||
const [messageBytes, setMessageBytes] = useState(0);
|
||||
|
||||
const debouncedSetMessageDraft = useMemo(
|
||||
() => debounce(setMessageDraft, 300),
|
||||
@@ -60,19 +69,29 @@ export const MessageInput = ({
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalDraft(newValue);
|
||||
debouncedSetMessageDraft(newValue);
|
||||
const byteLength = new Blob([newValue]).size;
|
||||
|
||||
if (byteLength <= maxBytes) {
|
||||
setLocalDraft(newValue);
|
||||
debouncedSetMessageDraft(newValue);
|
||||
setMessageBytes(byteLength);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendText(localDraft);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
action={async (formData: FormData) => {
|
||||
// prevent user from sending blank/empty message
|
||||
if (localDraft === "") return;
|
||||
const message = formData.get("messageInput") as string;
|
||||
startTransition(() => {
|
||||
sendText(message);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
setMessageBytes(0);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-grow gap-2">
|
||||
@@ -80,11 +99,16 @@ export const MessageInput = ({
|
||||
<Input
|
||||
autoFocus={true}
|
||||
minLength={1}
|
||||
name="messageInput"
|
||||
placeholder="Enter Message"
|
||||
value={localDraft}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex items-center w-24 p-2 place-content-end">
|
||||
{messageBytes}/{maxBytes}
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -1,36 +1,60 @@
|
||||
import { useDevice } from "@app/core/stores/deviceStore.ts";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface TraceRouteProps {
|
||||
from?: Protobuf.Mesh.NodeInfo;
|
||||
to?: Protobuf.Mesh.NodeInfo;
|
||||
route: Array<number>;
|
||||
routeBack?: Array<number>;
|
||||
snrTowards?: Array<number>;
|
||||
snrBack?: Array<number>;
|
||||
}
|
||||
|
||||
export const TraceRoute = ({
|
||||
from,
|
||||
to,
|
||||
route,
|
||||
routeBack,
|
||||
snrTowards,
|
||||
snrBack,
|
||||
}: TraceRouteProps): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
return route.length === 0 ? (
|
||||
return (
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
{to?.user?.longName}↔{from?.user?.longName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
{to?.user?.longName}↔
|
||||
{route.map((hop) => {
|
||||
const node = nodes.get(hop);
|
||||
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}↔`;
|
||||
})}
|
||||
<p className="font-semibold">Route to destination:</p>
|
||||
<p>{to?.user?.longName}</p>
|
||||
<p>↓ {snrTowards?.[0] ? snrTowards[0] : "??"}dB</p>
|
||||
{route.map((hop, i) => (
|
||||
<span key={nodes.get(hop)?.num}>
|
||||
<p>
|
||||
{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
|
||||
</p>
|
||||
<p>↓ {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB</p>
|
||||
</span>
|
||||
))}
|
||||
{from?.user?.longName}
|
||||
</span>
|
||||
{routeBack ? (
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
<p className="font-semibold">Route back:</p>
|
||||
<p>{from?.user?.longName}</p>
|
||||
<p>↓ {snrBack?.[0] ? snrBack[0] : "??"}dB</p>
|
||||
{routeBack.map((hop, i) => (
|
||||
<span key={nodes.get(hop)?.num}>
|
||||
<p>
|
||||
{nodes.get(hop)?.user?.longName ??
|
||||
`!${numberToHexUnpadded(hop)}`}
|
||||
</p>
|
||||
<p>↓ {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB</p>
|
||||
</span>
|
||||
))}
|
||||
{to?.user?.longName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ export const PageLayout = ({
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={action.icon.name}
|
||||
key={action.icon.displayName}
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { type ComponentRef, forwardRef } from "react";
|
||||
|
||||
export const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
export const AccordionHeader = AccordionPrimitive.Header;
|
||||
|
||||
export const AccordionItem = AccordionPrimitive.Item;
|
||||
|
||||
export const AccordionTrigger = forwardRef<
|
||||
ComponentRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex justify-between items-center w-full p-4 border-b border-gray-200 dark:border-gray-800 group",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5 transition-transform duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
));
|
||||
|
||||
export const AccordionContent = forwardRef<
|
||||
ComponentRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-4 border-b border-gray-200 dark:border-gray-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -15,7 +15,7 @@ const buttonVariants = cva(
|
||||
success:
|
||||
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
|
||||
outline:
|
||||
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
|
||||
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-100",
|
||||
subtle:
|
||||
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
|
||||
ghost:
|
||||
|
||||
@@ -44,7 +44,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid w-full max-w-[512px] max-h-[100vh] overflow-y-scroll scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
|
||||
"dark:bg-slate-900",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<footer
|
||||
className={`flex flex- justify-center p-2 ${className}`}
|
||||
className={`flex mt-auto justify-center p-2 ${className}`}
|
||||
style={{
|
||||
backgroundColor: "var(--backgroundPrimary)",
|
||||
color: "var(--textPrimary)",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface MultiSelectProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MultiSelect = ({ children, className = "" }: MultiSelectProps) => {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-2", className)}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MultiSelectItemProps {
|
||||
name: string;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (name: string, value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MultiSelectItem = ({
|
||||
name,
|
||||
value,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
children,
|
||||
className = "",
|
||||
}: MultiSelectItemProps) => {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
name={name}
|
||||
id={value}
|
||||
checked={checked}
|
||||
onCheckedChange={(val) => onCheckedChange(name, !!val)}
|
||||
className={cn(
|
||||
`
|
||||
inline-flex items-center rounded-md px-3 py-2 text-sm transition-colors
|
||||
border border-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-slate-800
|
||||
focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2
|
||||
data-[state=checked]:bg-slate-100 dark:data-[state=checked]:bg-slate-700`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="mr-2">
|
||||
<Check className="h-4 w-4 animate-in zoom-in duration-200" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
{children}
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export { MultiSelect, MultiSelectItem };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface SidebarButtonProps {
|
||||
label: string;
|
||||
@@ -20,10 +21,10 @@ export const SidebarButton = ({
|
||||
onClick={onClick}
|
||||
variant={active ? "subtle" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
className="flex gap-2 w-full"
|
||||
>
|
||||
{Icon && <Icon size={16} />}
|
||||
{element && element}
|
||||
{label}
|
||||
<span className="flex flex-1 justify-start flex-shrink-0">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ const toastVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
|
||||
"border bg-backgroundPrimary text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
|
||||
destructive:
|
||||
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ const Tooltip = ({ ...props }) => <TooltipPrimitive.Root {...props} />;
|
||||
Tooltip.displayName = TooltipPrimitive.Tooltip.displayName;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
const TooltipArrow = TooltipPrimitive.Arrow;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
@@ -26,4 +27,10 @@ const TooltipContent = React.forwardRef<
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipArrow,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface DeviceImageProps {
|
||||
deviceType: string;
|
||||
className?: React.HTMLAttributes<HTMLImageElement>["className"];
|
||||
}
|
||||
|
||||
const hardwareModelToFilename: { [key: string]: string } = {
|
||||
DIY_V1: "diy.svg",
|
||||
NANO_G2_ULTRA: "nano-g2-ultra.svg",
|
||||
TBEAM: "tbeam.svg",
|
||||
HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg",
|
||||
RPI_PICO: "pico.svg",
|
||||
T_DECK: "t-deck.svg",
|
||||
HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg",
|
||||
HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg",
|
||||
HELTEC_V3: "heltec-v3.svg",
|
||||
HELTEC_V3_CASE: "heltec-v3-case.svg",
|
||||
HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg",
|
||||
HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg",
|
||||
HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg",
|
||||
HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg",
|
||||
HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg",
|
||||
HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg",
|
||||
HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg",
|
||||
HELTEC_WSL_V3: "heltec-wsl-v3.svg",
|
||||
TLORA_C6: "tlora-c6.svg",
|
||||
TLORA_T3_S3: "tlora-t3s3-v1.svg",
|
||||
TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg",
|
||||
TLORA_V2: "tlora-v2-1-1_6.svg",
|
||||
TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg",
|
||||
TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg",
|
||||
RAK11310: "rak11310.svg",
|
||||
RAK2560: "rak2560.svg",
|
||||
RAK4631: "rak4631.svg",
|
||||
RAK4631_CASE: "rak4631_case.svg",
|
||||
WIO_WM1110: "wio-tracker-wm1110.svg",
|
||||
WM1110_DEV_KIT: "wm1110_dev_kit.svg",
|
||||
STATION_G2: "station-g2.svg",
|
||||
TBEAM_V0P7: "tbeam-s3-core.svg",
|
||||
T_ECHO: "t-echo.svg",
|
||||
TRACKER_T1000_E: "tracker-t1000-e.svg",
|
||||
T_WATCH_S3: "t-watch-s3.svg",
|
||||
SEEED_XIAO_S3: "seeed-xiao-s3.svg",
|
||||
SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg",
|
||||
PROMICRO: "promicro.svg",
|
||||
RPIPICOW: "rpipicow.svg",
|
||||
UNKNOWN: "unknown.svg",
|
||||
};
|
||||
|
||||
export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => {
|
||||
const getPath = (device: string) => `/devices/${device}`;
|
||||
const device = hardwareModelToFilename[deviceType] || "unknown.svg";
|
||||
return <img className={className} src={getPath(device)} alt={device} />;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import TimeAgoReact from "timeago-react";
|
||||
|
||||
export interface TimeAgoProps {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => {
|
||||
return <TimeAgoReact datetime={timestamp} opts={{ minInterval: 10 }} />;
|
||||
};
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface TimeAgoProps {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const getTimeAgo = (
|
||||
unixTimestamp: number,
|
||||
locale: Intl.LocalesArgument = "en",
|
||||
): string => {
|
||||
const timestamp = new Date(unixTimestamp);
|
||||
const diff = (new Date().getTime() - timestamp.getTime()) / 1000;
|
||||
|
||||
const minutes = Math.floor(diff / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(months / 12);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
if (years > 0) {
|
||||
return rtf.format(0 - years, "year");
|
||||
}
|
||||
if (months > 0) {
|
||||
return rtf.format(0 - months, "month");
|
||||
}
|
||||
if (days > 0) {
|
||||
return rtf.format(0 - days, "day");
|
||||
}
|
||||
if (hours > 0) {
|
||||
return rtf.format(0 - hours, "hour");
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return rtf.format(0 - minutes, "minute");
|
||||
}
|
||||
return rtf.format(Math.floor(0 - diff), "second");
|
||||
};
|
||||
|
||||
export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span>{getTimeAgo(timestamp)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{new Date(timestamp).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface UptimeProps {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
const getUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
|
||||
const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60);
|
||||
return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`;
|
||||
};
|
||||
|
||||
export const Uptime = ({ seconds }: UptimeProps): JSX.Element => {
|
||||
return <span>{getUptime(seconds)}</span>;
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const FLAGS = {
|
||||
UNSET: 0,
|
||||
Altitude: 1,
|
||||
"Altitude is Mean Sea Level": 2,
|
||||
"Altitude Geoidal Seperation": 4,
|
||||
"Dilution of precision (DOP) PDOP used by default": 8,
|
||||
"If DOP is set, use HDOP / VDOP values instead of PDOP": 16,
|
||||
"Number of satellites": 32,
|
||||
"Sequence number": 64,
|
||||
Timestamp: 128,
|
||||
"Vehicle heading": 256,
|
||||
"Vehicle speed": 512,
|
||||
} as const;
|
||||
|
||||
export type FlagName = keyof typeof FLAGS;
|
||||
type FlagsObject = typeof FLAGS;
|
||||
|
||||
type UsePositionFlagsProps = {
|
||||
decode: (value: number) => FlagName[];
|
||||
encode: (flagNames: FlagName[]) => number;
|
||||
hasFlag: (value: number, flagName: FlagName) => boolean;
|
||||
getAllFlags: () => FlagsObject;
|
||||
isValidValue: (value: number) => boolean;
|
||||
flagsValue: number;
|
||||
activeFlags: FlagName[];
|
||||
toggleFlag: (flagName: FlagName) => void;
|
||||
setFlag: (flagName: FlagName, enabled: boolean) => void;
|
||||
setFlags: (value: number) => void;
|
||||
clearFlags: () => void;
|
||||
};
|
||||
|
||||
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
|
||||
const [flagsValue, setFlagsValue] = useState<number>(initialValue);
|
||||
|
||||
const utils = useMemo(() => {
|
||||
const decode = (value: number): FlagName[] => {
|
||||
if (value === 0) return ["UNSET"];
|
||||
|
||||
const activeFlags: FlagName[] = [];
|
||||
for (const [name, flagValue] of Object.entries(FLAGS)) {
|
||||
if (flagValue !== 0 && (value & flagValue) === flagValue) {
|
||||
activeFlags.push(name as FlagName);
|
||||
}
|
||||
}
|
||||
return activeFlags;
|
||||
};
|
||||
|
||||
const encode = (flagNames: FlagName[]): number => {
|
||||
if (flagNames.includes("UNSET")) {
|
||||
return 0;
|
||||
}
|
||||
return flagNames.reduce((acc, name) => {
|
||||
const value = FLAGS[name];
|
||||
return acc | value;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const hasFlag = (value: number, flagName: FlagName): boolean => {
|
||||
const flagValue = FLAGS[flagName];
|
||||
return (value & flagValue) === flagValue;
|
||||
};
|
||||
|
||||
const getAllFlags = (): FlagsObject => {
|
||||
return FLAGS;
|
||||
};
|
||||
|
||||
const isValidValue = (value: number): boolean => {
|
||||
const maxValue = Object.values(FLAGS)
|
||||
.filter((val) => val !== 0) // Exclude UNSET (0) from the calculation
|
||||
.reduce((acc, val) => acc | val, 0);
|
||||
return Number.isInteger(value) && value >= 0 && value <= maxValue;
|
||||
};
|
||||
|
||||
return {
|
||||
decode,
|
||||
encode,
|
||||
hasFlag,
|
||||
getAllFlags,
|
||||
isValidValue,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFlag = useCallback((flagName: FlagName) => {
|
||||
const flagValue = FLAGS[flagName];
|
||||
setFlagsValue((prev) => prev ^ flagValue);
|
||||
}, []);
|
||||
|
||||
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
|
||||
const flagValue = FLAGS[flagName];
|
||||
setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue));
|
||||
}, []);
|
||||
|
||||
const setFlags = useCallback(
|
||||
(value: number) => {
|
||||
if (!utils.isValidValue(value)) {
|
||||
throw new Error(`Invalid flags value: ${value}`);
|
||||
}
|
||||
setFlagsValue(value);
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
const clearFlags = useCallback(() => {
|
||||
setFlagsValue(0);
|
||||
}, []);
|
||||
|
||||
const activeFlags = utils.decode(flagsValue);
|
||||
|
||||
return {
|
||||
...utils,
|
||||
flagsValue,
|
||||
activeFlags,
|
||||
toggleFlag,
|
||||
setFlag,
|
||||
setFlags,
|
||||
clearFlags,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Types } from "@meshtastic/js";
|
||||
import { produce } from "immer";
|
||||
import { create } from "zustand";
|
||||
|
||||
@@ -29,6 +30,9 @@ interface AppState {
|
||||
nodeNumToBeRemoved: number;
|
||||
accent: AccentColor;
|
||||
connectDialogOpen: boolean;
|
||||
nodeNumDetails: number;
|
||||
activeChat: number;
|
||||
chatType: "broadcast" | "direct";
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
addRasterSource: (source: RasterSource) => void;
|
||||
@@ -42,6 +46,9 @@ interface AppState {
|
||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||
setAccent: (color: AccentColor) => void;
|
||||
setConnectDialogOpen: (open: boolean) => void;
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: "broadcast" | "direct") => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set) => ({
|
||||
@@ -57,6 +64,9 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
accent: "orange",
|
||||
connectDialogOpen: false,
|
||||
nodeNumToBeRemoved: 0,
|
||||
nodeNumDetails: 0,
|
||||
activeChat: Types.ChannelNumber.Primary,
|
||||
chatType: "broadcast",
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => {
|
||||
set(
|
||||
@@ -124,4 +134,16 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNodeNumDetails: (nodeNum) =>
|
||||
set((state) => ({
|
||||
nodeNumDetails: nodeNum,
|
||||
})),
|
||||
setActiveChat: (chat) =>
|
||||
set(() => ({
|
||||
activeChat: chat,
|
||||
})),
|
||||
setChatType: (type) =>
|
||||
set(() => ({
|
||||
chatType: type,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -26,7 +26,8 @@ export type DialogVariant =
|
||||
| "reboot"
|
||||
| "deviceName"
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup";
|
||||
| "pkiBackup"
|
||||
| "nodeDetails";
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
@@ -62,6 +63,7 @@ export interface Device {
|
||||
deviceName: boolean;
|
||||
nodeRemoval: boolean;
|
||||
pkiBackup: boolean;
|
||||
nodeDetails: boolean;
|
||||
};
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
@@ -145,6 +147,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
deviceName: false,
|
||||
nodeRemoval: false,
|
||||
pkiBackup: false,
|
||||
nodeDetails: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
interface PluralForms {
|
||||
one: string;
|
||||
other: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface FormatOptions {
|
||||
locale?: string;
|
||||
pluralRules?: Intl.PluralRulesOptions;
|
||||
numberFormat?: Intl.NumberFormatOptions;
|
||||
}
|
||||
|
||||
export function formatQuantity(
|
||||
value: number,
|
||||
forms: PluralForms,
|
||||
options: FormatOptions = {},
|
||||
) {
|
||||
const {
|
||||
locale = "en-US",
|
||||
pluralRules: pluralOptions = { type: "cardinal" },
|
||||
numberFormat: numberOptions = {},
|
||||
} = options;
|
||||
|
||||
const pluralRules = new Intl.PluralRules(locale, pluralOptions);
|
||||
const numberFormat = new Intl.NumberFormat(locale, numberOptions);
|
||||
|
||||
const pluralCategory = pluralRules.select(value);
|
||||
const word = forms[pluralCategory];
|
||||
|
||||
return `${numberFormat.format(value)} ${word}`;
|
||||
}
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { setConnectDialogOpen } = useAppStore();
|
||||
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { darkMode } = useAppStore();
|
||||
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
@@ -38,7 +39,13 @@ export const Dashboard = () => {
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full px-4 py-4 sm:px-6 ${darkMode ? "hover:bg-slate-800 focus:bg-slate-400 active:bg-slate-600" : "hover:bg-slate-50 focus:bg-slate-50 active:bg-slate-100"}`}
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
@@ -75,7 +82,7 @@ export const Dashboard = () => {
|
||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
+103
-126
@@ -1,59 +1,93 @@
|
||||
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
|
||||
import { Avatar } from "@app/components/UI/Avatar";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
import {
|
||||
BoxSelectIcon,
|
||||
MapPinIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import { MapPinIcon } from "lucide-react";
|
||||
import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AttributionControl, Marker, Popup, useMap } from "react-map-gl";
|
||||
import {
|
||||
AttributionControl,
|
||||
GeolocateControl,
|
||||
Marker,
|
||||
NavigationControl,
|
||||
Popup,
|
||||
ScaleControl,
|
||||
useMap,
|
||||
} from "react-map-gl";
|
||||
import MapGl from "react-map-gl/maplibre";
|
||||
|
||||
type NodePosition = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const convertToLatLng = (position: {
|
||||
latitudeI?: number;
|
||||
longitudeI?: number;
|
||||
}): NodePosition => ({
|
||||
latitude: (position.latitudeI ?? 0) / 1e7,
|
||||
longitude: (position.longitudeI ?? 0) / 1e7,
|
||||
});
|
||||
|
||||
const MapPage = (): JSX.Element => {
|
||||
const { nodes, waypoints } = useDevice();
|
||||
const { rasterSources, darkMode } = useAppStore();
|
||||
const { darkMode } = useAppStore();
|
||||
const { default: map } = useMap();
|
||||
|
||||
const [zoom, setZoom] = useState(0);
|
||||
const [selectedNode, setSelectedNode] =
|
||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||
|
||||
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
||||
// Filter out nodes without a valid position
|
||||
const validNodes = useMemo(
|
||||
() =>
|
||||
Array.from(nodes.values()).filter(
|
||||
(node): node is Protobuf.Mesh.NodeInfo =>
|
||||
Boolean(node.position?.latitudeI),
|
||||
),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const getBBox = useCallback(() => {
|
||||
const handleMarkerClick = useCallback(
|
||||
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
||||
event?.originalEvent?.stopPropagation();
|
||||
|
||||
setSelectedNode(node);
|
||||
|
||||
if (map) {
|
||||
const position = convertToLatLng(node.position);
|
||||
map.easeTo({
|
||||
center: [position.longitude, position.latitude],
|
||||
zoom: map?.getZoom(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[map],
|
||||
);
|
||||
|
||||
// Get the bounds of the map based on the nodes furtherest away from center
|
||||
const getMapBounds = useCallback(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const nodesWithPosition = allNodes.filter(
|
||||
(node) => node.position?.latitudeI,
|
||||
);
|
||||
if (!nodesWithPosition.length) {
|
||||
|
||||
if (!validNodes.length) {
|
||||
return;
|
||||
}
|
||||
if (nodesWithPosition.length === 1) {
|
||||
if (validNodes.length === 1) {
|
||||
map.easeTo({
|
||||
zoom: 12,
|
||||
zoom: map.getZoom(),
|
||||
center: [
|
||||
(nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7,
|
||||
(nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7,
|
||||
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
|
||||
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const line = lineString(
|
||||
nodesWithPosition.map((n) => [
|
||||
validNodes.map((n) => [
|
||||
(n.position?.latitudeI ?? 0) / 1e7,
|
||||
(n.position?.longitudeI ?? 0) / 1e7,
|
||||
]),
|
||||
@@ -69,78 +103,54 @@ const MapPage = (): JSX.Element => {
|
||||
if (center) {
|
||||
map.easeTo(center);
|
||||
}
|
||||
}, [allNodes, map]);
|
||||
}, [validNodes, map]);
|
||||
|
||||
useEffect(() => {
|
||||
map?.on("zoom", () => {
|
||||
setZoom(map?.getZoom() ?? 0);
|
||||
});
|
||||
}, [map]);
|
||||
// Generate all markers
|
||||
const markers = useMemo(
|
||||
() =>
|
||||
validNodes.map((node) => {
|
||||
const position = convertToLatLng(node.position);
|
||||
return (
|
||||
<Marker
|
||||
key={`marker-${node.num}`}
|
||||
longitude={position.longitude}
|
||||
latitude={position.latitude}
|
||||
anchor="bottom"
|
||||
onClick={(e) => handleMarkerClick(node, e)}
|
||||
>
|
||||
<Avatar
|
||||
text={node.user?.shortName?.toString() ?? node.num.toString()}
|
||||
className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600"
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
}),
|
||||
[validNodes, handleMarkerClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
map?.on("load", () => {
|
||||
getBBox();
|
||||
getMapBounds();
|
||||
});
|
||||
}, [map, getBBox]);
|
||||
}, [map, getMapBounds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar>
|
||||
<SidebarSection label="Sources">
|
||||
{rasterSources.map((source) => (
|
||||
<SidebarButton key={source.title} label={source.title} />
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
<PageLayout
|
||||
label="Map"
|
||||
noPadding={true}
|
||||
actions={[
|
||||
{
|
||||
icon: ZoomInIcon,
|
||||
onClick() {
|
||||
map?.zoomIn();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ZoomOutIcon,
|
||||
onClick() {
|
||||
map?.zoomOut();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: BoxSelectIcon,
|
||||
onClick() {
|
||||
getBBox();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Sidebar />
|
||||
<PageLayout label="Map" noPadding={true} actions={[]}>
|
||||
<MapGl
|
||||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||
// onClick={(e) => {
|
||||
// const waypoint = new Protobuf.Waypoint({
|
||||
// name: "test",
|
||||
// description: "test description",
|
||||
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
|
||||
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
|
||||
// });
|
||||
// addWaypoint(waypoint);
|
||||
// connection?.sendWaypoint(waypoint, "broadcast");
|
||||
// }}
|
||||
|
||||
// @ts-ignore
|
||||
|
||||
attributionControl={false}
|
||||
renderWorldCopies={false}
|
||||
maxPitch={0}
|
||||
antialias={true}
|
||||
style={{
|
||||
filter: darkMode ? "brightness(0.8)" : "",
|
||||
filter: darkMode ? "brightness(0.9)" : "",
|
||||
}}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={false}
|
||||
initialViewState={{
|
||||
zoom: 1.6,
|
||||
zoom: 1.8,
|
||||
latitude: 35,
|
||||
longitude: 0,
|
||||
}}
|
||||
@@ -151,6 +161,14 @@ const MapPage = (): JSX.Element => {
|
||||
color: darkMode ? "black" : "",
|
||||
}}
|
||||
/>
|
||||
<GeolocateControl
|
||||
position="top-right"
|
||||
positionOptions={{ enableHighAccuracy: true }}
|
||||
trackUserLocation
|
||||
/>
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
|
||||
<ScaleControl />
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
@@ -163,58 +181,17 @@ const MapPage = (): JSX.Element => {
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* {rasterSources.map((source, index) => (
|
||||
<Source key={index} type="raster" {...source}>
|
||||
<Layer type="raster" />
|
||||
</Source>
|
||||
))} */}
|
||||
{allNodes.map((node) => {
|
||||
if (node.position?.latitudeI && node.num !== selectedNode?.num) {
|
||||
return (
|
||||
<Marker
|
||||
key={node.num}
|
||||
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
||||
// style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
anchor="bottom"
|
||||
onClick={() => {
|
||||
setSelectedNode(node);
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
(node.position?.longitudeI ?? 0) / 1e7,
|
||||
(node.position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer gap-2 rounded-md bg-transparent p-1.5">
|
||||
<Avatar
|
||||
text={
|
||||
node.user?.shortName.toString() ?? node.num.toString()
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||
{node.user?.longName ||
|
||||
`!${numberToHexUnpadded(node.num)}`}
|
||||
</Subtle>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{selectedNode?.position && (
|
||||
{markers}
|
||||
{selectedNode ? (
|
||||
<Popup
|
||||
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7}
|
||||
anchor="left"
|
||||
closeOnClick={false}
|
||||
anchor="top"
|
||||
longitude={convertToLatLng(selectedNode.position).longitude}
|
||||
latitude={convertToLatLng(selectedNode.position).latitude}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
)}
|
||||
) : null}
|
||||
</MapGl>
|
||||
</PageLayout>
|
||||
</>
|
||||
|
||||
+19
-26
@@ -1,3 +1,4 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
@@ -5,24 +6,23 @@ import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { Device, useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { getChannelName } from "@pages/Channels.tsx";
|
||||
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const MessagesPage = () => {
|
||||
export const MessagesPage = () => {
|
||||
const { channels, nodes, hardware, messages, traceroutes, connection } =
|
||||
useDevice();
|
||||
const [chatType, setChatType] =
|
||||
useState<Types.PacketDestination>("broadcast");
|
||||
const [activeChat, setActiveChat] = useState<number>(
|
||||
Types.ChannelNumber.Primary,
|
||||
);
|
||||
const filteredNodes = Array.from(nodes.values()).filter(
|
||||
(n) => n.num !== hardware.myNodeNum,
|
||||
);
|
||||
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
||||
if (node.num === hardware.myNodeNum) return false;
|
||||
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
|
||||
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
});
|
||||
const allChannels = Array.from(channels.values());
|
||||
const filteredChannels = allChannels.filter(
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
@@ -56,6 +56,15 @@ const MessagesPage = () => {
|
||||
))}
|
||||
</SidebarSection>
|
||||
<SidebarSection label="Nodes">
|
||||
<div className="p-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredNodes.map((node) => (
|
||||
<SidebarButton
|
||||
@@ -108,21 +117,6 @@ const MessagesPage = () => {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: WaypointsIcon,
|
||||
async onClick() {
|
||||
const targetNode = nodes.get(activeChat)?.num;
|
||||
if (targetNode === undefined) return;
|
||||
toast({
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
await connection?.traceRoute(targetNode).then(() =>
|
||||
toast({
|
||||
title: "Traceroute sent.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
@@ -146,7 +140,6 @@ const MessagesPage = () => {
|
||||
to={activeChat}
|
||||
messages={messages.direct.get(node.num)}
|
||||
channel={Types.ChannelNumber.Primary}
|
||||
traceroutes={traceroutes.get(node.num)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
+101
-28
@@ -1,15 +1,18 @@
|
||||
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog";
|
||||
import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog";
|
||||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog";
|
||||
import Footer from "@app/components/UI/Footer";
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { Protobuf, type Types } from "@meshtastic/js";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||
import { Fragment, type JSX } from "react";
|
||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { Fragment, type JSX, useCallback, useEffect, useState } from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
export interface DeleteNoteDialogProps {
|
||||
@@ -18,37 +21,103 @@ export interface DeleteNoteDialogProps {
|
||||
}
|
||||
|
||||
const NodesPage = (): JSX.Element => {
|
||||
const { nodes, hardware, setDialogOpen } = useDevice();
|
||||
const { setNodeNumToBeRemoved } = useAppStore();
|
||||
const { nodes, hardware, connection } = useDevice();
|
||||
const [selectedNode, setSelectedNode] = useState<
|
||||
Protobuf.Mesh.NodeInfo | undefined
|
||||
>(undefined);
|
||||
const [selectedTraceroute, setSelectedTraceroute] = useState<
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
||||
>();
|
||||
const [selectedLocation, setSelectedLocation] = useState<
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
||||
>();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
|
||||
const filteredNodes = Array.from(nodes.values()).filter(
|
||||
(n) => n.num !== hardware.myNodeNum,
|
||||
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
||||
if (node.num === hardware.myNodeNum) return false;
|
||||
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
|
||||
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
|
||||
return () => {
|
||||
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const handleTraceroute = useCallback(
|
||||
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
|
||||
setSelectedTraceroute(traceroute);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
return () => {
|
||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
const handleLocation = useCallback(
|
||||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
||||
setSelectedLocation(location);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="p-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-full">
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Name", type: "normal", sortable: true },
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Long Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<span
|
||||
key={node.num}
|
||||
className="h-3 w-3 rounded-full bg-accent"
|
||||
/>,
|
||||
<div key={node.num}>
|
||||
<Avatar text={node.user?.shortName.toString() ?? "UNK"} />
|
||||
</div>,
|
||||
|
||||
<h1 key="header">
|
||||
<h1
|
||||
key="shortName"
|
||||
onMouseDown={() => setSelectedNode(node)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{node.user?.shortName ??
|
||||
(node.user?.macaddr
|
||||
? `${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
||||
</h1>,
|
||||
|
||||
<h1
|
||||
key="longName"
|
||||
onMouseDown={() => setSelectedNode(node)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
@@ -82,7 +151,7 @@ const NodesPage = (): JSX.Element => {
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon className="text-green-600" />
|
||||
) : (
|
||||
<LockOpenIcon className="text-yellow-300" />
|
||||
<LockOpenIcon className="text-yellow-300 mx-auto" />
|
||||
)}
|
||||
</Mono>,
|
||||
<Mono key="hops">
|
||||
@@ -95,19 +164,23 @@ const NodesPage = (): JSX.Element => {
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>,
|
||||
])}
|
||||
/>
|
||||
<NodeOptionsDialog
|
||||
node={selectedNode}
|
||||
open={!!selectedNode}
|
||||
onOpenChange={() => setSelectedNode(undefined)}
|
||||
/>
|
||||
<TracerouteResponseDialog
|
||||
traceroute={selectedTraceroute}
|
||||
open={!!selectedTraceroute}
|
||||
onOpenChange={() => setSelectedTraceroute(undefined)}
|
||||
/>
|
||||
<LocationResponseDialog
|
||||
location={selectedLocation}
|
||||
open={!!selectedLocation}
|
||||
onOpenChange={() => setSelectedLocation(undefined)}
|
||||
/>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user