Browse Source

WIP Layout Refactor

pull/83/head
Sacha Weatherstone 3 years ago
parent
commit
e0cb0dd9b7
No known key found for this signature in database GPG Key ID: 7AB2D7E206124B31
  1. 44
      package.json
  2. 1429
      pnpm-lock.yaml
  3. 12
      src/App.tsx
  4. 2
      src/DeviceWrapper.tsx
  5. 20
      src/Nav/BottomNav.tsx
  6. 37
      src/Nav/NavBar.tsx
  7. 3
      src/Nav/NavSpacer.tsx
  8. 69
      src/Nav/PageNav.tsx
  9. 6
      src/PageRouter.tsx
  10. 8
      src/components/CommandPalette/GroupView.tsx
  11. 91
      src/components/CommandPalette/Index.tsx
  12. 4
      src/components/CommandPalette/NoResults.tsx
  13. 7
      src/components/CommandPalette/SearchBox.tsx
  14. 23
      src/components/CommandPalette/SearchResult.tsx
  15. 103
      src/components/DeviceSelector.tsx
  16. 80
      src/components/Dialog/DeviceNameDialog.tsx
  17. 29
      src/components/Dialog/DialogManager.tsx
  18. 38
      src/components/Dialog/ImportDialog.tsx
  19. 45
      src/components/Dialog/QRDialog.tsx
  20. 42
      src/components/Dialog/RebootDialog.tsx
  21. 40
      src/components/Dialog/ShutdownDialog.tsx
  22. 134
      src/components/Drawer/Metrics.tsx
  23. 3
      src/components/Drawer/Notifications.tsx
  24. 170
      src/components/Drawer/Sensor.tsx
  25. 66
      src/components/Drawer/index.tsx
  26. 57
      src/components/Menu.tsx
  27. 66
      src/components/NewDevice.tsx
  28. 118
      src/components/PageComponents/AppConfig/Map.tsx
  29. 36
      src/components/PageComponents/Channel.tsx
  30. 2
      src/components/PageComponents/Config/Bluetooth.tsx
  31. 2
      src/components/PageComponents/Config/Device.tsx
  32. 2
      src/components/PageComponents/Config/Display.tsx
  33. 2
      src/components/PageComponents/Config/LoRa.tsx
  34. 2
      src/components/PageComponents/Config/Network.tsx
  35. 4
      src/components/PageComponents/Config/Position.tsx
  36. 2
      src/components/PageComponents/Config/Power.tsx
  37. 105
      src/components/PageComponents/Config/User.tsx
  38. 2
      src/components/PageComponents/Connect/BLE.tsx
  39. 2
      src/components/PageComponents/Connect/HTTP.tsx
  40. 2
      src/components/PageComponents/Connect/Serial.tsx
  41. 77
      src/components/PageComponents/Map/MapControlls.tsx
  42. 2
      src/components/PageComponents/Messages/ChannelChat.tsx
  43. 25
      src/components/PageComponents/Messages/Message.tsx
  44. 13
      src/components/PageComponents/Messages/MessageInput.tsx
  45. 2
      src/components/PageComponents/Messages/NewLocationMessage.tsx
  46. 10
      src/components/PageComponents/Messages/WaypointMessage.tsx
  47. 2
      src/components/PageComponents/ModuleConfig/Audio.tsx
  48. 2
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  49. 2
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  50. 2
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  51. 2
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  52. 2
      src/components/PageComponents/ModuleConfig/Serial.tsx
  53. 2
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  54. 2
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  55. 139
      src/components/Sidebar.tsx
  56. 44
      src/components/Topbar.tsx
  57. 53
      src/components/UI/Button.tsx
  58. 128
      src/components/UI/Dialog.tsx
  59. 21
      src/components/UI/Label.tsx
  60. 234
      src/components/UI/Menubar.tsx
  61. 29
      src/components/UI/Popover.tsx
  62. 111
      src/components/UI/Select.tsx
  63. 29
      src/components/UI/Seperator.tsx
  64. 30
      src/components/UI/Sidebar/SidebarItem.tsx
  65. 32
      src/components/UI/Sidebar/SidebarSection.tsx
  66. 27
      src/components/UI/Switch.tsx
  67. 53
      src/components/UI/Tabs.tsx
  68. 128
      src/components/UI/Toast.tsx
  69. 29
      src/components/UI/Tooltip.tsx
  70. 9
      src/components/UI/Typography/Blockquote.tsx
  71. 9
      src/components/UI/Typography/Code.tsx
  72. 9
      src/components/UI/Typography/H1.tsx
  73. 9
      src/components/UI/Typography/H2.tsx
  74. 9
      src/components/UI/Typography/H3.tsx
  75. 9
      src/components/UI/Typography/H4.tsx
  76. 15
      src/components/UI/Typography/Link.tsx
  77. 7
      src/components/UI/Typography/P.tsx
  78. 7
      src/components/UI/Typography/Subtle.tsx
  79. 77
      src/components/Widgets/BatteryWidget.tsx
  80. 39
      src/components/Widgets/DeviceWidget.tsx
  81. 44
      src/components/Widgets/PeersWidget.tsx
  82. 21
      src/components/Widgets/PositionWidget.tsx
  83. 31
      src/components/form/IconButton.tsx
  84. 6
      src/components/form/InfoWrapper.tsx
  85. 4
      src/components/form/Input.tsx
  86. 41
      src/components/form/Toggle.tsx
  87. 48
      src/components/generic/Dialog.tsx
  88. 97
      src/components/generic/TabbedContent.tsx
  89. 6
      src/components/generic/Table/index.tsx
  90. 49
      src/components/generic/VerticalTabbedContent.tsx
  91. 13
      src/core/providers/useDevice.ts
  92. 28
      src/core/stores/deviceStore.ts
  93. 27
      src/core/subscriptions.ts
  94. 6
      src/core/utils/cn.ts
  95. 92
      src/pages/Channels.tsx
  96. 41
      src/pages/Config/AppConfig.tsx
  97. 60
      src/pages/Config/DeviceConfig.tsx
  98. 50
      src/pages/Config/ModuleConfig.tsx
  99. 92
      src/pages/Config/index.tsx
  100. 103
      src/pages/Map.tsx

44
package.json

@ -20,57 +20,63 @@
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@headlessui/react": "^1.7.8", "@headlessui/react": "^1.7.9",
"@heroicons/react": "^2.0.14",
"@hookform/error-message": "^2.0.1", "@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^2.9.10", "@hookform/resolvers": "^2.9.10",
"@meshtastic/meshtasticjs": "2.0.15-0", "@meshtastic/meshtasticjs": "2.0.15-0",
"@primer/octicons-react": "^17.11.1", "@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-label": "^2.0.0",
"@radix-ui/react-menubar": "^1.0.0",
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-separator": "^1.0.1",
"@radix-ui/react-switch": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.2",
"@radix-ui/react-toast": "^1.1.2",
"@radix-ui/react-tooltip": "^1.0.3",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@turf/turf": "^6.5.0", "@turf/turf": "^6.5.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"chart.js": "^4.2.0",
"chartjs-adapter-date-fns": "^3.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"date-fns": "^2.29.3", "class-variance-authority": "^0.4.0",
"clsx": "^1.2.1",
"cmdk": "^0.1.21",
"geodesy": "^2.4.0", "geodesy": "^2.4.0",
"i18next": "^22.4.9", "i18next": "^22.4.9",
"immer": "^9.0.19", "immer": "^9.0.19",
"lucide-react": "^0.109.0",
"mapbox-gl": "npm:empty-npm-package@^1.0.0", "mapbox-gl": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "2.4.0", "maplibre-gl": "2.4.0",
"pretty-ms": "^8.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.42.1", "react-hook-form": "^7.43.0",
"react-hot-toast": "^2.4.0",
"react-i18next": "^12.1.4",
"react-json-tree": "^0.18.0",
"react-map-gl": "^7.0.21", "react-map-gl": "^7.0.21",
"react-qrcode-logo": "^2.8.0", "react-qrcode-logo": "^2.8.0",
"rfc4648": "^1.5.2", "rfc4648": "^1.5.2",
"tailwind-merge": "^1.9.0",
"tailwindcss-animate": "^1.0.5",
"timeago-react": "^3.0.5", "timeago-react": "^3.0.5",
"zustand": "4.3.2" "zustand": "4.3.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@types/chrome": "^0.0.210", "@types/chrome": "^0.0.212",
"@types/geodesy": "^2.2.3", "@types/geodesy": "^2.2.3",
"@types/node": "^18.11.18", "@types/node": "^18.11.18",
"@types/react": "^18.0.27", "@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/w3c-web-serial": "^1.0.3", "@types/w3c-web-serial": "^1.0.3",
"@types/web-bluetooth": "^0.0.16", "@types/web-bluetooth": "^0.0.16",
"@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.49.0", "@typescript-eslint/parser": "^5.50.0",
"@vitejs/plugin-react": "^3.0.1", "@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint": "^8.33.0", "eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.6.0",
"eslint-import-resolver-typescript": "^3.5.3", "eslint-import-resolver-typescript": "^3.5.3",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-react": "^7.32.1", "eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"gzipper": "^7.2.0", "gzipper": "^7.2.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
@ -80,8 +86,8 @@
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"tar": "^6.1.13", "tar": "^6.1.13",
"tslib": "^2.5.0", "tslib": "^2.5.0",
"typescript": "^4.9.4", "typescript": "^4.9.5",
"vite": "^4.0.4", "vite": "^4.1.1",
"vite-plugin-environment": "^1.1.3", "vite-plugin-environment": "^1.1.3",
"vite-plugin-pwa": "^0.14.1" "vite-plugin-pwa": "^0.14.1"
} }

1429
pnpm-lock.yaml

File diff suppressed because it is too large

12
src/App.tsx

@ -6,12 +6,9 @@ import { CommandPalette } from "@components/CommandPalette/Index.js";
import { DeviceSelector } from "@components/DeviceSelector.js"; import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js"; import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDevice } from "@components/NewDevice.js"; import { NewDevice } from "@components/NewDevice.js";
import { Sidebar } from "@components/Sidebar.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { Drawer } from "@components/Drawer/index.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
import { BottomNav } from "@app/Nav/BottomNav.js"; import { Menu } from "./components/Menu.js";
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore(); const { getDevice } = useDeviceStore();
@ -23,20 +20,21 @@ export const App = (): JSX.Element => {
<ThemeController> <ThemeController>
<MapProvider> <MapProvider>
<DeviceWrapper device={device}> <DeviceWrapper device={device}>
<div className="flex bg-backgroundSecondary"> <div className="flex min-h-screen flex-col">
<Menu />
<div className="flex flex-grow">
<DeviceSelector /> <DeviceSelector />
<div className="flex flex-grow flex-col"> <div className="flex flex-grow flex-col">
{device ? ( {device ? (
<div className="flex flex-grow"> <div className="flex flex-grow">
<DialogManager /> <DialogManager />
<CommandPalette /> <CommandPalette />
<Sidebar />
<PageRouter /> <PageRouter />
</div> </div>
) : ( ) : (
<NewDevice /> <NewDevice />
)} )}
<BottomNav>{device && <Drawer />}</BottomNav> </div>
</div> </div>
</div> </div>
</DeviceWrapper> </DeviceWrapper>

2
src/DeviceWrapper.tsx

@ -1,4 +1,4 @@
import { DeviceContext } from "@core/providers/useDevice.js"; import { DeviceContext } from "@core/stores/deviceStore.js";
import type { Device } from "@core/stores/deviceStore.js"; import type { Device } from "@core/stores/deviceStore.js";
import type { ReactNode } from "react"; import type { ReactNode } from "react";

20
src/Nav/BottomNav.tsx

@ -1,20 +0,0 @@
import { GitBranchIcon } from "@primer/octicons-react";
import type { ReactNode } from "react";
export interface BottomNavProps {
children: ReactNode;
}
export const BottomNav = ({ children }: BottomNavProps): JSX.Element => {
return (
<div className="flex bg-backgroundPrimary">
<div className="flex h-8 cursor-pointer select-none gap-1 bg-accent px-1 text-textPrimary hover:brightness-hover active:brightness-press">
<GitBranchIcon className="my-auto w-4" />
<span className="my-auto font-mono text-sm">
{process.env.COMMIT_HASH}
</span>
</div>
{children}
</div>
);
};

37
src/Nav/NavBar.tsx

@ -1,37 +0,0 @@
import { Button } from "@app/components/form/Button.js";
import { ChevronRightIcon, HomeIcon } from "@primer/octicons-react";
export interface NavBarProps {
breadcrumb: string[];
actions?: {
label: string;
onClick: () => void;
}[];
}
export const NavBar = ({ breadcrumb, actions }: NavBarProps): JSX.Element => {
return (
<div className="flex rounded-md bg-backgroundSecondary p-2">
<ol className="my-auto ml-2 flex gap-4 text-textSecondary">
<li className="cursor-pointer hover:brightness-disabled">
<HomeIcon className="h-5 w-5 flex-shrink-0" />
</li>
{breadcrumb.map((breadcrumb, index) => (
<li key={index} className="my-auto flex gap-4">
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 brightness-disabled" />
<span className="cursor-pointer text-sm font-medium hover:brightness-disabled">
{breadcrumb}
</span>
</li>
))}
</ol>
<div className="ml-auto">
{actions?.map((Action, index) => (
<Button key={index} onClick={Action.onClick}>
{Action.label}
</Button>
))}
</div>
</div>
);
};

3
src/Nav/NavSpacer.tsx

@ -1,3 +0,0 @@
export const NavSpacer = (): JSX.Element => {
return <div className="h-1 w-10 rounded-full bg-accentMuted" />;
};

69
src/Nav/PageNav.tsx

@ -1,69 +0,0 @@
import type { ComponentType, SVGProps } from "react";
import { useDevice } from "@core/providers/useDevice.js";
import type { Page } from "@core/stores/deviceStore.js";
import {
BeakerIcon,
ChatBubbleBottomCenterTextIcon,
Cog8ToothIcon,
MapIcon,
Square3Stack3DIcon,
UsersIcon
} from "@heroicons/react/24/outline";
export const PageNav = (): JSX.Element => {
const { activePage, setActivePage } = useDevice();
interface NavLink {
name: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
page: Page;
}
const pages: NavLink[] = [
{
name: "Messages",
icon: ChatBubbleBottomCenterTextIcon,
page: "messages"
},
{
name: "Map",
icon: MapIcon,
page: "map"
},
{
name: "Config",
icon: Cog8ToothIcon,
page: "config"
},
{
name: "Channels",
icon: Square3Stack3DIcon,
page: "channels"
},
{
name: "Peers",
icon: UsersIcon,
page: "peers"
}
];
return (
<div className="flex text-textPrimary">
{pages.map((Link) => (
<div
key={Link.name}
onClick={() => {
setActivePage(Link.page);
}}
className={`border-x-4 border-backgroundPrimary bg-backgroundPrimary py-5 px-4 hover:brightness-hover active:brightness-press ${
Link.page === activePage
? "border-l-accent text-textPrimary"
: "text-textSecondary hover:text-textPrimary"
}`}
>
<Link.icon className="w-4" />
</div>
))}
</div>
);
};

6
src/PageRouter.tsx

@ -1,4 +1,4 @@
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { ChannelsPage } from "@pages/Channels.js"; import { ChannelsPage } from "@pages/Channels.js";
import { ConfigPage } from "@pages/Config/index.js"; import { ConfigPage } from "@pages/Config/index.js";
import { MapPage } from "@pages/Map.js"; import { MapPage } from "@pages/Map.js";
@ -8,12 +8,12 @@ import { PeersPage } from "@pages/Peers.js";
export const PageRouter = (): JSX.Element => { export const PageRouter = (): JSX.Element => {
const { activePage } = useDevice(); const { activePage } = useDevice();
return ( return (
<div className="flex-grow overflow-y-auto bg-backgroundPrimary"> <>
{activePage === "messages" && <MessagesPage />} {activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />} {activePage === "map" && <MapPage />}
{activePage === "config" && <ConfigPage />} {activePage === "config" && <ConfigPage />}
{activePage === "channels" && <ChannelsPage />} {activePage === "channels" && <ChannelsPage />}
{activePage === "peers" && <PeersPage />} {activePage === "peers" && <PeersPage />}
</div> </>
); );
}; };

8
src/components/CommandPalette/GroupView.tsx

@ -1,6 +1,6 @@
import type { Group } from "@components/CommandPalette/Index.js"; import type { Group } from "@components/CommandPalette/Index.js";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { ChevronRightIcon } from "@heroicons/react/24/outline"; import { ChevronRightIcon } from "lucide-react";
export interface GroupViewProps { export interface GroupViewProps {
group: Group; group: Group;
@ -18,9 +18,11 @@ export const GroupView = ({ group }: GroupViewProps): JSX.Element => {
> >
{({ active }) => ( {({ active }) => (
<> <>
<group.icon className="h-6 w-6" /> <group.icon size={20} />
<span className="ml-3 flex-auto truncate">{group.label}</span> <span className="ml-3 flex-auto truncate">{group.label}</span>
{active && <ChevronRightIcon className="h-5 text-textSecondary" />} {active && (
<ChevronRightIcon size={16} className="text-textSecondary" />
)}
</> </>
)} )}
</Combobox.Option> </Combobox.Option>

91
src/components/CommandPalette/Index.tsx

@ -1,6 +1,5 @@
import { ComponentType, Fragment, SVGProps, useEffect, useState } from "react"; import { ComponentType, Fragment, SVGProps, useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { useDevice } from "@core/stores/deviceStore.js";
import { useDevice } from "@core/providers/useDevice.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { GroupView } from "@components/CommandPalette/GroupView.js"; import { GroupView } from "@components/CommandPalette/GroupView.js";
@ -11,42 +10,41 @@ import { SearchResult } from "@components/CommandPalette/SearchResult.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
import { import {
ArchiveBoxXMarkIcon, LucideIcon,
ArrowDownOnSquareStackIcon,
ArrowPathIcon,
ArrowPathRoundedSquareIcon,
ArrowsRightLeftIcon,
BeakerIcon,
BugAntIcon,
Cog8ToothIcon,
CubeTransparentIcon,
DevicePhoneMobileIcon,
InboxIcon,
LinkIcon, LinkIcon,
TrashIcon,
MapIcon, MapIcon,
MoonIcon, MoonIcon,
PlusIcon, PlusIcon,
PowerIcon, PowerIcon,
EraserIcon,
ImportIcon,
RefreshCwIcon,
FactoryIcon,
ArrowLeftRightIcon,
BugIcon,
SettingsIcon,
SmartphoneIcon,
MessageSquareIcon,
QrCodeIcon, QrCodeIcon,
QueueListIcon, LayersIcon,
Square3Stack3DIcon, PaletteIcon,
SwatchIcon,
TrashIcon,
UsersIcon, UsersIcon,
WindowIcon, LayoutIcon,
XCircleIcon XCircleIcon,
} from "@heroicons/react/24/outline"; BoxSelectIcon
} from "lucide-react";
import { Blur } from "@components/generic/Blur.js"; import { Blur } from "@components/generic/Blur.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
export interface Group { export interface Group {
label: string; label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>; icon: LucideIcon;
commands: Command[]; commands: Command[];
} }
export interface Command { export interface Command {
label: string; label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>; icon: LucideIcon;
action?: () => void; action?: () => void;
subItems?: SubItem[]; subItems?: SubItem[];
tags?: string[]; tags?: string[];
@ -63,7 +61,6 @@ export const CommandPalette = (): JSX.Element => {
const { const {
commandPaletteOpen, commandPaletteOpen,
setCommandPaletteOpen, setCommandPaletteOpen,
devices,
setSelectedDevice, setSelectedDevice,
removeDevice, removeDevice,
selectedDevice, selectedDevice,
@ -82,7 +79,7 @@ export const CommandPalette = (): JSX.Element => {
commands: [ commands: [
{ {
label: "Messages", label: "Messages",
icon: InboxIcon, icon: MessageSquareIcon,
action() { action() {
setActivePage("messages"); setActivePage("messages");
} }
@ -96,7 +93,7 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Config", label: "Config",
icon: Cog8ToothIcon, icon: SettingsIcon,
action() { action() {
setActivePage("config"); setActivePage("config");
}, },
@ -104,7 +101,7 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Channels", label: "Channels",
icon: Square3Stack3DIcon, icon: LayersIcon,
action() { action() {
setActivePage("channels"); setActivePage("channels");
} }
@ -120,11 +117,11 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Manage", label: "Manage",
icon: DevicePhoneMobileIcon, icon: SmartphoneIcon,
commands: [ commands: [
{ {
label: "Switch Node", label: "Switch Node",
icon: ArrowsRightLeftIcon, icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => { subItems: getDevices().map((device) => {
return { return {
label: label:
@ -133,7 +130,7 @@ export const CommandPalette = (): JSX.Element => {
)?.data.user?.longName ?? device.hardware.myNodeNum.toString(), )?.data.user?.longName ?? device.hardware.myNodeNum.toString(),
icon: ( icon: (
<Hashicon <Hashicon
size={18} size={16}
value={device.hardware.myNodeNum.toString()} value={device.hardware.myNodeNum.toString()}
/> />
), ),
@ -154,7 +151,7 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Contextual", label: "Contextual",
icon: CubeTransparentIcon, icon: BoxSelectIcon,
commands: [ commands: [
{ {
label: "QR Code", label: "QR Code",
@ -162,14 +159,14 @@ export const CommandPalette = (): JSX.Element => {
subItems: [ subItems: [
{ {
label: "Generator", label: "Generator",
icon: <QueueListIcon className="w-4" />, icon: <QrCodeIcon size={16} />,
action() { action() {
setDialogOpen("QR", true); setDialogOpen("QR", true);
} }
}, },
{ {
label: "Import", label: "Import",
icon: <ArrowDownOnSquareStackIcon className="w-4" />, icon: <QrCodeIcon size={16} />,
action() { action() {
setDialogOpen("import", true); setDialogOpen("import", true);
} }
@ -194,7 +191,7 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Schedule Reboot", label: "Schedule Reboot",
icon: ArrowPathIcon, icon: RefreshCwIcon,
action() { action() {
setDialogOpen("reboot", true); setDialogOpen("reboot", true);
} }
@ -203,44 +200,32 @@ export const CommandPalette = (): JSX.Element => {
label: "Reset Peers", label: "Reset Peers",
icon: TrashIcon, icon: TrashIcon,
action() { action() {
if (connection) { connection?.resetPeers();
void toast.promise(connection.resetPeers(), {
loading: "Resetting...",
success: "Succesfully reset peers",
error: "No response received"
});
}
} }
}, },
{ {
label: "Factory Reset", label: "Factory Reset",
icon: ArrowPathRoundedSquareIcon, icon: FactoryIcon,
action() { action() {
if (connection) { connection?.factoryReset();
void toast.promise(connection.factoryReset(), {
loading: "Resetting...",
success: "Succesfully factory peers",
error: "No response received"
});
}
} }
} }
] ]
}, },
{ {
label: "Debug", label: "Debug",
icon: BugAntIcon, icon: BugIcon,
commands: [ commands: [
{ {
label: "Reconfigure", label: "Reconfigure",
icon: ArrowPathIcon, icon: RefreshCwIcon,
action() { action() {
void connection?.configure(); void connection?.configure();
} }
}, },
{ {
label: "[WIP] Clear Messages", label: "[WIP] Clear Messages",
icon: ArchiveBoxXMarkIcon, icon: EraserIcon,
action() { action() {
alert("This feature is not implemented"); alert("This feature is not implemented");
} }
@ -249,7 +234,7 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Application", label: "Application",
icon: WindowIcon, icon: LayoutIcon,
commands: [ commands: [
{ {
label: "Toggle Dark Mode", label: "Toggle Dark Mode",
@ -260,7 +245,7 @@ export const CommandPalette = (): JSX.Element => {
}, },
{ {
label: "Accent Color", label: "Accent Color",
icon: SwatchIcon, icon: PaletteIcon,
subItems: [ subItems: [
{ {
label: "Red", label: "Red",

4
src/components/CommandPalette/NoResults.tsx

@ -1,10 +1,10 @@
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { CommandLineIcon } from "@heroicons/react/24/outline"; import { TerminalSquareIcon } from "lucide-react";
export const NoResults = (): JSX.Element => { export const NoResults = (): JSX.Element => {
return ( return (
<div className="py-14 px-14 text-center"> <div className="py-14 px-14 text-center">
<CommandLineIcon className="mx-auto h-6 text-textSecondary" /> <TerminalSquareIcon className="mx-auto text-textSecondary" />
<Mono className="tracking-tighter"> <Mono className="tracking-tighter">
Query does not match any avaliable commands Query does not match any avaliable commands
</Mono> </Mono>

7
src/components/CommandPalette/SearchBox.tsx

@ -1,5 +1,5 @@
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { SearchIcon } from "lucide-react";
export interface SearchBoxProps { export interface SearchBoxProps {
setQuery: (query: string) => void; setQuery: (query: string) => void;
@ -8,7 +8,10 @@ export interface SearchBoxProps {
export const SearchBox = ({ setQuery }: SearchBoxProps): JSX.Element => { export const SearchBox = ({ setQuery }: SearchBoxProps): JSX.Element => {
return ( return (
<div className="relative"> <div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-textSecondary" /> <SearchIcon
size={20}
className="pointer-events-none absolute m-3.5 text-textSecondary"
/>
<Combobox.Input <Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-textPrimary placeholder-textSecondary focus:ring-0" className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-textPrimary placeholder-textSecondary focus:ring-0"
placeholder="Search..." placeholder="Search..."

23
src/components/CommandPalette/SearchResult.tsx

@ -1,6 +1,6 @@
import type { Group } from "@components/CommandPalette/Index.js"; import type { Group } from "@components/CommandPalette/Index.js";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { ChevronRightIcon } from "@heroicons/react/24/outline"; import { ChevronRightIcon } from "lucide-react";
export interface SearchResultProps { export interface SearchResultProps {
group: Group; group: Group;
@ -10,7 +10,10 @@ export const SearchResult = ({ group }: SearchResultProps): JSX.Element => {
return ( return (
<div className="rounded-md border-2 border-backgroundPrimary py-2"> <div className="rounded-md border-2 border-backgroundPrimary py-2">
<div className="flex items-center px-3 py-2"> <div className="flex items-center px-3 py-2">
<group.icon className="text-gray-900 h-6 w-6 flex-none text-opacity-40" /> <group.icon
size={16}
className="flex-none text-gray-900 text-opacity-40"
/>
<span className="ml-3 flex-auto truncate">{group.label}</span> <span className="ml-3 flex-auto truncate">{group.label}</span>
</div> </div>
{group.commands.map((command, index) => ( {group.commands.map((command, index) => (
@ -19,20 +22,23 @@ export const SearchResult = ({ group }: SearchResultProps): JSX.Element => {
value={command} value={command}
className={({ active }) => className={({ active }) =>
`mr-2 ml-4 flex cursor-pointer select-none items-center rounded-md px-3 py-1 ${ `mr-2 ml-4 flex cursor-pointer select-none items-center rounded-md px-3 py-1 ${
active ? "text-gray-900 bg-backgroundPrimary" : "" active ? "bg-backgroundPrimary text-gray-900" : ""
}` }`
} }
> >
{({ active }) => ( {({ active }) => (
<> <>
<command.icon <command.icon
className={`text-gray-900 h-4 flex-none text-opacity-40 ${ className={`h-4 flex-none text-gray-900 text-opacity-40 ${
active ? "text-opacity-100" : "" active ? "text-opacity-100" : ""
}`} }`}
/> />
<span className="ml-3">{command.label}</span> <span className="ml-3">{command.label}</span>
{active && ( {active && (
<ChevronRightIcon className="text-gray-400 ml-auto h-4" /> <ChevronRightIcon
size={16}
className="ml-auto text-gray-400"
/>
)} )}
</> </>
)} )}
@ -46,7 +52,7 @@ export const SearchResult = ({ group }: SearchResultProps): JSX.Element => {
className={({ active }) => className={({ active }) =>
`mx-2 flex cursor-pointer select-none items-center rounded-md px-3 py-1 ${ `mx-2 flex cursor-pointer select-none items-center rounded-md px-3 py-1 ${
active active
? "text-gray-900 bg-backgroundPrimary bg-opacity-5" ? "bg-backgroundPrimary bg-opacity-5 text-gray-900"
: "" : ""
}` }`
} }
@ -56,7 +62,10 @@ export const SearchResult = ({ group }: SearchResultProps): JSX.Element => {
{item.icon} {item.icon}
<span className="ml-3">{item.label}</span> <span className="ml-3">{item.label}</span>
{active && ( {active && (
<ChevronRightIcon className="text-gray-400 ml-auto h-4" /> <ChevronRightIcon
size={16}
className="ml-auto text-gray-400"
/>
)} )}
</> </>
)} )}

103
src/components/DeviceSelector.tsx

@ -1,69 +1,86 @@
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { NavSpacer } from "@app/Nav/NavSpacer.js";
import { PageNav } from "@app/Nav/PageNav.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { PlusIcon } from "@heroicons/react/24/outline"; import {
import { MoonIcon, SunIcon } from "@primer/octicons-react"; PlusIcon,
HomeIcon,
LanguagesIcon,
SunIcon,
MoonIcon,
GithubIcon,
TerminalIcon
} from "lucide-react";
import { Separator } from "./UI/Seperator.js";
import { cn } from "@app/core/utils/cn.js";
import { Code } from "./UI/Typography/Code.js";
export const DeviceSelector = (): JSX.Element => { export const DeviceSelector = (): JSX.Element => {
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { selectedDevice, setSelectedDevice, darkMode, setDarkMode } = const {
useAppStore(); selectedDevice,
setSelectedDevice,
darkMode,
setDarkMode,
setCommandPaletteOpen
} = useAppStore();
return ( return (
<div className="flex h-full w-14 items-center gap-3 bg-backgroundPrimary pt-3 [writing-mode:vertical-rl]"> <nav className="drag custom-border flex flex-col justify-between border-r-[0.5px] bg-transparent pt-2">
<div className="flex items-center gap-3"> <div className="flex flex-col overflow-y-hidden">
<span className="flex font-bold text-textPrimary"> <ul className="flex w-20 shrink-0 flex-col items-center justify-end space-y-2.5 bg-transparent px-5 pt-1 pb-px">
<li className="aspect-w-1 aspect-h-1 w-full">
<a className="text-primary-500 ring-primary-300 flex flex-col items-center justify-center rounded-full outline-none ring transition-all focus:outline-none sm:duration-300">
<HomeIcon />
</a>
</li>
</ul>
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
{getDevices().map((device) => ( {getDevices().map((device) => (
<div <li
key={device.id} key={device.id}
className="aspect-w-1 aspect-h-1 w-full"
onClick={() => { onClick={() => {
setSelectedDevice(device.id); setSelectedDevice(device.id);
}} }}
className={`cursor-pointer border-x-4 border-backgroundPrimary bg-backgroundPrimary py-3 px-2 hover:brightness-hover active:brightness-press ${ >
selectedDevice === device.id ? "border-l-accent" : "" <div
}`} className={cn(
"flex aspect-square cursor-pointer flex-col items-center justify-center rounded-full ring ring-accent",
selectedDevice === device.id ? "ring" : "ring-0"
)}
> >
<Hashicon <Hashicon
size={32} size={24}
value={device.hardware.myNodeNum.toString()} value={device.hardware.myNodeNum.toString()}
/> />
</div> </div>
</li>
))} ))}
<div <Separator />
onClick={() => { <div className="transition-all duration-300 hover:text-accent">
setSelectedDevice(0); <PlusIcon />
}}
className={`cursor-pointer border-x-4 border-backgroundPrimary bg-backgroundPrimary py-4 px-3 hover:brightness-hover active:brightness-press ${
selectedDevice === 0 ? "border-l-accent" : ""
}`}
>
<PlusIcon className="w-6" />
</div> </div>
</span> </ul>
</div> </div>
<div className="flex w-20 flex-col items-center space-y-5 bg-transparent px-5 pb-5">
{selectedDevice !== 0 && ( <button
<> className="transition-all hover:text-accent"
<NavSpacer />
<PageNav />
</>
)}
<NavSpacer />
<div
onClick={() => setDarkMode(!darkMode)} onClick={() => setDarkMode(!darkMode)}
className="bg-backgroundPrimary py-5 px-4 text-textSecondary hover:text-textPrimary hover:brightness-hover active:brightness-press"
> >
{darkMode ? <SunIcon className="w-4" /> : <MoonIcon className="w-4" />} {darkMode ? <SunIcon /> : <MoonIcon />}
</div> </button>
<button
<img className="transition-all hover:text-accent"
src={darkMode ? "Logo_White.svg" : "Logo_Black.svg"} onClick={() => setCommandPaletteOpen(true)}
className="mt-auto px-2 py-3" >
/> <TerminalIcon />
</button>
<button className="transition-all hover:text-accent">
<LanguagesIcon />
</button>
<Separator />
<Code>{process.env.COMMIT_HASH}</Code>
</div> </div>
</nav>
); );
}; };

80
src/components/Dialog/DeviceNameDialog.tsx

@ -0,0 +1,80 @@
import { Input } from "@components/form/Input.js";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@components/UI/Dialog.js";
import { Button } from "../form/Button.js";
import { useDevice } from "@app/core/stores/deviceStore.js";
import { useForm } from "react-hook-form";
import { Protobuf } from "@meshtastic/meshtasticjs";
export interface User {
longName: string;
shortName: string;
}
export interface DeviceNameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeviceNameDialog = ({
open,
onOpenChange
}: DeviceNameDialogProps): JSX.Element => {
const { hardware, nodes, connection, setDialogOpen } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
const { register, handleSubmit } = useForm<User>({
defaultValues: {
longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName
}
});
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
new Protobuf.User({
...myNode?.data.user,
...data
})
);
setDialogOpen("deviceName", false);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change Device Name</DialogTitle>
<DialogDescription>
The Device will restart once the config is saved.
</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Input
label="Long Name"
description="Personalised name for this device."
{...register("longName")}
/>
<Input
label="Short Name"
description="Shown on small screens."
maxLength={4}
{...register("shortName")}
/>
</form>
</div>
<DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

29
src/components/Dialog/DialogManager.tsx

@ -1,41 +1,48 @@
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { QRDialog } from "@components/Dialog/QRDialog.js"; import { QRDialog } from "@components/Dialog/QRDialog.js";
import { RebootDialog } from "@components/Dialog/RebootDialog.js"; import { RebootDialog } from "@components/Dialog/RebootDialog.js";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js"; import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { DeviceNameDialog } from "./DeviceNameDialog.js";
export const DialogManager = (): JSX.Element => { export const DialogManager = (): JSX.Element => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
return ( return (
<> <>
<QRDialog <QRDialog
isOpen={dialog.QR} open={dialog.QR}
close={() => { onOpenChange={(open) => {
setDialogOpen("QR", false); setDialogOpen("QR", open);
}} }}
channels={channels.map((ch) => ch.config)} channels={channels.map((ch) => ch.config)}
loraConfig={config.lora} loraConfig={config.lora}
/> />
<ImportDialog <ImportDialog
isOpen={dialog.import} open={dialog.import}
close={() => { onOpenChange={(open) => {
setDialogOpen("import", false); setDialogOpen("import", open);
}} }}
channels={channels.map((ch) => ch.config)} channels={channels.map((ch) => ch.config)}
loraConfig={config.lora} loraConfig={config.lora}
/> />
<ShutdownDialog <ShutdownDialog
isOpen={dialog.shutdown} open={dialog.shutdown}
close={() => { onOpenChange={() => {
setDialogOpen("shutdown", false); setDialogOpen("shutdown", false);
}} }}
/> />
<RebootDialog <RebootDialog
isOpen={dialog.reboot} open={dialog.reboot}
close={() => { onOpenChange={() => {
setDialogOpen("reboot", false); setDialogOpen("reboot", false);
}} }}
/> />
<DeviceNameDialog
open={dialog.deviceName}
onOpenChange={(open) => {
setDialogOpen("deviceName", open);
}}
/>
</> </>
); );
}; };

38
src/components/Dialog/ImportDialog.tsx

@ -2,24 +2,31 @@ import { useEffect, useState } from "react";
import { toByteArray } from "base64-js"; import { toByteArray } from "base64-js";
import { Checkbox } from "@components/form/Checkbox.js"; import { Checkbox } from "@components/form/Checkbox.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
import { Dialog } from "@components/generic/Dialog.js"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@components/UI/Dialog.js";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { Select } from "@components/form/Select.js"; import { Select } from "@components/form/Select.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/form/Button.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
export interface ImportDialogProps { export interface ImportDialogProps {
isOpen: boolean; open: boolean;
close: () => void; onOpenChange: (open: boolean) => void;
loraConfig?: Protobuf.Config_LoRaConfig; loraConfig?: Protobuf.Config_LoRaConfig;
channels: Protobuf.Channel[]; channels: Protobuf.Channel[];
} }
export const ImportDialog = ({ export const ImportDialog = ({
isOpen, open,
close onOpenChange
}: ImportDialogProps): JSX.Element => { }: ImportDialogProps): JSX.Element => {
const [QRCodeURL, setQRCodeURL] = useState<string>(""); const [QRCodeURL, setQRCodeURL] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.ChannelSet>(); const [channelSet, setChannelSet] = useState<Protobuf.ChannelSet>();
@ -68,12 +75,14 @@ export const ImportDialog = ({
}; };
return ( return (
<Dialog <Dialog open={open} onOpenChange={onOpenChange}>
title={"Import Channel Set"} <DialogContent>
description={"The current LoRa configuration will be overridden."} <DialogHeader>
isOpen={isOpen} <DialogTitle>Import Channel Set</DialogTitle>
close={close} <DialogDescription>
> The current LoRa configuration will be overridden.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input <Input
label="Channel Set/QR Code URL" label="Channel Set/QR Code URL"
@ -127,10 +136,13 @@ export const ImportDialog = ({
</div> </div>
</div> </div>
)} )}
</div>
<DialogFooter>
<Button onClick={() => apply()} disabled={!validURL}> <Button onClick={() => apply()} disabled={!validURL}>
Apply Apply
</Button> </Button>
</div> </DialogFooter>
</DialogContent>
</Dialog> </Dialog>
); );
}; };

45
src/components/Dialog/QRDialog.tsx

@ -1,23 +1,29 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { toast } from "react-hot-toast";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
import { Checkbox } from "@components/form/Checkbox.js"; import { Checkbox } from "@components/form/Checkbox.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
import { Dialog } from "@components/generic/Dialog.js"; import {
import { ClipboardIcon } from "@heroicons/react/24/outline"; Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@components/UI/Dialog.js";
import { ClipboardIcon } from "lucide-react";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
export interface QRDialogProps { export interface QRDialogProps {
isOpen: boolean; open: boolean;
close: () => void; onOpenChange: (open: boolean) => void;
loraConfig?: Protobuf.Config_LoRaConfig; loraConfig?: Protobuf.Config_LoRaConfig;
channels: Protobuf.Channel[]; channels: Protobuf.Channel[];
} }
export const QRDialog = ({ export const QRDialog = ({
isOpen, open,
close, onOpenChange,
loraConfig, loraConfig,
channels channels
}: QRDialogProps): JSX.Element => { }: QRDialogProps): JSX.Element => {
@ -44,12 +50,15 @@ export const QRDialog = ({
}, [channels, selectedChannels, loraConfig]); }, [channels, selectedChannels, loraConfig]);
return ( return (
<Dialog <Dialog open={open} onOpenChange={onOpenChange}>
title={"Generate QR Code"} <DialogContent>
description={"The current LoRa configuration will also be shared."} <DialogHeader>
isOpen={isOpen} <DialogTitle>Generate QR Code</DialogTitle>
close={close} <DialogDescription>
> The current LoRa configuration will also be shared.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-3 px-4 py-5 sm:p-6"> <div className="flex gap-3 px-4 py-5 sm:p-6">
<div className="flex w-40 flex-col gap-1"> <div className="flex w-40 flex-col gap-1">
{channels.map((channel) => ( {channels.map((channel) => (
@ -81,21 +90,21 @@ export const QRDialog = ({
</div> </div>
<QRCode value={QRCodeURL} size={200} qrStyle="dots" /> <QRCode value={QRCodeURL} size={200} qrStyle="dots" />
</div> </div>
</div>
<div className="sm:px-6"> <DialogFooter>
<Input <Input
label="Sharable URL" label="Sharable URL"
value={QRCodeURL} value={QRCodeURL}
disabled disabled
action={{ action={{
icon: <ClipboardIcon className="h-4" />, icon: <ClipboardIcon size={16} />,
action() { action() {
void navigator.clipboard.writeText(QRCodeURL); void navigator.clipboard.writeText(QRCodeURL);
toast.success("Copied URL to Clipboard");
} }
}} }}
/> />
</div> </DialogFooter>
</DialogContent>
</Dialog> </Dialog>
); );
}; };

42
src/components/Dialog/RebootDialog.tsx

@ -1,37 +1,46 @@
import { useState } from "react"; import { useState } from "react";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Dialog } from "@components/generic/Dialog.js"; import {
import { ArrowPathIcon, ClockIcon } from "@heroicons/react/24/outline"; Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@components/UI/Dialog.js";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/form/Button.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
export interface RebootDialogProps { export interface RebootDialogProps {
isOpen: boolean; open: boolean;
close: () => void; onOpenChange: (open: boolean) => void;
} }
export const RebootDialog = ({ export const RebootDialog = ({
isOpen, open,
close onOpenChange
}: RebootDialogProps): JSX.Element => { }: RebootDialogProps): JSX.Element => {
const { connection, setDialogOpen } = useDevice(); const { connection, setDialogOpen } = useDevice();
const [time, setTime] = useState<number>(5); const [time, setTime] = useState<number>(5);
return ( return (
<Dialog <Dialog open={open} onOpenChange={onOpenChange}>
title={"Schedule Reboot"} <DialogContent>
description={"Reboot the connected node after x minutes."} <DialogHeader>
isOpen={isOpen} <DialogTitle>Schedule Reboot</DialogTitle>
close={close} <DialogDescription>
> Reboot the connected node after x minutes.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4"> <div className="flex gap-2 p-4">
<Input <Input
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(parseInt(e.target.value))} onChange={(e) => setTime(parseInt(e.target.value))}
action={{ action={{
icon: <ClockIcon className="w-4" />, icon: <ClockIcon size={16} />,
action() { action() {
connection connection
?.reboot(time * 60) ?.reboot(time * 60)
@ -45,12 +54,11 @@ export const RebootDialog = ({
connection?.reboot(2).then(() => setDialogOpen("reboot", false)); connection?.reboot(2).then(() => setDialogOpen("reboot", false));
}} }}
> >
<span> <RefreshCwIcon size={16} />
<ArrowPathIcon className="w-4" />
</span>
Now Now
</Button> </Button>
</div> </div>
</DialogContent>
</Dialog> </Dialog>
); );
}; };

40
src/components/Dialog/ShutdownDialog.tsx

@ -1,37 +1,46 @@
import { useState } from "react"; import { useState } from "react";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Dialog } from "@components/generic/Dialog.js"; import {
import { ClockIcon, PowerIcon } from "@heroicons/react/24/outline"; Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@components/UI/Dialog.js";
import { ClockIcon, PowerIcon } from "lucide-react";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/form/Button.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
export interface ShutdownDialogProps { export interface ShutdownDialogProps {
isOpen: boolean; open: boolean;
close: () => void; onOpenChange: (open: boolean) => void;
} }
export const ShutdownDialog = ({ export const ShutdownDialog = ({
isOpen, open,
close onOpenChange
}: ShutdownDialogProps): JSX.Element => { }: ShutdownDialogProps): JSX.Element => {
const { connection, setDialogOpen } = useDevice(); const { connection, setDialogOpen } = useDevice();
const [time, setTime] = useState<number>(5); const [time, setTime] = useState<number>(5);
return ( return (
<Dialog <Dialog open={open} onOpenChange={onOpenChange}>
title={"Schedule Shutdown"} <DialogContent>
description={"Turn off the connected node after x minutes."} <DialogHeader>
isOpen={isOpen} <DialogTitle>Schedule Shutdown</DialogTitle>
close={close} <DialogDescription>
> Turn off the connected node after x minutes.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4"> <div className="flex gap-2 p-4">
<Input <Input
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(parseInt(e.target.value))} onChange={(e) => setTime(parseInt(e.target.value))}
action={{ action={{
icon: <ClockIcon className="w-4" />, icon: <ClockIcon size={16} />,
action() { action() {
connection connection
?.shutdown(time * 60) ?.shutdown(time * 60)
@ -47,10 +56,11 @@ export const ShutdownDialog = ({
.then(() => setDialogOpen("shutdown", false)); .then(() => setDialogOpen("shutdown", false));
}} }}
> >
<PowerIcon className="w-4" /> <PowerIcon size={16} />
<span>Now</span> <span>Now</span>
</Button> </Button>
</div> </div>
</DialogContent>
</Dialog> </Dialog>
); );
}; };

134
src/components/Drawer/Metrics.tsx

@ -1,134 +0,0 @@
import "chartjs-adapter-date-fns";
import {
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
TimeSeriesScale,
Tooltip
} from "chart.js";
import { Line } from "react-chartjs-2";
import { useDevice } from "@core/providers/useDevice.js";
export const Metrics = (): JSX.Element => {
const { nodes, hardware } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
ChartJS.register(
LinearScale,
PointElement,
LineElement,
Tooltip,
Filler,
Legend,
TimeSeriesScale
);
return (
<div className="flex h-full w-full flex-grow">
<Line
className="h-full w-full flex-grow"
options={{
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false
},
line: {
datasets: {
tension: 0.5
}
},
scales: {
x: {
type: "timeseries",
ticks: {
display: false
}
},
y: {
ticks: {
display: false
}
},
y1: {
display: false
},
y2: {
display: false
},
y3: {
display: false
}
},
plugins: {}
}}
data={{
labels: [],
datasets: [
{
fill: true,
label: "airUtilTx",
yAxisID: "y",
data: myNode?.deviceMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.airUtilTx
};
}),
backgroundColor: "rgba(102, 126, 234, 0.25)",
borderColor: "rgba(102, 126, 234, 1)",
pointBackgroundColor: "rgba(102, 126, 234, 1)"
},
{
fill: true,
label: "channelUtilization",
yAxisID: "y1",
data: myNode?.deviceMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.channelUtilization
};
}),
backgroundColor: "rgba(237, 100, 166, 0.25)",
borderColor: "rgba(237, 100, 166, 1)",
pointBackgroundColor: "rgba(237, 100, 166, 1)"
},
{
fill: true,
label: "batteryLevel",
yAxisID: "y2",
data: myNode?.deviceMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.batteryLevel
};
}),
backgroundColor: "rgba(113, 234, 102, 0.25)",
borderColor: "rgba(113, 234, 102, 1)",
pointBackgroundColor: "rgba(113, 234, 102, 1)"
},
{
fill: true,
label: "voltage",
yAxisID: "y3",
data: myNode?.deviceMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.voltage
};
}),
backgroundColor: "rgba(234, 166, 102, 0.25)",
borderColor: "rgba(234, 166, 102, 1)",
pointBackgroundColor: "rgba(234, 166, 102, 1)"
}
]
}}
/>
</div>
);
};

3
src/components/Drawer/Notifications.tsx

@ -1,3 +0,0 @@
export const Notifications = (): JSX.Element => {
return <div></div>;
};

170
src/components/Drawer/Sensor.tsx

@ -1,170 +0,0 @@
import "chartjs-adapter-date-fns";
import {
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
TimeSeriesScale,
Tooltip
} from "chart.js";
import { Line } from "react-chartjs-2";
import { useDevice } from "@core/providers/useDevice.js";
export const Sensor = (): JSX.Element => {
const { nodes, hardware } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
ChartJS.register(
LinearScale,
PointElement,
LineElement,
Tooltip,
Filler,
Legend,
TimeSeriesScale
);
return (
<div className="flex h-full w-full flex-grow">
<Line
className="h-full w-full flex-grow"
options={{
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false
},
line: {
datasets: {
tension: 0.5
}
},
scales: {
x: {
type: "timeseries",
ticks: {
display: false
}
},
y: {
ticks: {
display: false
}
},
y1: {
display: false
},
y2: {
display: false
},
y3: {
display: false
},
y4: {
display: false
},
y5: {
display: false
}
},
plugins: {}
}}
data={{
labels: [],
datasets: [
{
fill: true,
label: "barometricPressure",
yAxisID: "y",
data: myNode?.environmentMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.barometricPressure
};
}),
backgroundColor: "rgba(102, 126, 234, 0.25)",
borderColor: "rgba(102, 126, 234, 1)",
pointBackgroundColor: "rgba(102, 126, 234, 1)"
},
{
fill: true,
label: "current",
yAxisID: "y1",
data: myNode?.environmentMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.current
};
}),
backgroundColor: "rgba(237, 100, 166, 0.25)",
borderColor: "rgba(237, 100, 166, 1)",
pointBackgroundColor: "rgba(237, 100, 166, 1)"
},
{
fill: true,
label: "gasResistance",
yAxisID: "y2",
data: myNode?.environmentMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.gasResistance
};
}),
backgroundColor: "rgba(113, 234, 102, 0.25)",
borderColor: "rgba(113, 234, 102, 1)",
pointBackgroundColor: "rgba(113, 234, 102, 1)"
},
{
fill: true,
label: "relativeHumidity",
yAxisID: "y3",
data: myNode?.environmentMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.relativeHumidity
};
}),
backgroundColor: "rgba(234, 166, 102, 0.25)",
borderColor: "rgba(234, 166, 102, 1)",
pointBackgroundColor: "rgba(234, 166, 102, 1)"
},
{
fill: true,
label: "temperature",
yAxisID: "y4",
data: myNode?.environmentMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.temperature
};
}),
backgroundColor: "rgba(38, 255, 212, 0.25)",
borderColor: "rgba(38, 255, 212, 1)",
pointBackgroundColor: "rgba(38, 255, 212, 1)"
},
{
fill: true,
label: "voltage",
yAxisID: "y5",
data: myNode?.environmentMetrics.map((metric) => {
return {
x: metric.timestamp,
y: metric.metric.voltage
};
}),
backgroundColor: "rgba(247, 255, 15, 0.25)",
borderColor: "rgba(247, 255, 15, 1)",
pointBackgroundColor: "rgba(247, 255, 15, 1)"
}
]
}}
/>
</div>
);
};

66
src/components/Drawer/index.tsx

@ -1,66 +0,0 @@
import { useState } from "react";
import { Metrics } from "@components/Drawer/Metrics.js";
import { Notifications } from "@components/Drawer/Notifications.js";
import { Sensor } from "@components/Drawer/Sensor.js";
import type { TabType } from "@components/generic/TabbedContent.js";
import { Tab } from "@headlessui/react";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline";
export const Drawer = (): JSX.Element => {
const [drawerOpen, setDrawerOpen] = useState(false);
const tabs: TabType[] = [
{ label: "Notifications", element: Notifications },
{ label: "Metrics", element: Metrics },
{ label: "Sensor", element: Sensor }
];
return (
<Tab.Group as="div">
<Tab.List className="flex w-full">
{tabs.map((tab, index) => (
<Tab key={index}>
{({ selected }) => (
<div
onClick={() => {
setDrawerOpen(true);
}}
className={`flex h-full cursor-pointer border-b-4 px-1 first:pl-2 last:pr-2 hover:text-textPrimary ${
selected
? "border-accent text-textPrimary"
: "border-backgroundPrimary text-textSecondary"
}`}
>
<span className="m-auto select-none">{tab.label}</span>
</div>
)}
</Tab>
))}
<div className="ml-auto flex h-8">
<div
onClick={() => {
setDrawerOpen(!drawerOpen);
}}
className="flex cursor-pointer px-2"
>
<div className="m-auto text-textSecondary">
{drawerOpen ? (
<ChevronDownIcon className="h-4" />
) : (
<ChevronUpIcon className="h-4" />
)}
</div>
</div>
</div>
</Tab.List>
<Tab.Panels className={`${drawerOpen ? "flex" : "hidden"}`}>
{tabs.map((tab, index) => (
<Tab.Panel key={index} className="flex h-40 flex-grow">
{tab.element}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

57
src/components/Menu.tsx

@ -0,0 +1,57 @@
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarTrigger
} from "@app/components/UI/Menubar.js";
import { useAppStore } from "@app/core/stores/appStore.js";
export const Menu = (): JSX.Element => {
const { darkMode } = useAppStore();
return (
<Menubar className="rounded-none dark:bg-slate-900">
<MenubarMenu>
<MenubarTrigger>
<img
src={darkMode ? "Logo_White.svg" : "Logo_Black.svg"}
className="h-4"
/>
</MenubarTrigger>
<MenubarContent>
<MenubarItem>About Music</MenubarItem>
<MenubarSeparator />
<MenubarItem>
Preferences... <MenubarShortcut>,</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem>
Hide Music... <MenubarShortcut>H</MenubarShortcut>
</MenubarItem>
<MenubarItem>
Hide Others... <MenubarShortcut>H</MenubarShortcut>
</MenubarItem>
<MenubarShortcut />
<MenubarItem>
Quit Music <MenubarShortcut>Q</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>
New Tab <MenubarShortcut>T</MenubarShortcut>
</MenubarItem>
<MenubarItem>New Window</MenubarItem>
<MenubarSeparator />
<MenubarItem>Share</MenubarItem>
<MenubarSeparator />
<MenubarItem>Print</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
);
};

66
src/components/NewDevice.tsx

@ -1,16 +1,18 @@
import React from "react";
import { TabbedContent, TabType } from "@components/generic/TabbedContent.js";
import { BLE } from "@components/PageComponents/Connect/BLE.js"; import { BLE } from "@components/PageComponents/Connect/BLE.js";
import { HTTP } from "@components/PageComponents/Connect/HTTP.js"; import { HTTP } from "@components/PageComponents/Connect/HTTP.js";
import { Serial } from "@components/PageComponents/Connect/Serial.js"; import { Serial } from "@components/PageComponents/Connect/Serial.js";
import { useAppStore } from "@core/stores/appStore.js"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./UI/Tabs.js";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; import { Subtle } from "./UI/Typography/Subtle.js";
import { Link } from "./UI/Typography/Link.js";
export const NewDevice = () => { export const NewDevice = () => {
const { darkMode, setDarkMode } = useAppStore(); const tabs = [
{
const tabs: TabType[] = [ label: "HTTP",
element: HTTP,
disabled: false,
disabledMessage: "Unsuported connection method"
},
{ {
label: "Bluetooth", label: "Bluetooth",
element: BLE, element: BLE,
@ -20,12 +22,6 @@ export const NewDevice = () => {
disabledLink: disabledLink:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility" "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility"
}, },
{
label: "HTTP",
element: HTTP,
disabled: false,
disabledMessage: "Unsuported connection method"
},
{ {
label: "Serial", label: "Serial",
element: Serial, element: Serial,
@ -36,8 +32,46 @@ export const NewDevice = () => {
]; ];
return ( return (
<div className="m-auto h-96 w-96"> <div className="m-auto">
<TabbedContent tabs={tabs} /> <Tabs defaultValue="HTTP" className="w-[400px]">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger value={tab.label} disabled={tab.disabled}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent value={tab.label}>
{tab.disabled ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{tab.disabledMessage}
</p>
) : (
<tab.element />
)}
</TabsContent>
))}
</Tabs>
{(!navigator.bluetooth || !navigator.serial) && (
<>
<Subtle>
Web Bluetooth and Web Serial are currently only supported by
Chromium-based browsers.
</Subtle>
<Subtle>
Read more:&nbsp;
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility">
Web Bluetooth
</Link>
&nbsp;
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility">
Web Serial
</Link>
</Subtle>
</>
)}
</div> </div>
); );
}; };

118
src/components/PageComponents/AppConfig/Map.tsx

@ -1,118 +0,0 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { Button } from "@components/form/Button.js";
import { IconButton } from "@components/form/IconButton.js";
import { InfoWrapper } from "@components/form/InfoWrapper.js";
import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js";
import { useAppStore } from "@core/stores/appStore.js";
import { MapValidation } from "@app/validation/appConfig/map.js";
import { Form } from "@components/form/Form";
import { TrashIcon } from "@heroicons/react/24/outline";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const Map = (): JSX.Element => {
const { rasterSources, setRasterSources } = useAppStore();
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
reset
} = useForm<MapValidation>({
defaultValues: {
// wmsSources: wmsSources ?? [
// {
// url: "",
// tileSize: 512,
// type: "raster"
// }
// ]
},
resolver: classValidatorResolver(MapValidation)
});
const { fields, append, remove, insert } = useFieldArray({
control,
name: "rasterSources"
});
const onSubmit = handleSubmit((data) => {
setRasterSources(data.rasterSources);
});
// useEffect(() => {
// reset(rasterSources);
// }, [reset, rasterSources]);
return (
<Form onSubmit={onSubmit}>
<InfoWrapper label="WMS Sources">
<div className="flex flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex w-full gap-2">
<Controller
name={`rasterSources.${index}.enabled`}
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle checked={value} {...rest} />
)}
/>
<Input
placeholder="Name"
error={
errors.rasterSources
? errors.rasterSources[index]?.title?.message
: undefined
}
{...register(`rasterSources.${index}.title`)}
/>
<Input
placeholder="Tile Size"
type="number"
error={
errors.rasterSources
? errors.rasterSources[index]?.tileSize?.message
: undefined
}
{...register(`rasterSources.${index}.tileSize`, {
valueAsNumber: true
})}
/>
<Input
placeholder="URL"
error={
errors.rasterSources
? errors.rasterSources[index]?.tiles?.message
: undefined
}
{...register(`rasterSources.${index}.tiles`)}
/>
<IconButton
className="shrink-0"
icon={<TrashIcon className="w-4" />}
onClick={() => {
remove(index);
}}
/>
</div>
))}
<Button
onClick={() => {
append({
enabled: true,
title: "",
tiles: [
"https://img.nj.gov/imagerywms/Natural2015?bbox={bbox-epsg-3857}&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:3857&transparent=true&width=256&height=256&layers=Natural2015"
],
tileSize: 512
});
}}
>
New Source
</Button>
</div>
</InfoWrapper>
</Form>
);
};

36
src/components/PageComponents/Channel.tsx

@ -1,21 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { ChannelSettingsValidation } from "@app/validation/channelSettings.js"; import { ChannelSettingsValidation } from "@app/validation/channelSettings.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js"; import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { import { RefreshCwIcon, EyeIcon, EyeOffIcon } from "lucide-react";
ArrowPathIcon,
EyeIcon,
EyeSlashIcon
} from "@heroicons/react/24/outline";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { NavBar } from "@app/Nav/NavBar.js";
export interface SettingsPanelProps { export interface SettingsPanelProps {
channel: Protobuf.Channel; channel: Protobuf.Channel;
@ -61,10 +55,8 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}, [channel, reset]); }, [channel, reset]);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) {
void toast.promise(
connection connection
.setChannel( ?.setChannel(
new Protobuf.Channel({ new Protobuf.Channel({
role: role:
channel?.role === Protobuf.Channel_Role.PRIMARY channel?.role === Protobuf.Channel_Role.PRIMARY
@ -92,29 +84,19 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
lastInterraction: new Date(), lastInterraction: new Date(),
messages: [] messages: []
}) })
),
{
loading: "Saving...",
success: "Saved Channel",
error: "No response received"
}
); );
}
}); });
return ( return (
<div className="flex flex-grow flex-col gap-2"> <div className="flex flex-grow flex-col gap-2">
<NavBar {/* actions={[
breadcrumb={["Channels", channel?.index.toString()]}
actions={[
{ {
label: "Apply", label: "Apply",
async onClick() { async onClick() {
await onSubmit(); await onSubmit();
} }
} }
]} ]} */}
/>
<Form onSubmit={onSubmit}> <Form onSubmit={onSubmit}>
{channel?.index !== 0 && ( {channel?.index !== 0 && (
@ -147,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
setKeySize(parseInt(e.target.value) as 128 | 256); setKeySize(parseInt(e.target.value) as 128 | 256);
}} }}
action={{ action={{
icon: <ArrowPathIcon className="h-4" />, icon: <RefreshCwIcon size={16} />,
action: () => { action: () => {
const key = new Uint8Array(keySize / 8); const key = new Uint8Array(keySize / 8);
crypto.getRandomValues(key); crypto.getRandomValues(key);
@ -166,11 +148,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
description="Channel key to encrypt data" description="Channel key to encrypt data"
type={pskHidden ? "password" : "text"} type={pskHidden ? "password" : "text"}
action={{ action={{
icon: pskHidden ? ( icon: pskHidden ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />,
<EyeIcon className="w-4" />
) : (
<EyeSlashIcon className="w-4" />
),
action: () => { action: () => {
setPskHidden(!pskHidden); setPskHidden(!pskHidden);
} }

2
src/components/PageComponents/Config/Bluetooth.tsx

@ -5,7 +5,7 @@ import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { BluetoothValidation } from "@app/validation/config/bluetooth.js"; import { BluetoothValidation } from "@app/validation/config/bluetooth.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/Config/Device.tsx

@ -5,7 +5,7 @@ import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { DeviceValidation } from "@app/validation/config/device.js"; import { DeviceValidation } from "@app/validation/config/device.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/Config/Display.tsx

@ -5,7 +5,7 @@ import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { DisplayValidation } from "@app/validation/config/display.js"; import { DisplayValidation } from "@app/validation/config/display.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/Config/LoRa.tsx

@ -6,7 +6,7 @@ import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { LoRaValidation } from "@app/validation/config/lora.js"; import { LoRaValidation } from "@app/validation/config/lora.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/Config/Network.tsx

@ -8,7 +8,7 @@ import { Toggle } from "@components/form/Toggle.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { NetworkValidation } from "@app/validation/config/network.js"; import { NetworkValidation } from "@app/validation/config/network.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { ErrorMessage } from "@hookform/error-message"; import { ErrorMessage } from "@hookform/error-message";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

4
src/components/PageComponents/Config/Position.tsx

@ -6,7 +6,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { PositionValidation } from "@app/validation/config/position.js"; import { PositionValidation } from "@app/validation/config/position.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
@ -45,6 +45,8 @@ export const Position = (): JSX.Element => {
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
const { fixedAlt, fixedLat, fixedLng, ...rest } = data; const { fixedAlt, fixedLat, fixedLng, ...rest } = data;
console.log("detected change");
const configHasChanged = !Protobuf.Config_PositionConfig.equals( const configHasChanged = !Protobuf.Config_PositionConfig.equals(
config.position, config.position,
new Protobuf.Config_PositionConfig(rest) new Protobuf.Config_PositionConfig(rest)

2
src/components/PageComponents/Config/Power.tsx

@ -5,7 +5,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { PowerValidation } from "@app/validation/config/power.js"; import { PowerValidation } from "@app/validation/config/power.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

105
src/components/PageComponents/Config/User.tsx

@ -1,105 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { base16 } from "rfc4648";
import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js";
import { UserValidation } from "@app/validation/config/user.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const User = (): JSX.Element => {
const { hardware, nodes, connection } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
const { register, handleSubmit, reset, control } = useForm<UserValidation>({
defaultValues: myNode?.data.user,
resolver: classValidatorResolver(UserValidation)
});
useEffect(() => {
reset({
longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName,
isLicensed: myNode?.data.user?.isLicensed
});
}, [reset, myNode]);
const onSubmit = handleSubmit((data) => {
if (connection && myNode?.data.user) {
void toast.promise(
connection
.setOwner(
new Protobuf.User({
...myNode.data.user,
...data
})
)
.then(() => reset({ ...data })),
{
loading: "Saving...",
success: "Saved User, Restarting Node",
error: "No response received"
}
);
}
});
return (
<Form onSubmit={onSubmit}>
<Input
label="Device Name"
description="Personalised name for this device."
{...register("longName")}
/>
<Input
label="Short Name"
description="Shown on small screens."
maxLength={4}
{...register("shortName")}
/>
<Controller
name="isLicensed"
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Licenced Operator?"
description="Remove bandwidth restrictions in certain regions (HAM license required)"
checked={value}
{...rest}
/>
)}
/>
<Input
label="Mac Address"
description="Hardware address for this node."
disabled
value={
base16
.stringify(myNode?.data.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? ""
}
/>
<Input
label="Device ID"
disabled
description="Preset unique identifier for this device."
value={myNode?.data.user?.id}
/>
<Select
label="Hardware"
description="Hardware model of this device."
disabled
value={myNode?.data.user?.hwModel}
>
{renderOptions(Protobuf.HardwareModel)}
</Select>
</Form>
);
};

2
src/components/PageComponents/Connect/BLE.tsx

@ -5,7 +5,6 @@ import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js"; import { randId } from "@core/utils/randId.js";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { Constants, IBLEConnection } from "@meshtastic/meshtasticjs"; import { Constants, IBLEConnection } from "@meshtastic/meshtasticjs";
export const BLE = (): JSX.Element => { export const BLE = (): JSX.Element => {
@ -64,7 +63,6 @@ export const BLE = (): JSX.Element => {
}); });
}} }}
> >
<PlusCircleIcon className="w-4" />
<span>New device</span> <span>New device</span>
</Button> </Button>
</div> </div>

2
src/components/PageComponents/Connect/HTTP.tsx

@ -6,7 +6,6 @@ import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js"; import { randId } from "@core/utils/randId.js";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { IHTTPConnection } from "@meshtastic/meshtasticjs"; import { IHTTPConnection } from "@meshtastic/meshtasticjs";
export const HTTP = (): JSX.Element => { export const HTTP = (): JSX.Element => {
@ -72,7 +71,6 @@ export const HTTP = (): JSX.Element => {
/> />
</div> </div>
<Button type="submit"> <Button type="submit">
<PlusCircleIcon className="w-4" />
<span>Connect</span> <span>Connect</span>
</Button> </Button>
</form> </form>

2
src/components/PageComponents/Connect/Serial.tsx

@ -5,7 +5,6 @@ import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js"; import { randId } from "@core/utils/randId.js";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { ISerialConnection } from "@meshtastic/meshtasticjs"; import { ISerialConnection } from "@meshtastic/meshtasticjs";
export const Serial = (): JSX.Element => { export const Serial = (): JSX.Element => {
@ -70,7 +69,6 @@ export const Serial = (): JSX.Element => {
}); });
}} }}
> >
<PlusCircleIcon className="w-4" />
<span>New device</span> <span>New device</span>
</Button> </Button>
</div> </div>

77
src/components/PageComponents/Map/MapControlls.tsx

@ -1,77 +0,0 @@
import { useEffect } from "react";
import { useMap } from "react-map-gl";
import { useDevice } from "@core/providers/useDevice.js";
import {
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
ShareIcon
} from "@heroicons/react/24/outline";
import { bbox, lineString } from "@turf/turf";
export const MapControlls = (): JSX.Element => {
const { current: map } = useMap();
const { nodes } = useDevice();
const getBBox = () => {
const nodesWithPosition = nodes.filter((n) => n.data.position?.latitudeI);
if (!nodesWithPosition.length) return;
const line = lineString(
nodesWithPosition.map((n) => [
(n.data.position?.latitudeI ?? 0) / 1e7,
(n.data.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);
else if (nodesWithPosition.length === 1)
map?.easeTo({
zoom: 12,
center: [
(nodesWithPosition[0].data.position?.longitudeI ?? 0) / 1e7,
(nodesWithPosition[0].data.position?.latitudeI ?? 0) / 1e7
]
});
};
useEffect(() => {
getBBox();
}, []);
return (
<div className="absolute right-0 top-0 z-10 m-2 ">
<div className="divide-y-2 divide-backgroundSecondary overflow-hidden rounded-md bg-backgroundPrimary text-textSecondary">
<div
className="hover:bg-orange-200 cursor-pointer p-3 hover:text-accent"
onClick={() => {
map?.zoomIn();
}}
>
<MagnifyingGlassPlusIcon className="h-4 w-4" />
</div>
<div
className="hover:bg-orange-200 cursor-pointer p-3 hover:text-accent"
onClick={() => {
map?.zoomOut();
}}
>
<MagnifyingGlassMinusIcon className="h-4 w-4" />
</div>
<div
className="hover:bg-orange-200 cursor-pointer p-3 hover:text-accent"
onClick={() => getBBox()}
>
<ShareIcon className="h-4 w-4" />
</div>
</div>
</div>
);
};

2
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,6 +1,6 @@
import { Message } from "@components/PageComponents/Messages/Message.js"; import { Message } from "@components/PageComponents/Messages/Message.js";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import type { Channel } from "@core/stores/deviceStore.js"; import type { Channel } from "@core/stores/deviceStore.js";
export interface ChannelChatProps { export interface ChannelChatProps {

25
src/components/PageComponents/Messages/Message.tsx

@ -1,12 +1,12 @@
import { WaypointMessage } from "@components/PageComponents/Messages/WaypointMessage.js"; import { WaypointMessage } from "@components/PageComponents/Messages/WaypointMessage.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import type { AllMessageTypes } from "@core/stores/deviceStore.js"; import type { AllMessageTypes } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { import {
CheckCircleIcon, CircleEllipsisIcon,
EllipsisHorizontalCircleIcon, AlertCircleIcon,
ExclamationCircleIcon CheckCircle2Icon
} from "@heroicons/react/24/outline"; } from "lucide-react";
import type { Protobuf } from "@meshtastic/meshtasticjs"; import type { Protobuf } from "@meshtastic/meshtasticjs";
export interface MessageProps { export interface MessageProps {
@ -30,11 +30,11 @@ export const Message = ({
return lastMsgSameUser ? ( return lastMsgSameUser ? (
<div className="ml-5 flex"> <div className="ml-5 flex">
{message.state === "ack" ? ( {message.state === "ack" ? (
<CheckCircleIcon className="my-auto h-4 text-textSecondary" /> <CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
) : message.state === "waiting" ? ( ) : message.state === "waiting" ? (
<EllipsisHorizontalCircleIcon className="my-auto h-4 text-textSecondary" /> <CircleEllipsisIcon size={16} className="my-auto text-textSecondary" />
) : ( ) : (
<ExclamationCircleIcon className="my-auto h-4 text-textSecondary" /> <AlertCircleIcon size={16} className="my-auto text-textSecondary" />
)} )}
{"waypointID" in message ? ( {"waypointID" in message ? (
<WaypointMessage waypointID={message.waypointID} /> <WaypointMessage waypointID={message.waypointID} />
@ -69,11 +69,14 @@ export const Message = ({
</div> </div>
<div className="ml-1 flex"> <div className="ml-1 flex">
{message.state === "ack" ? ( {message.state === "ack" ? (
<CheckCircleIcon className="my-auto h-4 text-textSecondary" /> <CheckCircle2Icon size={16} className="my-auto text-textSecondary" />
) : message.state === "waiting" ? ( ) : message.state === "waiting" ? (
<EllipsisHorizontalCircleIcon className="my-auto h-4 text-textSecondary" /> <CircleEllipsisIcon
size={16}
className="my-auto text-textSecondary"
/>
) : ( ) : (
<ExclamationCircleIcon className="my-auto h-4 text-textSecondary" /> <AlertCircleIcon size={16} className="my-auto text-textSecondary" />
)} )}
{"waypointID" in message ? ( {"waypointID" in message ? (
<WaypointMessage waypointID={message.waypointID} /> <WaypointMessage waypointID={message.waypointID} />

13
src/components/PageComponents/Messages/MessageInput.tsx

@ -1,9 +1,9 @@
import { IconButton } from "@components/form/IconButton.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import type { Channel } from "@core/stores/deviceStore.js"; import type { Channel } from "@core/stores/deviceStore.js";
import { MapPinIcon, PaperAirplaneIcon } from "@heroicons/react/24/outline"; import { SendIcon } from "lucide-react";
import type { Types } from "@meshtastic/meshtasticjs"; import type { Types } from "@meshtastic/meshtasticjs";
import { Button } from "@app/components/UI/Button.js";
export interface MessageInputProps { export interface MessageInputProps {
channel: Channel; channel: Channel;
@ -48,12 +48,11 @@ export const MessageInput = ({ channel }: MessageInputProps): JSX.Element => {
onChange={(e) => setMessageDraft(e.target.value)} onChange={(e) => setMessageDraft(e.target.value)}
/> />
</span> </span>
<IconButton <Button variant="subtle">
icon={<PaperAirplaneIcon className="text-slate-500 h-4" />} <SendIcon size={16} className="text-slate-500" />
/> </Button>
</div> </div>
</form> </form>
<IconButton icon={<MapPinIcon className="text-slate-500 h-4" />} />
</div> </div>
); );
}; };

2
src/components/PageComponents/Messages/NewLocationMessage.tsx

@ -1,7 +1,7 @@
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js"; import { Select } from "@components/form/Select.js";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/form/Button.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

10
src/components/PageComponents/Messages/WaypointMessage.tsx

@ -1,6 +1,6 @@
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { toMGRS } from "@core/utils/toMGRS.js"; import { toMGRS } from "@core/utils/toMGRS.js";
import { MapPinIcon } from "@heroicons/react/24/outline"; import { MapPinIcon } from "lucide-react";
export interface WaypointMessageProps { export interface WaypointMessageProps {
waypointID: number; waypointID: number;
@ -13,13 +13,13 @@ export const WaypointMessage = ({
const waypoint = waypoints.find((wp) => wp.id === waypointID); const waypoint = waypoints.find((wp) => wp.id === waypointID);
return ( return (
<div className="border-l-slate-200 ml-4 border-l-2 pl-2"> <div className="ml-4 border-l-2 border-l-slate-200 pl-2">
<div className="flex gap-2 rounded-md p-2"> <div className="flex gap-2 rounded-md p-2">
<MapPinIcon className="text-slate-600 m-auto w-6" /> <MapPinIcon size={16} className="m-auto text-slate-600" />
<div> <div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="font-bold">{waypoint?.name}</div> <div className="font-bold">{waypoint?.name}</div>
<span className="text-slate-500 font-mono text-sm"> <span className="font-mono text-sm text-slate-500">
{toMGRS(waypoint?.latitudeI, waypoint?.longitudeI)} {toMGRS(waypoint?.latitudeI, waypoint?.longitudeI)}
</span> </span>
</div> </div>

2
src/components/PageComponents/ModuleConfig/Audio.tsx

@ -5,7 +5,7 @@ import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { AudioValidation } from "@app/validation/moduleConfig/audio.js"; import { AudioValidation } from "@app/validation/moduleConfig/audio.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/ModuleConfig/CannedMessage.tsx

@ -5,7 +5,7 @@ import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js"; import { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -4,7 +4,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js"; import { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -4,7 +4,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js"; import { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/ModuleConfig/RangeTest.tsx

@ -4,7 +4,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js"; import { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/ModuleConfig/Serial.tsx

@ -4,7 +4,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { SerialValidation } from "@app/validation/moduleConfig/serial.js"; import { SerialValidation } from "@app/validation/moduleConfig/serial.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { renderOptions } from "@core/utils/selectEnumOptions"; import { renderOptions } from "@core/utils/selectEnumOptions";

2
src/components/PageComponents/ModuleConfig/StoreForward.tsx

@ -4,7 +4,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js"; import { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

2
src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -4,7 +4,7 @@ import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js"; import { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";

139
src/components/Sidebar.tsx

@ -1,69 +1,92 @@
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { toMGRS } from "@core/utils/toMGRS.js"; import type { Page } from "@core/stores/deviceStore.js";
import { BatteryWidget } from "@components/Widgets/BatteryWidget.js"; import {
import { DeviceWidget } from "@components/Widgets/DeviceWidget.js"; LucideIcon,
import { PeersWidget } from "@components/Widgets/PeersWidget.js"; MapIcon,
import { PositionWidget } from "@components/Widgets/PositionWidget.js"; MessageSquareIcon,
import { useAppStore } from "@core/stores/appStore.js"; SettingsIcon,
import { useDeviceStore } from "@core/stores/deviceStore.js"; LayersIcon,
import { CommandLineIcon } from "@heroicons/react/24/outline"; UsersIcon,
import { Types } from "@meshtastic/meshtasticjs"; EditIcon
import { Input } from "@components/form/Input.js"; } from "lucide-react";
import { Subtle } from "./UI/Typography/Subtle.js";
import { SidebarItem } from "./UI/Sidebar/SidebarItem.js";
export const Sidebar = (): JSX.Element => { export interface SidebarProps {
const { removeDevice } = useDeviceStore(); children?: React.ReactNode;
const { connection, hardware, nodes, status, currentMetrics } = useDevice(); }
const { selectedDevice, setSelectedDevice, setCommandPaletteOpen } =
useAppStore(); export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum); const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
const { activePage, setActivePage, setDialogOpen } = useDevice();
return ( interface NavLink {
<div className="bg-slate-50 relative flex w-72 flex-shrink-0 flex-col gap-2 p-2"> name: string;
<DeviceWidget icon: LucideIcon;
name={ page: Page;
nodes.find((n) => n.data.num === hardware.myNodeNum)?.data.user
?.longName ?? "UNK"
} }
nodeNum={hardware.myNodeNum.toString()}
disconnected={status === Types.DeviceStatusEnum.DEVICE_DISCONNECTED}
disconnect={() => {
void connection?.disconnect();
setSelectedDevice(0);
removeDevice(selectedDevice ?? 0);
}}
reconnect={() => {
void connection?.disconnect();
}}
/>
<div className="flex flex-grow flex-col gap-3"> const pages: NavLink[] = [
<BatteryWidget {
batteryLevel={currentMetrics.batteryLevel} name: "Messages",
voltage={currentMetrics.voltage} icon: MessageSquareIcon,
/> page: "messages"
<PeersWidget },
peers={nodes {
.map((n) => n.data) name: "Map",
.filter((n) => n.num !== hardware.myNodeNum)} icon: MapIcon,
/> page: "map"
<PositionWidget },
grid={toMGRS( {
myNode?.data.position?.latitudeI, name: "Config",
myNode?.data.position?.longitudeI icon: SettingsIcon,
)} page: "config"
/> },
<div className="mt-auto"> {
<Input name: "Channels",
placeholder={"Search for a command"} icon: LayersIcon,
onClick={() => setCommandPaletteOpen(true)} page: "channels"
action={{ },
icon: <CommandLineIcon className="w-4" />, {
action() { name: "Peers",
setCommandPaletteOpen(true); icon: UsersIcon,
page: "peers"
} }
];
return (
<div className="min-w-[220px] flex-col border-r-[0.5px] bg-transparent">
<div className="flex h-16 flex-col justify-center px-4">
<div className="flex items-center justify-between">
<div>
<span className="text-lg font-medium">
{myNode?.data.user?.shortName ?? "UNK"}
</span>
<Subtle>{myNode?.data.user?.longName ?? "UNK"}</Subtle>
</div>
<button
className="transition-all hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<EditIcon size={16} />
</button>
</div>
</div>
<div className="mx-2.5 space-y-2">
{pages.map((link) => (
<SidebarItem
key={link.page}
label={link.name}
icon={link.icon}
active={link.page === activePage}
onClick={() => {
setActivePage(link.page);
}} }}
/> />
</div> ))}
{children}
</div> </div>
</div> </div>
); );

44
src/components/Topbar.tsx

@ -0,0 +1,44 @@
import { LucideIcon, AlignLeftIcon } from "lucide-react";
export interface PageLayoutProps {
title: string;
children: React.ReactNode;
actions?: {
icon: LucideIcon;
onClick: () => void;
}[];
}
export const PageLayout = ({
title,
actions,
children
}: PageLayoutProps): JSX.Element => {
return (
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] md:h-16 md:px-4">
<button className="pl-4 transition-all hover:text-accent md:hidden">
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="text-palette-800 w-full text-lg font-medium">
{title}
</span>
<div className="flex justify-end space-x-4">
{actions?.map((action) => (
<button
className="transition-all hover:text-accent"
onClick={() => action.onClick()}
>
<action.icon />
</button>
))}
</div>
</div>
</div>
</div>
{children}
</div>
);
};

53
src/components/UI/Button.tsx

@ -0,0 +1,53 @@
import * as React from "react";
import { VariantProps, cva } from "class-variance-authority";
import { cn } from "@core/utils/cn.js";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800",
{
variants: {
variant: {
default:
"bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900",
destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
subtle:
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
ghost:
"bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent"
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md",
lg: "h-11 px-8 rounded-md"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

128
src/components/UI/Dialog.tsx

@ -0,0 +1,128 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@core/utils/cn.js";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({
className,
children,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity animate-in fade-in",
className
)}
{...props}
ref={ref}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full 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
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-slate-900",
"dark:text-slate-50",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500", "dark:text-slate-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
};

21
src/components/UI/Label.tsx

@ -0,0 +1,21 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@core/utils/cn.js";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

234
src/components/UI/Menubar.tsx

@ -0,0 +1,234 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@core/utils/cn.js";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border border-slate-300 bg-white p-1 dark:border-slate-700 dark:bg-slate-800",
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-[0.2rem] py-1.5 px-3 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-700 dark:data-[state=open]:bg-slate-700",
className
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-700 dark:data-[state=open]:bg-slate-700",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white p-1 shadow-md animate-in slide-in-from-left-1 dark:border-slate-700 dark:bg-slate-800",
className
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-slate-100 bg-white p-1 text-slate-700 shadow-md animate-in slide-in-from-top-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-700",
inset && "pl-8",
className
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-700",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-700",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-slate-900 dark:text-slate-300",
inset && "pl-8",
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-700", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500",
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut
};

29
src/components/UI/Popover.tsx

@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@core/utils/cn.js";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-800",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

111
src/components/UI/Select.tsx

@ -0,0 +1,111 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@core/utils/cn.js";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"border-slate-300 placeholder:text-slate-400 focus:ring-slate-400 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 flex h-10 w-full items-center justify-between rounded-md border bg-transparent py-2 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"border-slate-100 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400 relative z-50 min-w-[8rem] overflow-hidden rounded-md border shadow-md animate-in fade-in-80",
className
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1">
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"text-slate-900 dark:text-slate-300 py-1.5 pr-2 pl-8 text-sm font-semibold",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"focus:bg-slate-100 dark:focus:bg-slate-700 relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm font-medium outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-slate-100 dark:bg-slate-700 -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator
};

29
src/components/UI/Seperator.tsx

@ -0,0 +1,29 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@core/utils/cn.js";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"bg-slate-200 dark:bg-slate-700",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

30
src/components/UI/Sidebar/SidebarItem.tsx

@ -0,0 +1,30 @@
import { cn } from "@app/core/utils/cn.js";
import type { LucideIcon } from "lucide-react";
export interface SidebarItemProps {
icon?: LucideIcon;
element?: JSX.Element;
onClick?: () => void;
active?: boolean;
label: string;
}
export const SidebarItem = ({
icon: Icon,
element,
onClick,
active,
label
}: SidebarItemProps): JSX.Element => (
<button
className={cn(
"text-palette-600 flex w-full cursor-pointer items-center space-x-2.5 rounded-xl bg-transparent px-2.5 py-2.5 transition-all duration-300 hover:text-accent",
active && "bg-backgroundPrimary text-accent"
)}
onClick={() => onClick && onClick()}
>
{element}
{Icon && <Icon size={16} />}
<span className="align-middle font-mono text-sm">{label}</span>
</button>
);

32
src/components/UI/Sidebar/SidebarSection.tsx

@ -0,0 +1,32 @@
import type { LucideIcon } from "lucide-react";
export interface SidebarSectionProps {
title: string;
action?: {
icon: LucideIcon;
onClick: () => void;
};
subheader?: string;
children: React.ReactNode;
}
export const SidebarSection = ({
title,
action,
children
}: SidebarSectionProps): JSX.Element => (
<div className="space-y-1.5">
<div className="mb-2 flex items-center justify-between px-2">
<div className="font-medium">{title}</div>
{action && (
<button
className="transition-all duration-300 hover:text-accent"
onClick={() => action.onClick}
>
<action.icon size={16} />
</button>
)}
</div>
<ul className="space-y-1">{children}</ul>
</div>
);

27
src/components/UI/Switch.tsx

@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@core/utils/cn.js";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-slate-900 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=unchecked]:bg-slate-700 dark:data-[state=checked]:bg-slate-400",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

53
src/components/UI/Tabs.tsx

@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@core/utils/cn.js";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-md bg-slate-100 p-1 dark:bg-slate-800",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
className={cn(
"inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-slate-700 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm dark:text-slate-200 dark:data-[state=active]:bg-slate-900 dark:data-[state=active]:text-slate-100",
className
)}
{...props}
ref={ref}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
className={cn(
"mt-2 rounded-md border border-slate-200 p-6 dark:border-slate-700",
className
)}
{...props}
ref={ref}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

128
src/components/UI/Toast.tsx

@ -0,0 +1,128 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@core/utils/cn.js";
import { buttonVariants } from "./Button.js";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed bottom-6 right-6 z-50 max-h-screen w-full space-y-3 md:max-w-[400px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"data-[swipe=move]:transition-none grow-1 group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full dark:border-slate-700",
{
variants: {
status: {
default:
"bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700",
destructive: "bg-red-500 text-white border-red-500"
}
},
defaultVariants: {
status: "default"
}
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, status, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ status }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
buttonVariants({ size: "sm", variant: "outline" }),
"h-8 shrink-0 px-3",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute top-2 right-2 rounded-md p-1 text-slate-500 opacity-0 transition-opacity hover:text-slate-900 focus:opacity-100 group-hover:opacity-100 dark:hover:text-slate-50",
className
)}
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction
};

29
src/components/UI/Tooltip.tsx

@ -0,0 +1,29 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@core/utils/cn.js";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = ({ ...props }) => <TooltipPrimitive.Root {...props} />;
Tooltip.displayName = TooltipPrimitive.Tooltip.displayName;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

9
src/components/UI/Typography/Blockquote.tsx

@ -0,0 +1,9 @@
export interface BlockquoteProps {
children: React.ReactNode;
}
export const BlockQuote = ({ children }: BlockquoteProps): JSX.Element => (
<blockquote className="mt-6 border-l-2 border-slate-300 pl-6 italic text-slate-800 dark:border-slate-600 dark:text-slate-200">
{children}
</blockquote>
);

9
src/components/UI/Typography/Code.tsx

@ -0,0 +1,9 @@
export interface CodeProps {
children: React.ReactNode;
}
export const Code = ({ children }: CodeProps): JSX.Element => (
<code className="relative rounded bg-slate-100 py-[0.2rem] px-[0.3rem] font-mono text-sm font-semibold text-slate-900 dark:bg-slate-800 dark:text-slate-400">
{children}
</code>
);

9
src/components/UI/Typography/H1.tsx

@ -0,0 +1,9 @@
export interface H1Props {
children: React.ReactNode;
}
export const H1 = ({ children }: H1Props): JSX.Element => (
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
{children}
</h1>
);

9
src/components/UI/Typography/H2.tsx

@ -0,0 +1,9 @@
export interface H2Props {
children: React.ReactNode;
}
export const H2 = ({ children }: H2Props): JSX.Element => (
<h2 className="mt-10 scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700">
{children}
</h2>
);

9
src/components/UI/Typography/H3.tsx

@ -0,0 +1,9 @@
export interface H3Props {
children: React.ReactNode;
}
export const H3 = ({ children }: H3Props): JSX.Element => (
<h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight">
{children}
</h3>
);

9
src/components/UI/Typography/H4.tsx

@ -0,0 +1,9 @@
export interface H4Props {
children: React.ReactNode;
}
export const H4 = ({ children }: H4Props): JSX.Element => (
<h4 className="mt-8 scroll-m-20 text-xl font-semibold tracking-tight">
{children}
</h4>
);

15
src/components/UI/Typography/Link.tsx

@ -0,0 +1,15 @@
export interface LinkProps {
href: string;
children: React.ReactNode;
}
export const Link = ({ href, children }: LinkProps): JSX.Element => (
<a
href={href}
target={"_blank"}
rel="noopener noreferrer"
className="font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50"
>
{children}
</a>
);

7
src/components/UI/Typography/P.tsx

@ -0,0 +1,7 @@
export interface PProps {
children: React.ReactNode;
}
export const P = ({ children }: PProps): JSX.Element => (
<p className="leading-7 [&:not(:first-child)]:mt-6">{children}</p>
);

7
src/components/UI/Typography/Subtle.tsx

@ -0,0 +1,7 @@
export interface SubtleProps {
children: React.ReactNode;
}
export const Subtle = ({ children }: SubtleProps): JSX.Element => (
<p className="text-sm text-slate-500 dark:text-slate-400">{children}</p>
);

77
src/components/Widgets/BatteryWidget.tsx

@ -1,77 +0,0 @@
import { useEffect, useState } from "react";
import prettyMilliseconds from "pretty-ms";
import { useDevice } from "@core/providers/useDevice.js";
import { Battery100Icon, ClockIcon } from "@heroicons/react/24/outline";
export interface BatteryWidgetProps {
batteryLevel: number;
voltage: number;
}
export const BatteryWidget = ({
batteryLevel,
voltage
}: BatteryWidgetProps): JSX.Element => {
const { nodes, hardware } = useDevice();
const [timeRemaining, setTimeRemaining] = useState("Unknown");
useEffect(() => {
const stats = nodes.find(
(n) => n.data.num === hardware.myNodeNum
)?.deviceMetrics;
if (stats) {
let currentStat: number | undefined = undefined;
let currentTime = new Date();
let previousStat: number | undefined = undefined;
let previousTime = new Date();
for (const stat of [...stats].reverse()) {
if (stat.metric.batteryLevel) {
if (!currentStat) {
currentStat = stat.metric.batteryLevel;
currentTime = stat.timestamp;
} else {
previousStat = stat.metric.batteryLevel;
previousTime = stat.timestamp;
break;
}
}
}
if (currentStat && previousStat) {
const timeDiff = currentTime.getTime() - previousTime.getTime();
const statDiff = Math.abs(currentStat - previousStat);
if (statDiff !== 0) {
//convert to ms/%
const msPerPercent = timeDiff / statDiff;
const formatted = prettyMilliseconds(
(100 - currentStat) * msPerPercent
);
setTimeRemaining(formatted);
}
} else setTimeRemaining("Unknown");
}
}, [hardware.myNodeNum, nodes]);
return (
<div className="flex gap-3 overflow-hidden rounded-lg bg-backgroundPrimary p-3 text-textSecondary">
<div className="rounded-md bg-accent p-3 text-textPrimary">
<Battery100Icon className="h-6" />
</div>
<div>
<p className="truncate text-sm font-medium">Battery State</p>
<div className="flex gap-1">
<p className="text-xl font-semibold">{batteryLevel}%</p>
<div className="flex text-sm font-semibold">
<ClockIcon
className="h-5 w-5 flex-shrink-0 self-center"
aria-hidden="true"
/>
<span className="my-auto">{timeRemaining}</span>
</div>
</div>
</div>
</div>
);
};

39
src/components/Widgets/DeviceWidget.tsx

@ -1,39 +0,0 @@
import { Button } from "@components/form/Button.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { XCircleIcon } from "@heroicons/react/24/outline";
export interface DeviceWidgetProps {
name: string;
nodeNum: string;
disconnected: boolean;
disconnect: () => void;
reconnect: () => void;
}
export const DeviceWidget = ({
name,
nodeNum,
disconnected,
disconnect,
reconnect
}: DeviceWidgetProps): JSX.Element => {
return (
<div className="relative flex shrink-0 flex-col overflow-hidden rounded-md text-sm text-textPrimary">
<div className="flex bg-backgroundPrimary p-3">
<div>
<Hashicon size={96} value={nodeNum} />
</div>
<div className="flex w-full flex-col">
<span className="ml-auto whitespace-nowrap text-xl font-bold">
{name}
</span>
<div className="my-auto ml-auto">
<Button onClick={disconnected ? reconnect : disconnect} size="sm">
<span>{disconnected ? "Reconnect" : "Disconnect"}</span>
</Button>
</div>
</div>
</div>
</div>
);
};

44
src/components/Widgets/PeersWidget.tsx

@ -1,44 +0,0 @@
import { useDevice } from "@core/providers/useDevice.js";
import { IconButton } from "@components/form/IconButton.js";
import { Mono } from "@components/generic/Mono.js";
import {
EllipsisHorizontalIcon,
UserGroupIcon
} from "@heroicons/react/24/outline";
import type { Protobuf } from "@meshtastic/meshtasticjs";
export interface PeersWidgetProps {
peers: Protobuf.NodeInfo[];
}
export const PeersWidget = ({ peers }: PeersWidgetProps): JSX.Element => {
const { setActivePage } = useDevice();
return (
<div className="flex gap-3 overflow-hidden rounded-lg bg-backgroundPrimary p-3 text-textSecondary">
<div className="rounded-md bg-accent p-3">
<UserGroupIcon className="h-6 text-textPrimary" />
</div>
<div>
<p className="truncate text-sm font-medium">Peers</p>
<div className="flex gap-1">
{peers.length > 0 ? (
<p className="text-lg font-semibold">
{`${peers.length} ${peers.length > 1 ? "Peers" : "Peer"}`}
</p>
) : (
<Mono className="m-auto">None Discovered.</Mono>
)}
</div>
</div>
<IconButton
className="my-auto ml-auto"
size="sm"
onClick={() => {
setActivePage("peers");
}}
icon={<EllipsisHorizontalIcon className="h-4" />}
/>
</div>
);
};

21
src/components/Widgets/PositionWidget.tsx

@ -1,21 +0,0 @@
import { MapPinIcon } from "@heroicons/react/24/outline";
export interface PositionWidgetProps {
grid: string;
}
export const PositionWidget = ({ grid }: PositionWidgetProps): JSX.Element => {
return (
<div className="flex gap-3 overflow-hidden rounded-lg bg-backgroundPrimary p-3 text-textSecondary">
<div className="rounded-md bg-accent p-3 text-textPrimary">
<MapPinIcon className="text-white h-6" />
</div>
<div>
<p className="truncate text-sm font-medium">Current Location</p>
<div className="flex gap-1">
<p className="text-lg font-semibold">{grid}</p>
</div>
</div>
</div>
);
};

31
src/components/form/IconButton.tsx

@ -1,31 +0,0 @@
import type { ButtonHTMLAttributes } from "react";
export interface IconButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
icon?: JSX.Element;
}
export const IconButton = ({
size = "md",
icon,
disabled,
className,
...rest
}: IconButtonProps): JSX.Element => {
return (
<button
className={`flex rounded-md bg-accentMuted text-textPrimary hover:brightness-hover focus:outline-none active:brightness-press ${
size === "sm" ? "h-8 w-8" : size === "md" ? "h-10 w-10" : "h-12 w-12"
} ${
disabled
? "cursor-not-allowed text-textSecondary brightness-disabled hover:brightness-disabled"
: ""
} ${className ?? ""}`}
disabled={disabled}
{...rest}
>
<div className="m-auto">{icon}</div>
</button>
);
};

6
src/components/form/InfoWrapper.tsx

@ -1,4 +1,4 @@
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; import { AlertCircleIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export interface InfoWrapperProps { export interface InfoWrapperProps {
@ -26,13 +26,13 @@ export const InfoWrapper = ({
{children} {children}
{error && ( {error && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<ExclamationCircleIcon className="text-red-500 h-5 w-5" /> <AlertCircleIcon size={16} className="text-red-500" />
</div> </div>
)} )}
{description && ( {description && (
<p className="mt-2 text-sm text-textSecondary">{description}</p> <p className="mt-2 text-sm text-textSecondary">{description}</p>
)} )}
{error && <p className="text-red-600 mt-2 text-sm">{error}</p>} {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div> </div>
); );
}; };

4
src/components/form/Input.tsx

@ -1,6 +1,6 @@
import { forwardRef, InputHTMLAttributes } from "react"; import { forwardRef, InputHTMLAttributes } from "react";
import { InfoWrapper, InfoWrapperProps } from "@components/form/InfoWrapper.js"; import { InfoWrapper, InfoWrapperProps } from "@components/form/InfoWrapper.js";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; import { AlertCircleIcon } from "lucide-react";
export interface InputProps export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>, extends InputHTMLAttributes<HTMLInputElement>,
@ -63,7 +63,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
)} )}
{error && ( {error && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<ExclamationCircleIcon className="text-red-500 h-5 w-5" /> <AlertCircleIcon size={16} className="text-red-500" />
</div> </div>
)} )}
</div> </div>

41
src/components/form/Toggle.tsx

@ -1,12 +1,12 @@
import { Switch } from "@headlessui/react"; import { Switch } from "../UI/Switch.js";
import { InfoWrapper } from "./InfoWrapper.js";
export interface ToggleProps { export interface ToggleProps {
checked: boolean; checked: boolean;
label?: string; label?: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
className?: string; onChange: (checked: boolean) => void;
onChange?: (checked: boolean) => void;
} }
export const Toggle = ({ export const Toggle = ({
@ -14,44 +14,15 @@ export const Toggle = ({
label, label,
description, description,
disabled, disabled,
className,
onChange onChange
}: ToggleProps): JSX.Element => { }: ToggleProps): JSX.Element => {
return ( return (
<Switch.Group <InfoWrapper label={label} description={description}>
as="div"
className={`flex items-center justify-between ${className}`}
>
<span className="flex flex-grow flex-col">
{label && (
<Switch.Label
as="span"
className="block text-sm font-medium text-textPrimary"
passive
>
{label}
</Switch.Label>
)}
{description && (
<Switch.Description as="span" className="text-sm text-textSecondary">
{description}
</Switch.Description>
)}
</span>
<Switch <Switch
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onChange={onChange} onCheckedChange={(state) => onChange(state)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-accent ${
checked ? "bg-accent" : "bg-backgroundPrimary"
} ${disabled ? "bg-orange-200 cursor-not-allowed" : ""}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-backgroundSecondary ring-0 transition duration-200 ease-in-out ${
checked ? "translate-x-5" : "translate-x-0"
}`}
/> />
</Switch> </InfoWrapper>
</Switch.Group>
); );
}; };

48
src/components/generic/Dialog.tsx

@ -1,48 +0,0 @@
import { IconButton } from "@components/form/IconButton.js";
import { Dialog as DialogUI } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { ThemeController } from "@components/generic/ThemeController.js";
import { Blur } from "@components/generic/Blur.js";
import type { ReactNode } from "react";
export interface DialogProps {
title: string;
description: string;
isOpen: boolean;
close: () => void;
children: ReactNode;
}
export const Dialog = ({
title,
description,
isOpen,
close,
children
}: DialogProps): JSX.Element => {
return (
<DialogUI open={isOpen} onClose={close}>
<ThemeController>
<Blur />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogUI.Panel>
<div className="flex bg-backgroundPrimary px-4 py-5 sm:px-6">
<div>
<h1 className="text-lg font-bold text-textPrimary">{title}</h1>
<h5 className="text-sm text-textSecondary">{description}</h5>
</div>
<IconButton
onClick={close}
className="my-auto ml-auto"
size="sm"
icon={<XMarkIcon className="h-4" />}
/>
</div>
<div className="bg-backgroundSecondary p-4">{children}</div>
</DialogUI.Panel>
</div>
</ThemeController>
</DialogUI>
);
};

97
src/components/generic/TabbedContent.tsx

@ -1,97 +0,0 @@
import { Fragment } from "react";
import { Mono } from "@components/generic/Mono";
import { Tab } from "@headlessui/react";
export interface TabType {
label: string;
icon?: JSX.Element;
element: () => JSX.Element;
disabled?: boolean;
disabledMessage?: string;
disabledLink?: string;
}
export interface TabbedCOntentAction {
icon: JSX.Element;
action: () => void;
}
export interface TabbedContentProps {
tabs: TabType[];
actions?: TabbedCOntentAction[];
}
export const TabbedContent = ({
tabs,
actions
}: TabbedContentProps): JSX.Element => {
return (
<Tab.Group as="div" className="flex flex-grow flex-col">
<Tab.List className="flex bg-backgroundPrimary">
{tabs.map((entry, index) => (
<Tab key={index} disabled={entry.disabled}>
{({ selected }) => (
<div
className={`flex h-10 gap-3 truncate border-b-4 px-3 text-sm font-medium ${
selected
? "border-accent text-textPrimary"
: "border-backgroundPrimary text-textSecondary hover:text-textPrimary"
} ${
entry.disabled
? "cursor-not-allowed hover:text-textSecondary"
: "cursor-pointer bg-backgroundPrimary hover:brightness-hover active:brightness-press"
}
`}
>
{entry.icon && (
<div className="text-slate-500 m-auto">{entry.icon}</div>
)}
<span className="m-auto">{entry.label}</span>
</div>
)}
</Tab>
))}
<div className="ml-auto flex">
{actions?.map((action, index) => (
<div
key={index}
className="my-auto cursor-pointer bg-backgroundPrimary p-3 text-textSecondary hover:brightness-hover active:brightness-press"
onClick={action.action}
>
{action.icon}
</div>
))}
</div>
</Tab.List>
<Tab.Panels as={Fragment}>
{tabs.map((entry, index) => (
<Tab.Panel key={index} className="m-2 flex flex-grow">
{!entry.disabled ? (
<entry.element />
) : (
<div>
<Mono>
{entry.disabledMessage || "This tab is disabled"}.{" "}
{entry.disabledLink && (
<>
Click{" "}
<a
className="underline"
target="_blank"
rel="noreferrer"
href={entry.disabledLink}
>
here
</a>{" "}
for more information.
</>
)}
</Mono>
</div>
)}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

6
src/components/generic/Table/index.tsx

@ -1,4 +1,4 @@
import { ChevronUpIcon } from "@heroicons/react/24/outline"; import { ChevronUpIcon } from "lucide-react";
export interface TableProps { export interface TableProps {
headings: Heading[]; headings: Heading[];
@ -28,7 +28,9 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
> >
<div className="flex gap-2"> <div className="flex gap-2">
{heading.title} {heading.title}
{heading.sortable && <ChevronUpIcon className="my-auto h-3" />} {heading.sortable && (
<ChevronUpIcon size={16} className="my-auto" />
)}
</div> </div>
</th> </th>
))} ))}

49
src/components/generic/VerticalTabbedContent.tsx

@ -1,49 +0,0 @@
import { Fragment } from "react";
import { Mono } from "@components/generic/Mono";
import { Tab } from "@headlessui/react";
export interface TabType {
label: string;
element: () => JSX.Element;
disabled?: boolean;
}
export interface TabbedContentProps {
tabs: TabType[];
}
export const VerticalTabbedContent = ({
tabs
}: TabbedContentProps): JSX.Element => {
return (
<Tab.Group as="div" className="flex w-full gap-3">
<Tab.List className="flex w-44 flex-col">
{tabs.map((tab, index) => (
<Tab key={index} as={Fragment}>
{({ selected }) => (
<div
className={`flex cursor-pointer items-center border-l-4 p-4 text-sm font-medium ${
selected
? "border-accent bg-accentMuted bg-opacity-10 text-textPrimary"
: "border-backgroundPrimary text-textSecondary"
}`}
>
{tab.label}
<span className="ml-auto rounded-full bg-accent px-3 text-textPrimary">
3
</span>
</div>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
{tabs.map((tab, index) => (
<Tab.Panel key={index} as={Fragment}>
<tab.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

13
src/core/providers/useDevice.ts

@ -1,13 +0,0 @@
import { createContext, useContext } from "react";
import type { Device } from "@core/stores/deviceStore.js";
export const DeviceContext = createContext<Device | undefined>(undefined);
export const useDevice = (): Device => {
const context = useContext(DeviceContext);
if (context === undefined) {
throw new Error("useDevice must be used within a DeviceProvider");
}
return context;
};

28
src/core/stores/deviceStore.ts

@ -1,16 +1,11 @@
import { createContext } from "react"; import { createContext, useContext } from "react";
import { produce } from "immer"; import { produce } from "immer";
import { create } from "zustand"; import { create } from "zustand";
import { Protobuf, Types } from "@meshtastic/meshtasticjs"; import { Protobuf, Types } from "@meshtastic/meshtasticjs";
export type Page = export type Page = "messages" | "map" | "config" | "channels" | "peers";
| "messages"
| "map"
| "config"
| "channels"
| "peers";
export interface MessageWithState extends Types.PacketMetadata<string> { export interface MessageWithState extends Types.PacketMetadata<string> {
state: MessageState; state: MessageState;
@ -51,7 +46,12 @@ export interface processPacketParams {
time: number; time: number;
} }
export type DialogVariant = "import" | "QR" | "shutdown" | "reboot"; export type DialogVariant =
| "import"
| "QR"
| "shutdown"
| "reboot"
| "deviceName";
export interface Device { export interface Device {
id: number; id: number;
@ -78,6 +78,7 @@ export interface Device {
QR: boolean; QR: boolean;
shutdown: boolean; shutdown: boolean;
reboot: boolean; reboot: boolean;
deviceName: boolean;
}; };
setReady(ready: boolean): void; setReady(ready: boolean): void;
@ -152,7 +153,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
import: false, import: false,
QR: false, QR: false,
shutdown: false, shutdown: false,
reboot: false reboot: false,
deviceName: false
}, },
pendingSettingsChanges: false, pendingSettingsChanges: false,
messageDraft: "", messageDraft: "",
@ -610,3 +612,11 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
})); }));
export const DeviceContext = createContext<Device | undefined>(undefined); export const DeviceContext = createContext<Device | undefined>(undefined);
export const useDevice = (): Device => {
const context = useContext(DeviceContext);
if (context === undefined) {
throw new Error("useDevice must be used within a DeviceProvider");
}
return context;
};

27
src/core/subscriptions.ts

@ -1,5 +1,3 @@
import { toast } from "react-hot-toast";
import type { Device } from "@core/stores/deviceStore.js"; import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/meshtasticjs"; import { Protobuf, Types } from "@meshtastic/meshtasticjs";
@ -22,24 +20,15 @@ export const subscribeAll = (
if (routingPacket.data.variant.value === Protobuf.Routing_Error.NONE) { if (routingPacket.data.variant.value === Protobuf.Routing_Error.NONE) {
return; return;
} }
toast.error( console.log(`Routing Error: ${routingPacket.data.variant.value}`);
`Routing error: ${
Protobuf.Routing_Error[routingPacket.data.variant.value]
}`,
{
icon: "❌"
}
);
break; break;
case "routeReply": case "routeReply":
toast(`Route Reply: ${routingPacket.data.variant.value}`, { console.log(`Route Reply: ${routingPacket.data.variant.value}`);
icon: "✅"
});
break; break;
case "routeRequest": case "routeRequest":
toast(`Route Request: ${routingPacket.data.variant.value}`, { console.log(`Route Request: ${routingPacket.data.variant.value}`);
icon: "✅"
});
break; break;
} }
}); });
@ -82,9 +71,9 @@ export const subscribeAll = (
}); });
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, { // toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, {
icon: "🔎" // icon: "🔎"
}); // });
device.addNodeInfo(nodeInfo); device.addNodeInfo(nodeInfo);
}); });

6
src/core/utils/cn.ts

@ -0,0 +1,6 @@
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

92
src/pages/Channels.tsx

@ -1,39 +1,81 @@
import { TabbedContent, TabType } from "@components/generic/TabbedContent"; import { Sidebar } from "@app/components/Sidebar.js";
import { PageLayout } from "@app/components/Topbar.js";
import { cn } from "@app/core/utils/cn.js";
import { Channel } from "@components/PageComponents/Channel.js"; import { Channel } from "@components/PageComponents/Channel.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { import { QrCodeIcon, ImportIcon } from "lucide-react";
ArrowDownOnSquareStackIcon, import { Protobuf, Types } from "@meshtastic/meshtasticjs";
QrCodeIcon import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js";
} from "@heroicons/react/24/outline"; import { SidebarItem } from "@app/components/UI/Sidebar/SidebarItem.js";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { useState } from "react";
export const getChannelName = (channel: Protobuf.Channel) =>
channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? "Primary"
: `Ch ${channel.index}`;
export const ChannelsPage = (): JSX.Element => { export const ChannelsPage = (): JSX.Element => {
const { channels, setDialogOpen } = useDevice(); const { channels, setDialogOpen } = useDevice();
const [activeChannel, setActiveChannel] = useState<Types.ChannelNumber>(
const tabs: TabType[] = channels.map((channel) => { Types.ChannelNumber.PRIMARY
return { );
label: channel.config.settings?.name.length
? channel.config.settings.name
: channel.config.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.config.index}`,
element: () => <Channel channel={channel.config} />
};
});
return ( return (
<TabbedContent <>
tabs={tabs} <Sidebar>
<SidebarSection title="Channels">
{channels.map((channel) => (
<SidebarItem
key={channel.config.index}
active={channel.config.index === activeChannel}
onClick={() => setActiveChannel(channel.config.index)}
label={getChannelName(channel.config)}
element={
<span
className={cn(
"h-3 w-3 rounded-full",
channel.config.role === Protobuf.Channel_Role.DISABLED
? "bg-gray-500"
: "bg-green-500"
)}
/>
}
/>
))}
</SidebarSection>
</Sidebar>
<PageLayout
title={`Channel: ${
channels[activeChannel]
? getChannelName(channels[activeChannel].config)
: "Loading..."
}`}
actions={[ actions={[
{ {
icon: <ArrowDownOnSquareStackIcon className="w-4" />, icon: ImportIcon,
action: () => setDialogOpen("import", true) onClick() {
console.log("fired");
setDialogOpen("import", true);
}
}, },
{ {
icon: <QrCodeIcon className="w-4" />, icon: QrCodeIcon,
action: () => setDialogOpen("QR", true) onClick() {
setDialogOpen("QR", true);
}
} }
]} ]}
/> >
{channels.map(
(channel) =>
channel.config.index === activeChannel && (
<Channel key={channel.config.index} channel={channel.config} />
)
)}
</PageLayout>
</>
); );
}; };

41
src/pages/Config/AppConfig.tsx

@ -1,41 +0,0 @@
import { Fragment } from "react";
import { Map } from "@components/PageComponents/AppConfig/Map.js";
import { Tab } from "@headlessui/react";
export const AppConfig = (): JSX.Element => {
const configSections = [
{
label: "Map",
element: Map
}
];
return (
<Tab.Group as="div" className="flex w-full gap-3">
<Tab.List className="flex w-44 flex-col gap-1">
{configSections.map((Config, index) => (
<Tab key={index} as={Fragment}>
{({ selected }) => (
<div
className={`flex cursor-pointer items-center rounded-md px-3 py-2 text-sm font-medium ${
selected
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{Config.label}
</div>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
{configSections.map((Config, index) => (
<Tab.Panel key={index} as={Fragment}>
<Config.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
};

60
src/pages/Config/DeviceConfig.tsx

@ -6,26 +6,22 @@ import { Display } from "@components/PageComponents/Config/Display.js";
import { LoRa } from "@components/PageComponents/Config/LoRa.js"; import { LoRa } from "@components/PageComponents/Config/LoRa.js";
import { Position } from "@components/PageComponents/Config/Position.js"; import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js"; import { Power } from "@components/PageComponents/Config/Power.js";
import { User } from "@components/PageComponents/Config/User.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { useDevice } from "@core/providers/useDevice.js"; import {
import { Tab } from "@headlessui/react"; Tabs,
import { ChevronRightIcon, HomeIcon } from "@heroicons/react/24/outline"; TabsContent,
import { Button } from "@components/form/Button.js"; TabsList,
import { CheckIcon } from "@primer/octicons-react"; TabsTrigger
import { NavBar } from "@app/Nav/NavBar.js"; } from "@components/UI/Tabs.js";
import { VerticalTabbedContent } from "@app/components/generic/VerticalTabbedContent.js";
export const DeviceConfig = (): JSX.Element => { export const DeviceConfig = (): JSX.Element => {
const { hardware, workingConfig, connection } = useDevice(); const { hardware } = useDevice();
const tabs = [ const tabs = [
{
label: "User",
element: User
},
{ {
label: "Device", label: "Device",
element: Device element: Device,
count: 0
}, },
{ {
label: "Position", label: "Position",
@ -55,23 +51,23 @@ export const DeviceConfig = (): JSX.Element => {
]; ];
return ( return (
<div className="flex flex-grow flex-col gap-2"> <Tabs defaultValue="Device" className="w-[400px]">
<NavBar <TabsList>
breadcrumb={["Config"]} {tabs.map((tab) => (
actions={[ <TabsTrigger
{ key={tab.label}
label: "Apply & Reboot", value={tab.label}
async onClick() { disabled={tab.disabled}
workingConfig.map(async (config) => { >
await connection?.setConfig(config); {tab.label}
}); </TabsTrigger>
await connection?.commitEditSettings(); ))}
} </TabsList>
} {tabs.map((tab) => (
]} <TabsContent key={tab.label} value={tab.label}>
/> <tab.element />
</TabsContent>
<VerticalTabbedContent tabs={tabs} /> ))}
</div> </Tabs>
); );
}; };

50
src/pages/Config/ModuleConfig.tsx

@ -7,9 +7,13 @@ import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js"
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js"; import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js"; import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js"; import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
import { useDevice } from "@app/core/providers/useDevice.js"; import { useDevice } from "@app/core/stores/deviceStore.js";
import { NavBar } from "@app/Nav/NavBar.js"; import {
import { VerticalTabbedContent } from "@app/components/generic/VerticalTabbedContent.js"; Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "@components/UI/Tabs.js";
export const ModuleConfig = (): JSX.Element => { export const ModuleConfig = (): JSX.Element => {
const { workingModuleConfig, connection } = useDevice(); const { workingModuleConfig, connection } = useDevice();
@ -24,11 +28,11 @@ export const ModuleConfig = (): JSX.Element => {
element: Serial element: Serial
}, },
{ {
label: "External Notification", label: "Ext Notif",
element: ExternalNotification element: ExternalNotification
}, },
{ {
label: "Store & Forward", label: "S&F",
element: StoreForward element: StoreForward
}, },
{ {
@ -40,33 +44,29 @@ export const ModuleConfig = (): JSX.Element => {
element: Telemetry element: Telemetry
}, },
{ {
label: "Canned Message", label: "Canned",
element: CannedMessage element: CannedMessage
}, },
{ {
label: "Audio Config", label: "Audio",
element: Audio element: Audio
} }
]; ];
return ( return (
<div className="flex flex-grow flex-col gap-2"> <Tabs defaultValue="MQTT" className="w-[400px]">
<NavBar <TabsList>
breadcrumb={["Module Config"]} {tabs.map((tab) => (
actions={[ <TabsTrigger key={tab.label} value={tab.label}>
{ {tab.label}
label: "Apply & Reboot", </TabsTrigger>
async onClick() { ))}
workingModuleConfig.map(async (moduleConfig) => { </TabsList>
await connection?.setModuleConfig(moduleConfig); {tabs.map((tab) => (
}); <TabsContent key={tab.label} value={tab.label}>
await connection?.commitEditSettings(); <tab.element />
} </TabsContent>
} ))}
]} </Tabs>
/>
<VerticalTabbedContent tabs={tabs} />
</div>
); );
}; };

92
src/pages/Config/index.tsx

@ -1,34 +1,76 @@
import { TabbedContent, TabType } from "@components/generic/TabbedContent"; import { Sidebar } from "@app/components/Sidebar.js";
import { useDevice } from "@core/providers/useDevice.js"; import { SettingsIcon, BoxesIcon, SaveIcon } from "lucide-react";
import {
Cog8ToothIcon,
CubeTransparentIcon,
WindowIcon
} from "@heroicons/react/24/outline";
import { AppConfig } from "@pages/Config/AppConfig.js";
import { DeviceConfig } from "@pages/Config/DeviceConfig.js"; import { DeviceConfig } from "@pages/Config/DeviceConfig.js";
import { ModuleConfig } from "@pages/Config/ModuleConfig.js"; import { ModuleConfig } from "@pages/Config/ModuleConfig.js";
import { PageLayout } from "@app/components/Topbar.js";
import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js";
import { SidebarItem } from "@app/components/UI/Sidebar/SidebarItem.js";
import { useState } from "react";
import { useDevice } from "@app/core/stores/deviceStore.js";
export const ConfigPage = (): JSX.Element => { export const ConfigPage = (): JSX.Element => {
const { connection, pendingSettingsChanges } = useDevice(); const { workingConfig, workingModuleConfig, connection } = useDevice();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
>("device");
const tabs: TabType[] = [ return (
{ <>
label: "Device Config", <Sidebar>
icon: <Cog8ToothIcon className="h-4" />, <SidebarSection title="Config Sections">
element: DeviceConfig <SidebarItem
}, icon={SettingsIcon}
{ label="Device Config"
label: "Module Config", active={activeConfigSection === "device"}
icon: <CubeTransparentIcon className="h-4" />, onClick={() => setActiveConfigSection("device")}
element: ModuleConfig />
}, <SidebarItem
icon={BoxesIcon}
label="Module Config"
active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")}
/>
</SidebarSection>
<div className="space-y-1.5">
<div className="bg-palette-0 sticky top-0 pb-2">
<div className="text-palette-700 flex items-center justify-between px-2">
<div className="text-base font-medium"></div>
</div>
</div>
<ul className="space-y-1"></ul>
</div>
</Sidebar>
<PageLayout
title={
activeConfigSection === "device" ? "Device Config" : "Module Config"
}
actions={[
{ {
label: "App Config", icon: SaveIcon,
icon: <WindowIcon className="h-4" />, async onClick() {
element: AppConfig if (activeConfigSection === "device") {
workingConfig.map(
async (config) => await connection?.setConfig(config)
);
} else {
workingModuleConfig.map(
async (config) => await connection?.setModuleConfig(config)
);
} }
];
return <TabbedContent tabs={tabs} />; await connection?.commitEditSettings();
}
}
]}
>
<div className="p-3">
{activeConfigSection === "device" ? (
<DeviceConfig />
) : (
<ModuleConfig />
)}
</div>
</PageLayout>
</>
);
}; };

103
src/pages/Map.tsx

@ -1,17 +1,104 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Layer, Map, Marker, Source } from "react-map-gl"; import { Layer, Map, Marker, Source, useMap } from "react-map-gl";
import { MapControlls } from "@components/PageComponents/Map/MapControlls.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { MapPinIcon } from "@heroicons/react/24/outline"; import { Sidebar } from "@app/components/Sidebar.js";
import { PageLayout } from "@app/components/Topbar.js";
import {
ZoomInIcon,
ZoomOutIcon,
BoxSelectIcon,
MapPinIcon,
EditIcon,
PlusIcon
} from "lucide-react";
import { bbox, lineString } from "@turf/turf";
export const MapPage = (): JSX.Element => { export const MapPage = (): JSX.Element => {
const { nodes, waypoints } = useDevice(); const { nodes, waypoints } = useDevice();
const { rasterSources } = useAppStore(); const { rasterSources } = useAppStore();
const { default: map } = useMap();
const tmp = useMap();
const getBBox = () => {
const nodesWithPosition = nodes.filter((n) => n.data.position?.latitudeI);
if (!nodesWithPosition.length) return;
const line = lineString(
nodesWithPosition.map((n) => [
(n.data.position?.latitudeI ?? 0) / 1e7,
(n.data.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);
else if (nodesWithPosition.length === 1)
map?.easeTo({
zoom: 12,
center: [
(nodesWithPosition[0].data.position?.longitudeI ?? 0) / 1e7,
(nodesWithPosition[0].data.position?.latitudeI ?? 0) / 1e7
]
});
};
return ( return (
<div className="h-full flex-grow"> <>
<Sidebar>
<div className="space-y-1.5">
<div className="bg-palette-0 sticky top-0 pb-2">
<div className="text-palette-700 flex items-center justify-between px-2">
<div className="text-base font-medium">Sources</div>
<div>
<button className="flex h-[22px] items-center transition-all duration-300 hover:text-accent">
<PlusIcon size={16} />
</button>
</div>
</div>
</div>
<ul className="space-y-1">
{rasterSources.map((source, index) => (
<li key={index}>
<div className="text-palette-600 flex w-full cursor-pointer items-center space-x-2.5 rounded-xl bg-transparent px-2.5 py-2.5 transition-all duration-300 hover:text-accent">
<span className="align-middle font-mono text-sm">
{source.title}
</span>
</div>
</li>
))}
</ul>
</div>
</Sidebar>
<PageLayout
title="Map"
actions={[
{
icon: ZoomInIcon,
onClick() {
map?.zoomIn();
}
},
{
icon: ZoomOutIcon,
onClick() {
map?.zoomOut();
}
},
{
icon: BoxSelectIcon,
onClick() {
getBBox();
}
}
]}
>
<Map <Map
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json" mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
mapLib={maplibregl} mapLib={maplibregl}
@ -26,7 +113,6 @@ export const MapPage = (): JSX.Element => {
longitude: 145 longitude: 145
}} }}
> >
<MapControlls />
{waypoints.map((wp) => ( {waypoints.map((wp) => (
<Marker <Marker
key={wp.id} key={wp.id}
@ -35,7 +121,7 @@ export const MapPage = (): JSX.Element => {
anchor="bottom" anchor="bottom"
> >
<div> <div>
<MapPinIcon className="h-4" /> <MapPinIcon size={16} />
</div> </div>
</Marker> </Marker>
))} ))}
@ -59,6 +145,7 @@ export const MapPage = (): JSX.Element => {
} }
})} })}
</Map> </Map>
</div> </PageLayout>
</>
); );
}; };

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

Loading…
Cancel
Save