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>;
|
||||
};
|
||||
Reference in New Issue
Block a user