Add monorepo support to project (#682)
* feat: add monorepo support * fix: correct relatedProjects structure in vercel.json * update github actions with monorepo support * removed legacy dep * moved jsr config into web package
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Channel } from "@components/PageComponents/Channel.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Types } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import i18next from "i18next";
|
||||
import { QrCodeIcon, UploadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
|
||||
return channel.settings?.name.length
|
||||
? channel.settings?.name
|
||||
: channel.index === 0
|
||||
? i18next.t("page.broadcastLabel")
|
||||
: i18next.t("page.channelIndex", { ns: "channels", index: channel.index });
|
||||
};
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const { t } = useTranslation("channels");
|
||||
const { channels, setDialogOpen } = useDevice();
|
||||
const [activeChannel] = useState<Types.ChannelNumber>(
|
||||
Types.ChannelNumber.Primary,
|
||||
);
|
||||
|
||||
const currentChannel = channels.get(activeChannel);
|
||||
const allChannels = Array.from(channels.values());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={<Sidebar />}
|
||||
label={currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: t("loading", { ns: "common" })}
|
||||
actions={[
|
||||
{
|
||||
key: "import",
|
||||
icon: UploadIcon,
|
||||
onClick() {
|
||||
setDialogOpen("import", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "qr",
|
||||
icon: QrCodeIcon,
|
||||
onClick() {
|
||||
setDialogOpen("QR", true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Tabs defaultValue="0">
|
||||
<TabsList className="dark:bg-slate-800">
|
||||
{allChannels.map((channel) => (
|
||||
<TabsTrigger
|
||||
key={channel.index}
|
||||
value={channel.index.toString()}
|
||||
className="dark:text-white"
|
||||
>
|
||||
{getChannelName(channel)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent key={channel.index} value={channel.index.toString()}>
|
||||
<Channel key={channel.index} channel={channel} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ChannelsPage;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
|
||||
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
|
||||
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
||||
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
||||
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
|
||||
import { Position } from "@components/PageComponents/Config/Position.tsx";
|
||||
import { Power } from "@components/PageComponents/Config/Power.tsx";
|
||||
import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentType, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
|
||||
|
||||
interface ConfigProps {
|
||||
// We can get rid of this exception if we import every config schema and pass the union type
|
||||
// deno-lint-ignore no-explicit-any
|
||||
onFormInit: (methods: UseFormReturn<any>) => void;
|
||||
}
|
||||
type TabItem = {
|
||||
case: ValidConfigType;
|
||||
label: string;
|
||||
element: ComponentType<ConfigProps>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const { getWorkingConfig } = useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
const tabs: TabItem[] = [
|
||||
{
|
||||
case: "device",
|
||||
label: t("page.tabDevice"),
|
||||
element: Device,
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
case: "position",
|
||||
label: t("page.tabPosition"),
|
||||
element: Position,
|
||||
},
|
||||
{
|
||||
case: "power",
|
||||
label: t("page.tabPower"),
|
||||
element: Power,
|
||||
},
|
||||
{
|
||||
case: "network",
|
||||
label: t("page.tabNetwork"),
|
||||
element: Network,
|
||||
},
|
||||
{
|
||||
case: "display",
|
||||
label: t("page.tabDisplay"),
|
||||
element: Display,
|
||||
},
|
||||
{
|
||||
case: "lora",
|
||||
label: t("page.tabLora"),
|
||||
element: LoRa,
|
||||
},
|
||||
{
|
||||
case: "bluetooth",
|
||||
label: t("page.tabBluetooth"),
|
||||
element: Bluetooth,
|
||||
},
|
||||
{
|
||||
case: "security",
|
||||
label: t("page.tabSecurity"),
|
||||
element: Security,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const flags = useMemo(
|
||||
() => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
|
||||
[tabs, getWorkingConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabDevice")}>
|
||||
<TabsList className="dark:bg-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
|
||||
</span>
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<ConfigSuspender configCase={tab.case}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</ConfigSuspender>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { AmbientLighting } from "@components/PageComponents/ModuleConfig/AmbientLighting.tsx";
|
||||
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
|
||||
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
|
||||
import { Audio } from "@components/PageComponents/ModuleConfig/Audio.tsx";
|
||||
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.tsx";
|
||||
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.tsx";
|
||||
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.tsx";
|
||||
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.tsx";
|
||||
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
|
||||
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
|
||||
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
|
||||
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDevice,
|
||||
type ValidModuleConfigType,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentType, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
|
||||
|
||||
interface ConfigProps {
|
||||
// We can get rid of this exception if we import every config schema and pass the union type
|
||||
// deno-lint-ignore no-explicit-any
|
||||
onFormInit: (methods: UseFormReturn<any>) => void;
|
||||
}
|
||||
type TabItem = {
|
||||
case: ValidModuleConfigType;
|
||||
label: string;
|
||||
element: ComponentType<ConfigProps>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const { getWorkingModuleConfig } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
const tabs: TabItem[] = [
|
||||
{
|
||||
case: "mqtt",
|
||||
label: t("page.tabMqtt"),
|
||||
element: MQTT,
|
||||
},
|
||||
{
|
||||
case: "serial",
|
||||
label: t("page.tabSerial"),
|
||||
element: Serial,
|
||||
},
|
||||
{
|
||||
case: "externalNotification",
|
||||
label: t("page.tabExternalNotification"),
|
||||
element: ExternalNotification,
|
||||
},
|
||||
{
|
||||
case: "storeForward",
|
||||
label: t("page.tabStoreAndForward"),
|
||||
element: StoreForward,
|
||||
},
|
||||
{
|
||||
case: "rangeTest",
|
||||
label: t("page.tabRangeTest"),
|
||||
element: RangeTest,
|
||||
},
|
||||
{
|
||||
case: "telemetry",
|
||||
label: t("page.tabTelemetry"),
|
||||
element: Telemetry,
|
||||
},
|
||||
{
|
||||
case: "cannedMessage",
|
||||
label: t("page.tabCannedMessage"),
|
||||
element: CannedMessage,
|
||||
},
|
||||
{
|
||||
case: "audio",
|
||||
label: t("page.tabAudio"),
|
||||
element: Audio,
|
||||
},
|
||||
{
|
||||
case: "neighborInfo",
|
||||
label: t("page.tabNeighborInfo"),
|
||||
element: NeighborInfo,
|
||||
},
|
||||
{
|
||||
case: "ambientLighting",
|
||||
label: t("page.tabAmbientLighting"),
|
||||
element: AmbientLighting,
|
||||
},
|
||||
{
|
||||
case: "detectionSensor",
|
||||
label: t("page.tabDetectionSensor"),
|
||||
element: DetectionSensor,
|
||||
},
|
||||
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
|
||||
] as const;
|
||||
|
||||
const flags = useMemo(
|
||||
() =>
|
||||
new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
|
||||
[tabs, getWorkingModuleConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabMqtt")}>
|
||||
<TabsList className="dark:bg-slate-800">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
|
||||
</span>
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<ConfigSuspender moduleConfigCase={tab.case}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</ConfigSuspender>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.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 { useToast } from "@core/hooks/useToast.ts";
|
||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||
import {
|
||||
BoxesIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SaveOff,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
const ConfigPage = () => {
|
||||
const {
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
connection,
|
||||
removeWorkingConfig,
|
||||
removeWorkingModuleConfig,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
} = useDevice();
|
||||
const { hasErrors } = useAppStore();
|
||||
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||
"device" | "module"
|
||||
>("device");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onFormInit = (methods: UseFormReturn) => {
|
||||
setFormMethods(methods);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (hasErrors()) {
|
||||
return toast({
|
||||
title: t("toast.validationError.title"),
|
||||
description: t("toast.validationError.description"),
|
||||
});
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
workingConfig.map((newConfig) =>
|
||||
connection?.setConfig(newConfig).then(() => {
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newConfig.payloadVariant.case,
|
||||
}),
|
||||
});
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
workingModuleConfig.map((newModuleConfig) =>
|
||||
connection?.setModuleConfig(newModuleConfig).then(() =>
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newModuleConfig.payloadVariant.case,
|
||||
}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
await connection?.commitEditSettings().then(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset({}, {
|
||||
keepValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
workingConfig.map((newConfig) => setConfig(newConfig));
|
||||
workingModuleConfig.map((newModuleConfig) =>
|
||||
setModuleConfig(newModuleConfig)
|
||||
);
|
||||
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
});
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: t("toast.configSaveError.title"),
|
||||
description: t("toast.configSaveError.description"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (formMethods) {
|
||||
formMethods.reset();
|
||||
}
|
||||
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
};
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
<SidebarSection
|
||||
label={t("sidebar.label")}
|
||||
className="py-2 px-0"
|
||||
>
|
||||
<SidebarButton
|
||||
label={t("navigation.radioConfig")}
|
||||
active={activeConfigSection === "device"}
|
||||
onClick={() => setActiveConfigSection("device")}
|
||||
Icon={SettingsIcon}
|
||||
isDirty={workingConfig.length > 0}
|
||||
count={workingConfig.length}
|
||||
/>
|
||||
<SidebarButton
|
||||
label={t("navigation.moduleConfig")}
|
||||
active={activeConfigSection === "module"}
|
||||
onClick={() => setActiveConfigSection("module")}
|
||||
Icon={BoxesIcon}
|
||||
isDirty={workingModuleConfig.length > 0}
|
||||
count={workingModuleConfig.length}
|
||||
/>
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[activeConfigSection, workingConfig, workingModuleConfig],
|
||||
);
|
||||
|
||||
const buttonOpacity = useMemo(() => {
|
||||
const isFormDirty = formMethods?.formState.isDirty ?? false;
|
||||
const hasDirtyFields =
|
||||
(Object.keys(formMethods?.formState.dirtyFields ?? {}).length ?? 0) > 0;
|
||||
const hasWorkingConfig = workingConfig.length > 0;
|
||||
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
|
||||
|
||||
const shouldShowButton = (isFormDirty && hasDirtyFields) ||
|
||||
hasWorkingConfig || hasWorkingModuleConfig;
|
||||
|
||||
return shouldShowButton ? "opacity-100" : "opacity-0";
|
||||
}, [
|
||||
formMethods?.formState.isDirty,
|
||||
formMethods?.formState.dirtyFields,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
|
||||
}, [formMethods?.formState.errors]);
|
||||
|
||||
const actions = useMemo(() => [
|
||||
{
|
||||
key: "unsavedChanges",
|
||||
label: t("common:formValidation.unsavedChanges"),
|
||||
onClick: () => {},
|
||||
className: cn([
|
||||
"bg-blue-500 text-slate-900 hover:bg-initial",
|
||||
"transition-colors duration-200",
|
||||
buttonOpacity,
|
||||
"transition-opacity",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
icon: RefreshCwIcon,
|
||||
label: t("common:button.reset"),
|
||||
onClick: handleReset,
|
||||
className: cn([
|
||||
buttonOpacity,
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !isValid ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving ||
|
||||
!isValid ||
|
||||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
|
||||
iconClasses: !isValid
|
||||
? "text-red-400 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
className: cn([
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||
"disabled:hover:cursor-not-allowed cursor-pointer",
|
||||
]),
|
||||
onClick: handleSave,
|
||||
label: t("common:button.save"),
|
||||
},
|
||||
], [
|
||||
activeConfigSection,
|
||||
isSaving,
|
||||
isValid,
|
||||
buttonOpacity,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={leftSidebar}
|
||||
label={activeConfigSection === "device"
|
||||
? t("navigation.radioConfig")
|
||||
: t("navigation.moduleConfig")}
|
||||
actions={actions}
|
||||
>
|
||||
{activeConfigSection === "device"
|
||||
? <DeviceConfig onFormInit={onFormInit} />
|
||||
: <ModuleConfig onFormInit={onFormInit} />}
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3 px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Heading as="h3">
|
||||
{t("dashboard.title")}
|
||||
</Heading>
|
||||
<Subtle>
|
||||
{t("dashboard.description")}
|
||||
</Subtle>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length
|
||||
? (
|
||||
<ul className="grow divide-y divide-slate-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-4 py-4 sm:px-6"
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.getNode(device.hardware.myNodeNum)?.user
|
||||
?.longName ??
|
||||
t("unknown.shortName")}
|
||||
</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-slate-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.getNodesLength() === 0
|
||||
? 0
|
||||
: device.getNodesLength() - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon
|
||||
size={48}
|
||||
className="mx-auto text-text-secondary"
|
||||
/>
|
||||
<Heading as="h3">
|
||||
{t("dashboard.noDevicesTitle")}
|
||||
</Heading>
|
||||
<Subtle>
|
||||
{t("dashboard.noDevicesDescription")}
|
||||
</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="default"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
{t(
|
||||
"dashboard.button_newConnection",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
||||
import { Avatar } from "../../components/UI/Avatar.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
import { FunnelIcon, MapPinIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
|
||||
import { Map } from "@components/Map.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
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 = () => {
|
||||
const { getNodes, waypoints, hasNodeError } = useDevice();
|
||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||
|
||||
const { default: map } = useMap();
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<
|
||||
Protobuf.Mesh.NodeInfo | null
|
||||
>(null);
|
||||
|
||||
const validNodes = useMemo(
|
||||
() =>
|
||||
getNodes(
|
||||
(node): node is Protobuf.Mesh.NodeInfo =>
|
||||
Boolean(node.position?.latitudeI),
|
||||
),
|
||||
[getNodes],
|
||||
);
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(() =>
|
||||
defaultFilterValues
|
||||
);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
|
||||
[validNodes, deferredFilterState],
|
||||
);
|
||||
|
||||
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 || validNodes.length === 0) return;
|
||||
|
||||
if (validNodes.length === 1) {
|
||||
map.easeTo({
|
||||
zoom: map.getZoom(),
|
||||
center: [
|
||||
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
|
||||
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const line = lineString(
|
||||
validNodes.map((n) => [
|
||||
(n.position?.latitudeI ?? 0) / 1e7,
|
||||
(n.position?.longitudeI ?? 0) / 1e7,
|
||||
]),
|
||||
);
|
||||
const bounds = bbox(line);
|
||||
const center = map.cameraForBounds(
|
||||
[
|
||||
[bounds[1], bounds[0]],
|
||||
[bounds[3], bounds[2]],
|
||||
],
|
||||
{ padding: { top: 10, bottom: 10, left: 10, right: 10 } },
|
||||
);
|
||||
if (center) {
|
||||
map.easeTo(center);
|
||||
}
|
||||
}, [map, validNodes]);
|
||||
|
||||
// Generate all markers
|
||||
const markers = useMemo(
|
||||
() =>
|
||||
filteredNodes.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-m shadow-slate-600"
|
||||
showError={hasNodeError(node.num)}
|
||||
showFavorite={node.isFavorite}
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
}),
|
||||
[filteredNodes, handleMarkerClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
||||
<Map onLoad={getMapBounds}>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={(wp.longitudeI ?? 0) / 1e7}
|
||||
latitude={(wp.latitudeI ?? 0) / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
<MapPinIcon size={16} />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{markers}
|
||||
{selectedNode && (() => {
|
||||
const position = convertToLatLng(selectedNode.position);
|
||||
return (
|
||||
<Popup
|
||||
key={selectedNode.num}
|
||||
anchor="top"
|
||||
longitude={position.longitude}
|
||||
latitude={position.latitude}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
className="w-full"
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</Map>
|
||||
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-5" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapPage;
|
||||
@@ -0,0 +1,363 @@
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/core";
|
||||
import { getChannelName } from "@pages/Channels.tsx";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import {
|
||||
MessageState,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore/index.ts";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { messagesWithParamsRoute } from "@app/routes.tsx";
|
||||
|
||||
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
|
||||
|
||||
function SelectMessageChat() {
|
||||
const { t } = useTranslation("messages");
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
|
||||
{t("selectChatPrompt.text", { ns: "messages" })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const {
|
||||
channels,
|
||||
getNodes,
|
||||
getNode,
|
||||
hasNodeError,
|
||||
getUnreadCount,
|
||||
resetUnread,
|
||||
connection,
|
||||
} = useDevice();
|
||||
const {
|
||||
getMyNodeNum,
|
||||
getMessages,
|
||||
setMessageState,
|
||||
} = useMessageStore();
|
||||
|
||||
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { isCollapsed } = useSidebar();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const { t } = useTranslation(["messages", "channels", "ui"]);
|
||||
const deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
const navigateToChat = useCallback((type: MessageType, id: string) => {
|
||||
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
|
||||
navigate({ to: `/messages/${typeParam}/${id}` });
|
||||
}, [navigate]);
|
||||
|
||||
const chatType = type === "direct"
|
||||
? MessageType.Direct
|
||||
: MessageType.Broadcast;
|
||||
const numericChatId = Number(chatId);
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const filteredChannels = allChannels.filter(
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!type && !chatId && filteredChannels.length > 0) {
|
||||
const defaultChannel = filteredChannels[0];
|
||||
navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
|
||||
}
|
||||
}, [type, chatId, filteredChannels, navigateToChat]);
|
||||
|
||||
const currentChannel = channels.get(numericChatId);
|
||||
const otherNode = getNode(numericChatId);
|
||||
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const isBroadcast = chatType === MessageType.Broadcast;
|
||||
|
||||
const filteredNodes = (): NodeInfoWithUnread[] => {
|
||||
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
|
||||
|
||||
return getNodes((node) => {
|
||||
const longName = node.user?.longName?.toLowerCase() ?? "";
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? "";
|
||||
return longName.includes(lowerCaseSearchTerm) ||
|
||||
shortName.includes(lowerCaseSearchTerm);
|
||||
})
|
||||
.map((node) => ({
|
||||
...node,
|
||||
unreadCount: getUnreadCount(node.num) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const diff = b.unreadCount - a.unreadCount;
|
||||
if (diff !== 0) return diff;
|
||||
return Number(b.isFavorite) - Number(a.isFavorite);
|
||||
});
|
||||
};
|
||||
|
||||
const sendText = useCallback(async (message: string) => {
|
||||
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
|
||||
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
|
||||
|
||||
let messageId: number | undefined;
|
||||
|
||||
try {
|
||||
messageId = await connection?.sendText(
|
||||
message,
|
||||
toValue,
|
||||
true,
|
||||
channelValue,
|
||||
);
|
||||
if (messageId !== undefined) {
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: numericChatId,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("sendText completed but messageId is undefined");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to send message:", e);
|
||||
const failedId = messageId ?? randId();
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelValue,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
} else {
|
||||
setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: numericChatId,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
numericChatId,
|
||||
chatId,
|
||||
chatType,
|
||||
connection,
|
||||
getMyNodeNum,
|
||||
setMessageState,
|
||||
]);
|
||||
|
||||
const renderChatContent = () => {
|
||||
switch (chatType) {
|
||||
case MessageType.Broadcast:
|
||||
return (
|
||||
<ChannelChat
|
||||
messages={getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: numericChatId,
|
||||
}).reverse()}
|
||||
/>
|
||||
);
|
||||
case MessageType.Direct:
|
||||
return (
|
||||
<ChannelChat
|
||||
messages={getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: numericChatId,
|
||||
}).reverse()}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <SelectMessageChat />;
|
||||
}
|
||||
};
|
||||
|
||||
const leftSidebar = useMemo(() => (
|
||||
<Sidebar>
|
||||
<SidebarSection
|
||||
label={t("navigation.channels")}
|
||||
className="py-2 px-0"
|
||||
>
|
||||
{filteredChannels?.map((channel) => (
|
||||
<SidebarButton
|
||||
key={channel.index}
|
||||
count={getUnreadCount(channel.index)}
|
||||
label={channel.settings?.name ||
|
||||
(channel.index === 0
|
||||
? t("page.broadcastLabel", { ns: "channels" })
|
||||
: t("page.channelLabel", {
|
||||
index: channel.index,
|
||||
ns: "channels",
|
||||
}))}
|
||||
active={numericChatId === channel.index &&
|
||||
chatType === MessageType.Broadcast}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Broadcast, channel.index.toString());
|
||||
resetUnread(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon
|
||||
size={16}
|
||||
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
), [
|
||||
filteredChannels,
|
||||
numericChatId,
|
||||
chatType,
|
||||
isCollapsed,
|
||||
getUnreadCount,
|
||||
navigateToChat,
|
||||
resetUnread,
|
||||
t,
|
||||
]);
|
||||
|
||||
const rightSidebar = useMemo(
|
||||
() => (
|
||||
<SidebarSection
|
||||
label=""
|
||||
className="px-0 flex flex-col h-full overflow-y-auto"
|
||||
>
|
||||
<label className="p-2 block">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search.nodes")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
showClearButton={!!searchTerm}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
|
||||
)}
|
||||
>
|
||||
{filteredNodes()?.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
preventCollapse
|
||||
label={node.user?.longName ??
|
||||
t("unknown.shortName")}
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined}
|
||||
active={numericChatId === node.num &&
|
||||
chatType === MessageType.Direct}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Direct, node.num.toString());
|
||||
resetUnread(node.num);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
text={node.user?.shortName ??
|
||||
t("unknown.shortName")}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
showFavorite={node.isFavorite}
|
||||
size="sm"
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
),
|
||||
[
|
||||
filteredNodes,
|
||||
searchTerm,
|
||||
numericChatId,
|
||||
chatType,
|
||||
navigateToChat,
|
||||
resetUnread,
|
||||
hasNodeError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
label={`${
|
||||
t("page.title", {
|
||||
chatName: isBroadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: isDirect && otherNode
|
||||
? (otherNode.user?.longName ?? t("unknown.longName"))
|
||||
: t("emptyState.title"),
|
||||
})
|
||||
}
|
||||
`}
|
||||
rightBar={rightSidebar}
|
||||
leftBar={leftSidebar}
|
||||
actions={isDirect && otherNode
|
||||
? [
|
||||
{
|
||||
key: "encryption",
|
||||
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
|
||||
iconClasses: otherNode.user?.publicKey?.length
|
||||
? "text-green-600"
|
||||
: "text-yellow-300",
|
||||
onClick() {
|
||||
toast({
|
||||
title: otherNode.user?.publicKey?.length
|
||||
? t("toast.messages.pkiEncryption.title")
|
||||
: t("toast.messages.pskEncryption.title"),
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
: []}
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{renderChatContent()}
|
||||
|
||||
<div className="flex-none dark:bg-slate-900 p-2">
|
||||
{(isBroadcast || isDirect)
|
||||
? (
|
||||
<MessageInput
|
||||
to={isDirect ? numericChatId : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="p-4 text-center text-slate-400 italic">
|
||||
{t("sendMessage.sendButton", { ns: "messages" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
@@ -0,0 +1,283 @@
|
||||
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
|
||||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import {
|
||||
type DataRow,
|
||||
type Heading,
|
||||
Table,
|
||||
} from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import {
|
||||
type JSX,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLang from "@core/hooks/useLang.ts";
|
||||
|
||||
export interface DeleteNoteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const NodesPage = (): JSX.Element => {
|
||||
const { t } = useTranslation("nodes");
|
||||
const { currentLanguage } = useLang();
|
||||
const { getNodes, hardware, connection, hasNodeError, setDialogOpen } =
|
||||
useDevice();
|
||||
const { setNodeNumDetails } = useAppStore();
|
||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||
|
||||
const [selectedTraceroute, setSelectedTraceroute] = useState<
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
||||
>();
|
||||
const [selectedLocation, setSelectedLocation] = useState<
|
||||
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
|
||||
>();
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(() =>
|
||||
defaultFilterValues
|
||||
);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() => getNodes((node) => nodeFilter(node, deferredFilterState)),
|
||||
[deferredFilterState, getNodes, nodeFilter],
|
||||
);
|
||||
|
||||
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>) => {
|
||||
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
||||
setSelectedLocation(location);
|
||||
},
|
||||
[hardware.myNodeNum],
|
||||
);
|
||||
|
||||
function handleNodeInfoDialog(nodeNum: number): void {
|
||||
setNodeNumDetails(nodeNum);
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}
|
||||
|
||||
const tableHeadings: Heading[] = [
|
||||
{ title: "", sortable: false },
|
||||
{ title: t("nodesTable.headings.longName"), sortable: true },
|
||||
{ title: t("nodesTable.headings.connection"), sortable: true },
|
||||
{ title: t("nodesTable.headings.lastHeard"), sortable: true },
|
||||
{ title: t("nodesTable.headings.encryption"), sortable: false },
|
||||
{ title: t("unit.snr"), sortable: true },
|
||||
{ title: t("nodesTable.headings.model"), sortable: true },
|
||||
{ title: t("nodesTable.headings.macAddress"), sortable: true },
|
||||
];
|
||||
|
||||
const tableRows: DataRow[] = filteredNodes.map((node) => {
|
||||
const macAddress = base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? t("unknown.shortName");
|
||||
|
||||
return {
|
||||
id: node.num,
|
||||
isFavorite: node.isFavorite,
|
||||
cells: [
|
||||
{
|
||||
content: (
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? t("unknown.shortName")}
|
||||
showFavorite={node.isFavorite}
|
||||
showError={hasNodeError(node.num)}
|
||||
/>
|
||||
),
|
||||
sortValue: node.user?.shortName ?? "", // Non-sortable column
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<h1
|
||||
onMouseDown={() => handleNodeInfoDialog(node.num)}
|
||||
onKeyUp={(evt) => {
|
||||
evt.key === "Enter" && handleNodeInfoDialog(node.num);
|
||||
}}
|
||||
className="cursor-pointer underline ml-2 whitespace-break-spaces"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{node.user?.longName ?? numberToHexUnpadded(node.num)}
|
||||
</h1>
|
||||
),
|
||||
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono className="w-16">
|
||||
{node.hopsAway !== undefined
|
||||
? node?.viaMqtt === false && node.hopsAway === 0
|
||||
? t("nodesTable.connectionStatus.direct")
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway ?? 0 > 1
|
||||
? t("unit.hop.plural")
|
||||
: t("unit.hops_one")
|
||||
} ${t("nodesTable.connectionStatus.away")}`
|
||||
: t("nodesTable.connectionStatus.unknown")}
|
||||
{node?.viaMqtt === true
|
||||
? t("nodesTable.connectionStatus.viaMqtt")
|
||||
: ""}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.lastHeard === 0
|
||||
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
|
||||
: (
|
||||
<TimeAgo
|
||||
timestamp={node.lastHeard * 1000}
|
||||
locale={currentLanguage?.code}
|
||||
/>
|
||||
)}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.lastHeard,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
? <LockIcon className="text-green-600 mx-auto" />
|
||||
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: "", // Non-sortable column
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.snr}
|
||||
{t("unit.dbm")}/
|
||||
{Math.min(
|
||||
Math.max((node.snr + 10) * 5, 0),
|
||||
100,
|
||||
)}%/{/* Percentage */}
|
||||
{(node.snr + 10) * 5}
|
||||
{t("unit.raw")}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.snr,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
|
||||
},
|
||||
{
|
||||
content: <Mono>{macAddress}</Mono>,
|
||||
sortValue: macAddress,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
label=""
|
||||
leftBar={<Sidebar />}
|
||||
>
|
||||
<div className="pl-2 pt-2 flex flex-row">
|
||||
<div className="flex-1 mr-2">
|
||||
<Input
|
||||
placeholder={t("search.nodes")}
|
||||
value={filterState.nodeName}
|
||||
className="bg-transparent"
|
||||
showClearButton={!!filterState.nodeName}
|
||||
onChange={(e) =>
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
nodeName: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: "mr-1 p-2",
|
||||
showTextSearch: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
<Table
|
||||
headings={tableHeadings}
|
||||
rows={tableRows}
|
||||
/>
|
||||
<TracerouteResponseDialog
|
||||
traceroute={selectedTraceroute}
|
||||
open={!!selectedTraceroute}
|
||||
onOpenChange={() => setSelectedTraceroute(undefined)}
|
||||
/>
|
||||
<LocationResponseDialog
|
||||
location={selectedLocation}
|
||||
open={!!selectedLocation}
|
||||
onOpenChange={() => setSelectedLocation(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodesPage;
|
||||
Reference in New Issue
Block a user