Browse Source

WIP form overhaul

pull/83/head
Sacha Weatherstone 3 years ago
parent
commit
66b6633822
No known key found for this signature in database GPG Key ID: 7AB2D7E206124B31
  1. 28
      package.json
  2. 594
      pnpm-lock.yaml
  3. 10
      src/App.tsx
  4. 153
      src/components/CommandPalette.tsx
  5. 30
      src/components/CommandPalette/GroupView.tsx
  6. 13
      src/components/CommandPalette/NoResults.tsx
  7. 40
      src/components/CommandPalette/PaletteTransition.tsx
  8. 22
      src/components/CommandPalette/SearchBox.tsx
  9. 80
      src/components/CommandPalette/SearchResult.tsx
  10. 104
      src/components/Dashboard.tsx
  11. 10
      src/components/DeviceSelector.tsx
  12. 26
      src/components/Dialog/DeviceNameDialog.tsx
  13. 15
      src/components/Dialog/ImportDialog.tsx
  14. 4
      src/components/Dialog/QRDialog.tsx
  15. 2
      src/components/Dialog/RebootDialog.tsx
  16. 2
      src/components/Dialog/ShutdownDialog.tsx
  17. 227
      src/components/DynamicForm.tsx
  18. 57
      src/components/Menu.tsx
  19. 77
      src/components/NewDevice.tsx
  20. 21
      src/components/PageComponents/Channel.tsx
  21. 108
      src/components/PageComponents/Config/Bluetooth.tsx
  22. 125
      src/components/PageComponents/Config/Device.tsx
  23. 174
      src/components/PageComponents/Config/Display.tsx
  24. 283
      src/components/PageComponents/Config/LoRa.tsx
  25. 269
      src/components/PageComponents/Config/Network.tsx
  26. 5
      src/components/PageComponents/Config/Position.tsx
  27. 163
      src/components/PageComponents/Config/Power.tsx
  28. 2
      src/components/PageComponents/Connect/BLE.tsx
  29. 4
      src/components/PageComponents/Connect/HTTP.tsx
  30. 2
      src/components/PageComponents/Connect/Serial.tsx
  31. 9
      src/components/PageComponents/Messages/MessageInput.tsx
  32. 8
      src/components/PageComponents/Messages/NewLocationMessage.tsx
  33. 11
      src/components/PageComponents/ModuleConfig/Audio.tsx
  34. 11
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  35. 5
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  36. 5
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  37. 5
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  38. 15
      src/components/PageComponents/ModuleConfig/Serial.tsx
  39. 5
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  40. 5
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  41. 21
      src/components/Sidebar.tsx
  42. 12
      src/components/Topbar.tsx
  43. 156
      src/components/UI/Command.tsx
  44. 24
      src/components/UI/Input.tsx
  45. 30
      src/components/UI/Sidebar/SidebarItem.tsx
  46. 27
      src/components/UI/Sidebar/SidebarSection.tsx
  47. 29
      src/components/UI/Sidebar/sidebarButton.tsx
  48. 22
      src/components/UI/Toast.tsx
  49. 2
      src/components/UI/Typography/H2.tsx
  50. 2
      src/components/UI/Typography/H3.tsx
  51. 12
      src/components/UI/Typography/H4.tsx
  52. 6
      src/components/form/BitwiseSelect.tsx
  53. 35
      src/components/form/Button.tsx
  54. 22
      src/components/form/Form.tsx
  55. 61
      src/components/form/Select.tsx
  56. 4
      src/components/form/Toggle.tsx
  57. 4
      src/core/stores/appStore.ts
  58. 13
      src/core/utils/selectEnumOptions.tsx
  59. 4
      src/index.css
  60. 13
      src/pages/Channels.tsx
  61. 2
      src/pages/Config/DeviceConfig.tsx
  62. 2
      src/pages/Config/ModuleConfig.tsx
  63. 23
      src/pages/Config/index.tsx
  64. 34
      src/pages/Map.tsx
  65. 40
      src/pages/Messages.tsx
  66. 6
      src/pages/Peers.tsx
  67. 6
      src/validation/config/device.ts
  68. 3
      src/validation/config/lora.ts
  69. 22
      src/validation/rasterSource.ts

28
package.json

@ -20,10 +20,9 @@
"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.9",
"@hookform/error-message": "^2.0.1", "@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^2.9.10", "@hookform/resolvers": "^2.9.11",
"@meshtastic/meshtasticjs": "2.0.15-0", "@meshtastic/meshtasticjs": "2.0.20-1",
"@radix-ui/react-dialog": "^1.0.2", "@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-label": "^2.0.0", "@radix-ui/react-label": "^2.0.0",
"@radix-ui/react-menubar": "^1.0.0", "@radix-ui/react-menubar": "^1.0.0",
@ -41,37 +40,38 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"class-variance-authority": "^0.4.0", "class-variance-authority": "^0.4.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^0.1.22",
"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", "lucide-react": "^0.112.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",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.43.0", "react-hook-form": "^7.43.1",
"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", "tailwind-merge": "^1.9.1",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"timeago-react": "^3.0.5", "timeago-react": "^3.0.5",
"zustand": "4.3.2" "zustand": "4.3.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@types/chrome": "^0.0.212", "@types/chrome": "^0.0.212",
"@types/geodesy": "^2.2.3", "@types/geodesy": "^2.2.3",
"@types/node": "^18.11.18", "@types/node": "^18.13.0",
"@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.50.0", "@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.50.0", "@typescript-eslint/parser": "^5.51.0",
"@vitejs/plugin-react": "^3.1.0", "@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint": "^8.33.0", "eslint": "^8.34.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",
@ -79,15 +79,15 @@
"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",
"prettier": "^2.8.3", "prettier": "^2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2", "prettier-plugin-tailwindcss": "^0.2.2",
"rollup-plugin-visualizer": "^5.9.0", "rollup-plugin-visualizer": "^5.9.0",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.6",
"tar": "^6.1.13", "tar": "^6.1.13",
"tslib": "^2.5.0", "tslib": "^2.5.0",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite": "^4.1.1", "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.4"
} }
} }

594
pnpm-lock.yaml

File diff suppressed because it is too large

10
src/App.tsx

@ -2,13 +2,12 @@ import { MapProvider } from "react-map-gl";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { DeviceWrapper } from "@app/DeviceWrapper.js"; import { DeviceWrapper } from "@app/DeviceWrapper.js";
import { PageRouter } from "@app/PageRouter.js"; import { PageRouter } from "@app/PageRouter.js";
import { CommandPalette } from "@components/CommandPalette/Index.js"; import { CommandPalette } from "@components/CommandPalette.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 { Dashboard } from "@app/components/Dashboard.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
import { Menu } from "./components/Menu.js";
import { NewDeviceDialog } from "./components/Dialog/NewDevice.js"; import { NewDeviceDialog } from "./components/Dialog/NewDevice.js";
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
@ -28,8 +27,7 @@ export const App = (): JSX.Element => {
/> />
<MapProvider> <MapProvider>
<DeviceWrapper device={device}> <DeviceWrapper device={device}>
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col bg-backgroundPrimary text-textPrimary">
<Menu />
<div className="flex flex-grow"> <div className="flex flex-grow">
<DeviceSelector /> <DeviceSelector />
<div className="flex flex-grow flex-col"> <div className="flex flex-grow flex-col">
@ -40,7 +38,7 @@ export const App = (): JSX.Element => {
<PageRouter /> <PageRouter />
</div> </div>
) : ( ) : (
<NewDevice /> <Dashboard />
)} )}
</div> </div>
</div> </div>

153
src/components/CommandPalette/Index.tsx → src/components/CommandPalette.tsx

@ -1,14 +1,8 @@
import { ComponentType, Fragment, SVGProps, useEffect, useState } from "react"; import { useEffect } from "react";
import { useDevice } from "@core/stores/deviceStore.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDevice, useDeviceStore } from "@core/stores/deviceStore.js";
import { GroupView } from "@components/CommandPalette/GroupView.js"; import { useCommandState } from "cmdk";
import { NoResults } from "@components/CommandPalette/NoResults.js";
import { PaletteTransition } from "@components/CommandPalette/PaletteTransition.js";
import { SearchBox } from "@components/CommandPalette/SearchBox.js";
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 { import {
LucideIcon, LucideIcon,
LinkIcon, LinkIcon,
@ -18,7 +12,6 @@ import {
PlusIcon, PlusIcon,
PowerIcon, PowerIcon,
EraserIcon, EraserIcon,
ImportIcon,
RefreshCwIcon, RefreshCwIcon,
FactoryIcon, FactoryIcon,
ArrowLeftRightIcon, ArrowLeftRightIcon,
@ -34,8 +27,14 @@ import {
XCircleIcon, XCircleIcon,
BoxSelectIcon BoxSelectIcon
} from "lucide-react"; } from "lucide-react";
import { Blur } from "@components/generic/Blur.js"; import {
import { ThemeController } from "@components/generic/ThemeController.js"; CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@components/UI/Command.js";
export interface Group { export interface Group {
label: string; label: string;
@ -57,7 +56,6 @@ export interface SubItem {
} }
export const CommandPalette = (): JSX.Element => { export const CommandPalette = (): JSX.Element => {
const [query, setQuery] = useState("");
const { const {
commandPaletteOpen, commandPaletteOpen,
setCommandPaletteOpen, setCommandPaletteOpen,
@ -69,7 +67,6 @@ export const CommandPalette = (): JSX.Element => {
setAccent setAccent
} = useAppStore(); } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice(); const { setDialogOpen, setActivePage, connection } = useDevice();
const groups: Group[] = [ const groups: Group[] = [
@ -344,6 +341,7 @@ export const CommandPalette = (): JSX.Element => {
} }
]; ];
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
@ -351,95 +349,64 @@ export const CommandPalette = (): JSX.Element => {
} }
}; };
useEffect(() => {
window.addEventListener("keydown", handleKeydown); window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown); return () => window.removeEventListener("keydown", handleKeydown);
}, []); }, []);
const filtered =
query === ""
? []
: groups
.map((group) => {
return {
...group,
commands: group.commands.filter((command) => {
const nameIncludes = `${group.label} ${command.label}`
.toLowerCase()
.includes(query.toLowerCase());
const tagsInclude = (
command.tags
?.map((t) => t.includes(query.toLowerCase()))
.filter(Boolean) ?? []
).length;
const subItemsInclude = (
command.subItems
?.map((s) =>
s.label.toLowerCase().includes(query.toLowerCase())
)
.filter(Boolean) ?? []
).length;
return nameIncludes || tagsInclude || subItemsInclude;
})
};
})
.filter((group) => group.commands.length);
return ( return (
<Transition.Root <CommandDialog
show={commandPaletteOpen} open={commandPaletteOpen}
as={Fragment} onOpenChange={setCommandPaletteOpen}
afterLeave={() => setQuery("")}
appear
>
<Dialog
as="div"
className="relative z-10"
onClose={setCommandPaletteOpen}
> >
<ThemeController> <CommandInput placeholder="Type a command or search..." />
<Blur /> <CommandList>
<PaletteTransition> <CommandEmpty>No results found.</CommandEmpty>
<Dialog.Panel className="mx-auto max-w-2xl transform overflow-hidden rounded-md bg-backgroundPrimary transition-all"> {groups.map((group) => (
<Combobox<Command | string> <CommandGroup heading={group.label}>
onChange={(input) => { {group.commands.map((command) => (
if (typeof input === "string") { <>
setQuery(input); <CommandItem
} else if (input.action) { onSelect={() => {
command.action && command.action();
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
input.action();
}
}} }}
> >
<SearchBox setQuery={setQuery} /> <command.icon size={16} className="mr-2" />
{command.label}
{query === "" || filtered.length > 0 ? ( </CommandItem>
<Combobox.Options {command.subItems &&
static command.subItems.map((subItem) => (
className="max-h-80 scroll-py-2 divide-y divide-opacity-10 overflow-y-auto bg-backgroundSecondary" <SubItem
> label={subItem.label}
<li className="p-2"> icon={subItem.icon}
<ul className="flex flex-col gap-2 text-sm text-textSecondary"> action={subItem.action}
{filtered.map((group, index) => ( />
<SearchResult key={index} group={group} /> ))}
</>
))} ))}
{query === "" && </CommandGroup>
groups.map((group, index) => (
<GroupView key={index} group={group} />
))} ))}
</ul> </CommandList>
</li> </CommandDialog>
</Combobox.Options> );
) : ( };
query !== "" && filtered.length === 0 && <NoResults />
)} const SubItem = ({
</Combobox> label,
</Dialog.Panel> icon,
</PaletteTransition> action
</ThemeController> }: {
</Dialog> label: string;
</Transition.Root> icon: React.ReactNode;
action: () => void;
}) => {
const search = useCommandState((state) => state.search);
if (!search) return null;
return (
<CommandItem onSelect={action}>
{icon}
{label}
</CommandItem>
); );
}; };

30
src/components/CommandPalette/GroupView.tsx

@ -1,30 +0,0 @@
import type { Group } from "@components/CommandPalette/Index.js";
import { Combobox } from "@headlessui/react";
import { ChevronRightIcon } from "lucide-react";
export interface GroupViewProps {
group: Group;
}
export const GroupView = ({ group }: GroupViewProps): JSX.Element => {
return (
<Combobox.Option
value={group.label}
className={({ active }) =>
`flex cursor-default select-none items-center rounded-md px-3 py-2 ${
active ? "bg-backgroundPrimary text-textPrimary" : ""
}`
}
>
{({ active }) => (
<>
<group.icon size={20} />
<span className="ml-3 flex-auto truncate">{group.label}</span>
{active && (
<ChevronRightIcon size={16} className="text-textSecondary" />
)}
</>
)}
</Combobox.Option>
);
};

13
src/components/CommandPalette/NoResults.tsx

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

40
src/components/CommandPalette/PaletteTransition.tsx

@ -1,40 +0,0 @@
import { Fragment, ReactNode } from "react";
import { Transition } from "@headlessui/react";
export interface PaletteTransitionProps {
children: ReactNode;
}
export const PaletteTransition = ({
children
}: PaletteTransitionProps): JSX.Element => {
return (
<>
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="bg-gray-500 fixed inset-0 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
{children}
</Transition.Child>
</div>
</>
);
};

22
src/components/CommandPalette/SearchBox.tsx

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

80
src/components/CommandPalette/SearchResult.tsx

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

104
src/components/Dashboard.tsx

@ -0,0 +1,104 @@
import { useAppStore } from "@app/core/stores/appStore.js";
import { Button } from "@components/UI/Button.js";
import {
PlusIcon,
ListPlusIcon,
UsersIcon,
MapPinIcon,
CalendarIcon,
BluetoothIcon,
UsbIcon,
NetworkIcon
} from "lucide-react";
import { Subtle } from "./UI/Typography/Subtle.js";
import { H3 } from "./UI/Typography/H3.js";
import { useDeviceStore } from "@app/core/stores/deviceStore.js";
import { useMemo } from "react";
import { Separator } from "./UI/Seperator.js";
export const Dashboard = () => {
const { setConnectDialogOpen } = useAppStore();
const { getDevices } = useDeviceStore();
const devices = useMemo(() => getDevices(), [getDevices]);
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
</div>
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul role="list" className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.filter(
(n) => n.data.num === device.hardware.myNodeNum
)[0]?.data.user?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.length === 0
? 0
: device.nodes.length - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<H3>No Devices</H3>
<Subtle>Connect atleast one device to get started</Subtle>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div>
</div>
);
};

10
src/components/DeviceSelector.tsx

@ -11,7 +11,6 @@ import {
TerminalIcon TerminalIcon
} from "lucide-react"; } from "lucide-react";
import { Separator } from "./UI/Seperator.js"; import { Separator } from "./UI/Seperator.js";
import { cn } from "@app/core/utils/cn.js";
import { Code } from "./UI/Typography/Code.js"; import { Code } from "./UI/Typography/Code.js";
import { SidebarButton } from "./SidebarButton.js"; import { SidebarButton } from "./SidebarButton.js";
@ -27,10 +26,15 @@ export const DeviceSelector = (): JSX.Element => {
} = useAppStore(); } = useAppStore();
return ( return (
<nav className="drag custom-border flex flex-col justify-between border-r-[0.5px] bg-transparent pt-2"> <nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 bg-transparent pt-2 dark:border-slate-700">
<div className="flex flex-col overflow-y-hidden"> <div className="flex flex-col overflow-y-hidden">
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5"> <ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
<SidebarButton active={false} onClick={() => {}}> <SidebarButton
active={selectedDevice === 0}
onClick={() => {
setSelectedDevice(0);
}}
>
<HomeIcon /> <HomeIcon />
</SidebarButton> </SidebarButton>
{getDevices().map((device) => ( {getDevices().map((device) => (

26
src/components/Dialog/DeviceNameDialog.tsx

@ -1,4 +1,4 @@
import { Input } from "@components/form/Input.js"; import { Input } from "@components/UI/Input.js";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -7,10 +7,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { Button } from "../form/Button.js"; import { Button } from "@components/UI/Button.js";
import { useDevice } from "@app/core/stores/deviceStore.js"; import { useDevice } from "@app/core/stores/deviceStore.js";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { Label } from "../UI/Label.js";
export interface User { export interface User {
longName: string; longName: string;
@ -31,9 +32,9 @@ export const DeviceNameDialog = ({
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum); const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
const { register, handleSubmit } = useForm<User>({ const { register, handleSubmit } = useForm<User>({
defaultValues: { values: {
longName: myNode?.data.user?.longName, longName: myNode?.data.user?.longName ?? "Unknown",
shortName: myNode?.data.user?.shortName shortName: myNode?.data.user?.shortName ?? "Unknown"
} }
}); });
@ -58,17 +59,10 @@ export const DeviceNameDialog = ({
</DialogHeader> </DialogHeader>
<div className="gap-4"> <div className="gap-4">
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Input <Label>Long Name</Label>
label="Long Name" <Input {...register("longName")} />
description="Personalised name for this device." <Label>Short Name</Label>
{...register("longName")} <Input maxLength={4} {...register("shortName")} />
/>
<Input
label="Short Name"
description="Shown on small screens."
maxLength={4}
{...register("shortName")}
/>
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>

15
src/components/Dialog/ImportDialog.tsx

@ -11,10 +11,8 @@ import {
DialogTitle DialogTitle
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { Select } from "@components/form/Select.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/UI/Button.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
export interface ImportDialogProps { export interface ImportDialogProps {
@ -97,27 +95,26 @@ export const ImportDialog = ({
<div className="flex w-full gap-2"> <div className="flex w-full gap-2">
<div className="w-36"> <div className="w-36">
<Toggle <Toggle
className="flex-col gap-2"
label="Use Preset?" label="Use Preset?"
disabled disabled
checked={channelSet?.loraConfig?.usePreset ?? true} checked={channelSet?.loraConfig?.usePreset ?? true}
/> />
</div> </div>
<Select {/* <Select
label="Modem Preset" label="Modem Preset"
disabled disabled
value={channelSet?.loraConfig?.modemPreset} value={channelSet?.loraConfig?.modemPreset}
> >
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)} {renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</Select> </Select> */}
</div> </div>
<Select {/* <Select
label="Region" label="Region"
disabled disabled
value={channelSet?.loraConfig?.region} value={channelSet?.loraConfig?.region}
> >
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)} {renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</Select> </Select> */}
<span className="text-md block font-medium text-textPrimary"> <span className="text-md block font-medium text-textPrimary">
Channels: Channels:
@ -138,7 +135,7 @@ export const ImportDialog = ({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={() => apply()} disabled={!validURL}> <Button onClick={apply} disabled={!validURL}>
Apply Apply
</Button> </Button>
</DialogFooter> </DialogFooter>

4
src/components/Dialog/QRDialog.tsx

@ -64,10 +64,6 @@ export const QRDialog = ({
{channels.map((channel) => ( {channels.map((channel) => (
<Checkbox <Checkbox
key={channel.index} key={channel.index}
disabled={
channel.index === 0 ||
channel.role === Protobuf.Channel_Role.DISABLED
}
label={ label={
channel.settings?.name.length channel.settings?.name.length
? channel.settings.name ? channel.settings.name

2
src/components/Dialog/RebootDialog.tsx

@ -9,7 +9,7 @@ import {
DialogTitle DialogTitle
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { ClockIcon, RefreshCwIcon } from "lucide-react"; import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/UI/Button.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
export interface RebootDialogProps { export interface RebootDialogProps {

2
src/components/Dialog/ShutdownDialog.tsx

@ -8,7 +8,7 @@ import {
DialogTitle DialogTitle
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { ClockIcon, PowerIcon } from "lucide-react"; import { ClockIcon, PowerIcon } from "lucide-react";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/UI/Button.js";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
export interface ShutdownDialogProps { export interface ShutdownDialogProps {

227
src/components/DynamicForm.tsx

@ -0,0 +1,227 @@
import {
Controller,
DeepPartial,
FieldValues,
Path,
SubmitHandler,
useForm
} from "react-hook-form";
import { Input } from "./UI/Input.js";
import { Label } from "./UI/Label.js";
import { ErrorMessage } from "@hookform/error-message";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./UI/Select.js";
import { Switch } from "./UI/Switch.js";
import { H4 } from "./UI/Typography/H4.js";
import { Subtle } from "./UI/Typography/Subtle.js";
interface DisabledBy<T> {
fieldName: Path<T>;
selector?: number;
invert?: boolean;
}
interface BasicFieldProps<T> {
name: Path<T>;
label: string;
description?: string;
active?: boolean;
required?: boolean;
disabledBy?: DisabledBy<T>[];
}
interface InputFieldProps<T> extends BasicFieldProps<T> {
type: "text" | "number" | "password" | "textarea";
suffix?: string;
}
interface SelectFieldProps<T> extends BasicFieldProps<T> {
type: "select";
enumValue: {
[s: string]: string | number;
};
formatEnumName?: boolean;
}
interface ToggleFieldProps<T> extends BasicFieldProps<T> {
type: "toggle";
}
export interface FormProps<T extends FieldValues> {
onSubmit: SubmitHandler<T>;
defaultValues?: DeepPartial<T>;
fieldGroups: {
label: string;
description: string;
fields: (InputFieldProps<T> | SelectFieldProps<T> | ToggleFieldProps<T>)[];
}[];
}
export function DynamicForm<T extends FieldValues>({
fieldGroups,
onSubmit,
defaultValues
}: FormProps<T>) {
const { register, handleSubmit, control, getValues } = useForm<T>({
mode: "onChange",
defaultValues: defaultValues
});
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
if (!disabledBy) return false;
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
return (
(typeof value === "boolean" && field.invert ? value : !value) ||
(typeof value === "number" &&
(field.selector && field.invert
? !field.selector === value
: field.selector === value))
);
});
};
return (
<form
className="space-y-8 divide-y divide-gray-200"
onChange={handleSubmit(onSubmit)}
>
{fieldGroups.map((fieldGroup) => (
<div className="space-y-8 divide-y divide-gray-200 sm:space-y-5">
<div>
<H4 className="font-medium">{fieldGroup.label}</H4>
<Subtle>{fieldGroup.description}</Subtle>
</div>
{fieldGroup.fields.map((field) => {
const fieldWrapperData: FieldWrapperProps = {
label: field.label,
description: field.description,
disabled: isDisabled(field.disabledBy)
};
switch (field.type) {
case "text":
return (
<FieldWrapper {...fieldWrapperData}>
<Input
disabled={fieldWrapperData.disabled}
{...register(field.name)}
/>
</FieldWrapper>
);
case "number":
return (
<FieldWrapper {...fieldWrapperData}>
<Input
type="number"
disabled={fieldWrapperData.disabled}
{...register(field.name)}
/>
</FieldWrapper>
);
case "toggle":
return (
<FieldWrapper {...fieldWrapperData}>
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
<Switch
checked={value}
onCheckedChange={onChange}
disabled={fieldWrapperData.disabled}
{...rest}
/>
)}
/>
</FieldWrapper>
);
case "select":
const optionsEnumValues = field.enumValue
? Object.entries(field.enumValue).filter(
(value) => typeof value[1] === "number"
)
: [];
return (
<FieldWrapper {...fieldWrapperData}>
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
<Select
onValueChange={(e) => onChange(parseInt(e))}
disabled={fieldWrapperData.disabled}
value={value?.toString()}
{...rest}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, value], index) => (
<SelectItem key={index} value={value.toString()}>
{field.formatEnumName
? name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map(
(s) =>
s.charAt(0).toUpperCase() +
s.substring(1)
)
.join(" ")
: name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldWrapper>
);
}
})}
</div>
))}
</form>
);
}
interface FieldWrapperProps {
label: string;
description?: string;
disabled?: boolean;
children?: React.ReactNode;
}
const FieldWrapper = ({
label,
description,
disabled,
children
}: FieldWrapperProps): JSX.Element => (
<div className="pt-6 sm:pt-5">
<div role="group" aria-labelledby="label-notifications">
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
<Label>{label}</Label>
<div className="sm:col-span-2">
<div className="max-w-lg">
<p className="text-sm text-gray-500">{description}</p>
<div className="mt-4 space-y-4">
<div className="flex items-center">{children}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);

57
src/components/Menu.tsx

@ -1,57 +0,0 @@
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>
);
};

77
src/components/NewDevice.tsx

@ -1,77 +0,0 @@
import { BLE } from "@components/PageComponents/Connect/BLE.js";
import { HTTP } from "@components/PageComponents/Connect/HTTP.js";
import { Serial } from "@components/PageComponents/Connect/Serial.js";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./UI/Tabs.js";
import { Subtle } from "./UI/Typography/Subtle.js";
import { Link } from "./UI/Typography/Link.js";
export const NewDevice = () => {
const tabs = [
{
label: "HTTP",
element: HTTP,
disabled: false,
disabledMessage: "Unsuported connection method"
},
{
label: "Bluetooth",
element: BLE,
disabled: !navigator.bluetooth,
disabledMessage:
"Web Bluetooth is currently only supported by Chromium-based browsers",
disabledLink:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility"
},
{
label: "Serial",
element: Serial,
disabled: !navigator.serial,
disabledMessage:
"WebSerial is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility"
}
];
return (
<div className="m-auto">
<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>
);
};

21
src/components/PageComponents/Channel.tsx

@ -2,9 +2,7 @@ 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 { ChannelSettingsValidation } from "@app/validation/channelSettings.js"; import { ChannelSettingsValidation } from "@app/validation/channelSettings.js";
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 { Toggle } from "@components/form/Toggle.js"; import { Toggle } from "@components/form/Toggle.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { RefreshCwIcon, EyeIcon, EyeOffIcon } from "lucide-react"; import { RefreshCwIcon, EyeIcon, EyeOffIcon } from "lucide-react";
@ -88,17 +86,8 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}); });
return ( return (
<div className="flex flex-grow flex-col gap-2"> <div className="p-3">
{/* actions={[ <form onSubmit={onSubmit}>
{
label: "Apply",
async onClick() {
await onSubmit();
}
}
]} */}
<Form onSubmit={onSubmit}>
{channel?.index !== 0 && ( {channel?.index !== 0 && (
<> <>
<Controller <Controller
@ -121,7 +110,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
/> />
</> </>
)} )}
<Select {/* <Select
label="Key Size" label="Key Size"
description="Desired size of generated key." description="Desired size of generated key."
value={keySize} value={keySize}
@ -141,7 +130,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
> >
<option value={128}>128 Bit</option> <option value={128}>128 Bit</option>
<option value={256}>256 Bit</option> <option value={256}>256 Bit</option>
</Select> </Select> */}
<Input <Input
width="100%" width="100%"
label="Pre-Shared Key" label="Pre-Shared Key"
@ -180,7 +169,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
/> />
)} )}
/> />
</Form> </form>
</div> </div>
); );
}; };

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

@ -1,30 +1,12 @@
import { useEffect } from "react"; import type { BluetoothValidation } from "@app/validation/config/bluetooth.js";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js";
import { BluetoothValidation } from "@app/validation/config/bluetooth.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { DynamicForm } from "@app/components/DynamicForm.js";
export const Bluetooth = (): JSX.Element => { export const Bluetooth = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { register, handleSubmit, control, reset } = const onSubmit = (data: BluetoothValidation) => {
useForm<BluetoothValidation>({
mode: "onChange",
defaultValues: config.bluetooth,
resolver: classValidatorResolver(BluetoothValidation)
});
useEffect(() => {
reset(config.bluetooth);
}, [reset, config.bluetooth]);
const onSubmit = handleSubmit((data) => {
setWorkingConfig( setWorkingConfig(
new Protobuf.Config({ new Protobuf.Config({
payloadVariant: { payloadVariant: {
@ -33,47 +15,55 @@ export const Bluetooth = (): JSX.Element => {
} }
}) })
); );
}); };
const pairingMode = useWatch({
control,
name: "mode",
defaultValue: Protobuf.Config_BluetoothConfig_PairingMode.RANDOM_PIN
});
return ( return (
<Form onSubmit={onSubmit}> <DynamicForm<BluetoothValidation>
<Controller onSubmit={onSubmit}
name="enabled" defaultValues={config.bluetooth}
control={control} fieldGroups={[
render={({ field: { value, ...rest } }) => ( {
<Toggle label: "Bluetooth Settings",
label="Enabled" description: "Settings for the Bluetooth module",
description="Enable or disable Bluetooth" fields: [
checked={value} {
{...rest} type: "toggle",
/> name: "enabled",
)} label: "Enabled",
/> description: "Enable or disable Bluetooth"
<Select },
label="Pairing mode" {
description="Pin selection behaviour." type: "select",
{...register("mode", { valueAsNumber: true })} name: "mode",
> label: "Pairing mode",
{renderOptions(Protobuf.Config_BluetoothConfig_PairingMode)} description: "Pin selection behaviour.",
</Select> enumValue: Protobuf.Config_BluetoothConfig_PairingMode,
disabledBy: [
<Input {
disabled={ fieldName: "enabled"
pairingMode !== Protobuf.Config_BluetoothConfig_PairingMode.FIXED_PIN }
]
},
{
type: "number",
name: "fixedPin",
label: "Pin",
description: "Pin to use when pairing",
disabledBy: [
{
fieldName: "mode",
selector:
Protobuf.Config_BluetoothConfig_PairingMode.FIXED_PIN,
invert: true
},
{
fieldName: "enabled"
}
]
}
]
} }
label="Pin" ]}
description="Pin to use when pairing"
type="number"
{...register("fixedPin", {
valueAsNumber: true
})}
/> />
</Form>
); );
}; };

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

@ -1,28 +1,12 @@
import { useEffect } from "react"; import type { DeviceValidation } from "@app/validation/config/device.js";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js";
import { DeviceValidation } from "@app/validation/config/device.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { DynamicForm } from "@app/components/DynamicForm.js";
export const Device = (): JSX.Element => { export const Device = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { register, handleSubmit, control, reset } = useForm<DeviceValidation>({
mode: "onChange",
defaultValues: config.device,
resolver: classValidatorResolver(DeviceValidation)
});
useEffect(() => { const onSubmit = (data: DeviceValidation) => {
reset(config.device);
}, [reset, config.device]);
const onSubmit = handleSubmit((data) => {
setWorkingConfig( setWorkingConfig(
new Protobuf.Config({ new Protobuf.Config({
payloadVariant: { payloadVariant: {
@ -31,53 +15,66 @@ export const Device = (): JSX.Element => {
} }
}) })
); );
}); };
return ( return (
<Form onSubmit={onSubmit}> <DynamicForm<DeviceValidation>
<Select onSubmit={onSubmit}
label="Role" defaultValues={config.device}
description="What role the device performs on the mesh" fieldGroups={[
{...register("role", { valueAsNumber: true })} {
> label: "Device Settings",
{renderOptions(Protobuf.Config_DeviceConfig_Role)} description: "Settings for the device",
</Select> fields: [
<Controller {
name="serialEnabled" type: "select",
control={control} name: "role",
render={({ field: { value, ...rest } }) => ( label: "Role",
<Toggle description: "What role the device performs on the mesh",
label="Serial Output Enabled" enumValue: Protobuf.Config_DeviceConfig_Role
description="Disable the device's serial console" },
checked={value} {
{...rest} type: "toggle",
/> name: "serialEnabled",
)} label: "Serial Output Enabled",
/> description: "Disable the device's serial console"
<Controller },
name="debugLogEnabled" {
control={control} type: "toggle",
render={({ field: { value, ...rest } }) => ( name: "debugLogEnabled",
<Toggle label: "Enabled Debug Log",
label="Enabled Debug Log" description:
description="Output debugging information to the device's serial port (auto disables when serial client is connected)" "Output debugging information to the device's serial port (auto disables when serial client is connected)"
checked={value} },
{...rest} {
/> type: "number",
)} name: "buttonGpio",
/> label: "Button Pin",
<Input description: "Button pin override"
label="Button Pin" },
description="Button pin override" {
type="number" type: "number",
{...register("buttonGpio", { valueAsNumber: true })} name: "buzzerGpio",
/> label: "Buzzer Pin",
<Input description: "Buzzer pin override"
label="Buzzer Pin" },
description="Buzzer pin override" {
type="number" type: "select",
{...register("buzzerGpio", { valueAsNumber: true })} name: "rebroadcastMode",
label: "Rebroadcast Mode",
description: "How to handle rebroadcasting",
enumValue: Protobuf.Config_DeviceConfig_RebroadcastMode
},
{
type: "number",
name: "nodeInfoBroadcastSecs",
label: "Node Info Broadcast Interval",
description: "How often to broadcast node info",
suffix: "Seconds"
}
]
}
]}
/> />
</Form>
); );
}; };

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

@ -1,30 +1,12 @@
import { useEffect } from "react"; import type { DisplayValidation } from "@app/validation/config/display.js";
import { Controller, useForm } from "react-hook-form";
import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js";
import { DisplayValidation } from "@app/validation/config/display.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { DynamicForm } from "@app/components/DynamicForm.js";
export const Display = (): JSX.Element => { export const Display = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { register, handleSubmit, reset, control } = useForm<DisplayValidation>(
{
mode: "onChange",
defaultValues: config.display,
resolver: classValidatorResolver(DisplayValidation)
}
);
useEffect(() => { const onSubmit = (data: DisplayValidation) => {
reset(config.display);
}, [reset, config.display]);
const onSubmit = handleSubmit((data) => {
setWorkingConfig( setWorkingConfig(
new Protobuf.Config({ new Protobuf.Config({
payloadVariant: { payloadVariant: {
@ -33,88 +15,78 @@ export const Display = (): JSX.Element => {
} }
}) })
); );
}); };
return ( return (
<Form onSubmit={onSubmit}> <DynamicForm<DisplayValidation>
<Input onSubmit={onSubmit}
label="Screen Timeout" defaultValues={config.display}
description="Turn off the display after this long" fieldGroups={[
suffix="Seconds" {
type="number" label: "Display Settings",
{...register("screenOnSecs", { valueAsNumber: true })} description: "Settings for the device display",
/> fields: [
<Input {
label="Carousel Delay" type: "number",
description="How fast to cycle through windows" name: "screenOnSecs",
suffix="Seconds" label: "Screen Timeout",
type="number" description: "Turn off the display after this long"
{...register("autoScreenCarouselSecs", { valueAsNumber: true })} },
/> {
<Select type: "select",
label="GPS Display Units" name: "gpsFormat",
description="Coordinate display format" label: "GPS Display Units",
{...register("gpsFormat", { valueAsNumber: true })} description: "Coordinate display format",
> enumValue: Protobuf.Config_DisplayConfig_GpsCoordinateFormat
{renderOptions(Protobuf.Config_DisplayConfig_GpsCoordinateFormat)} },
</Select> {
<Controller type: "number",
name="compassNorthTop" name: "autoScreenCarouselSecs",
control={control} label: "Carousel Delay",
render={({ field: { value, ...rest } }) => ( description: "How fast to cycle through windows"
<Toggle },
label="Compass North Top" {
description="Fix north to the top of compass" type: "toggle",
checked={value} name: "compassNorthTop",
{...rest} label: "Compass North Top",
/> description: "Fix north to the top of compass"
)} },
/> {
<Controller type: "toggle",
name="flipScreen" name: "flipScreen",
control={control} label: "Flip Screen",
render={({ field: { value, ...rest } }) => ( description: "Flip display 180 degrees"
<Toggle },
label="Flip Screen" {
description="Flip display 180 degrees" type: "select",
checked={value} name: "units",
{...rest} label: "Display Units",
/> description: "Display metric or imperial units",
)} enumValue: Protobuf.Config_DisplayConfig_DisplayUnits
/> },
<Select {
label="Display Units" type: "select",
description="Display metric or imperial units" name: "oled",
{...register("units", { valueAsNumber: true })} label: "OLED Type",
> description: "Type of OLED screen attached to the device",
{renderOptions(Protobuf.Config_DisplayConfig_DisplayUnits)} enumValue: Protobuf.Config_DisplayConfig_OledType
</Select> },
<Select {
label="OLED Type" type: "select",
description="Type of OLED screen attached to the device" name: "displaymode",
{...register("oled", { valueAsNumber: true })} label: "Display Mode",
> description: "Screen layout variant",
{renderOptions(Protobuf.Config_DisplayConfig_OledType)} enumValue: Protobuf.Config_DisplayConfig_DisplayMode
</Select> },
<Select {
label="Display Mode" type: "toggle",
description="Screen layout variant" name: "headingBold",
{...register("displaymode", { valueAsNumber: true })} label: "Bold Heading",
> description: "Bolden the heading text"
{renderOptions(Protobuf.Config_DisplayConfig_DisplayMode)} }
</Select> ]
<Controller }
name="headingBold" ]}
control={control}
render={({ field: { value, ...rest } }) => (
<Toggle
label="Bold Heading"
description="Bolden the heading text"
checked={value}
{...rest}
/>
)}
/> />
</Form>
); );
}; };

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

@ -1,36 +1,12 @@
import { useEffect } from "react"; import type { LoRaValidation } from "@app/validation/config/lora.js";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormSection } from "@components/form/FormSection.js";
import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js";
import { LoRaValidation } from "@app/validation/config/lora.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { DynamicForm } from "@app/components/DynamicForm.js";
export const LoRa = (): JSX.Element => { export const LoRa = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { register, handleSubmit, control, reset } = useForm<LoRaValidation>({ const onSubmit = (data: LoRaValidation) => {
mode: "onChange",
defaultValues: config.lora,
resolver: classValidatorResolver(LoRaValidation)
});
const usePreset = useWatch({
control,
name: "usePreset",
defaultValue: true
});
useEffect(() => {
reset(config.lora);
}, [reset, config.lora]);
const onSubmit = handleSubmit((data) => {
setWorkingConfig( setWorkingConfig(
new Protobuf.Config({ new Protobuf.Config({
payloadVariant: { payloadVariant: {
@ -39,121 +15,148 @@ export const LoRa = (): JSX.Element => {
} }
}) })
); );
}); };
return ( return (
<Form onSubmit={onSubmit}> <DynamicForm<LoRaValidation>
<FormSection title="Modem Settings"> onSubmit={onSubmit}
<Controller defaultValues={config.lora}
name="usePreset" fieldGroups={[
control={control} {
render={({ field: { value, ...rest } }) => ( label: "Mesh Settings",
<Toggle description: "Settings for the LoRa mesh",
label="Use Preset" fields: [
description="Use one of the predefined modem presets" {
checked={value} type: "select",
{...rest} name: "region",
/> label: "Region",
)} description: "Sets the region for your node",
/> enumValue: Protobuf.Config_LoRaConfig_RegionCode
{usePreset ? ( },
<Select {
label="Preset" type: "number",
description="Modem preset to use" name: "hopLimit",
{...register("modemPreset", { valueAsNumber: true })} label: "Hop Limit",
> description: "Maximum number of hops"
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)} },
</Select> {
) : ( type: "number",
<> name: "channelNum",
<Input label: "Channel Number",
label="Bandwidth" description: "LoRa channel number"
description="Channel bandwidth in MHz" }
type="number" ]
suffix="MHz" },
{...register("bandwidth", { {
valueAsNumber: true label: "Waveform Settings",
})} description: "Settings for the LoRa waveform",
/> fields: [
<Input {
label="Spread Factor" type: "toggle",
description="Indicates the number of chirps per symbol" name: "usePreset",
type="number" label: "Use Preset",
suffix="CPS" description: "Use one of the predefined modem presets"
{...register("spreadFactor", { },
valueAsNumber: true {
})} type: "select",
/> name: "modemPreset",
<Input label: "Modem Preset",
label="Coding Rate" description: "Modem preset to use",
description="The denominator of the coding rate" enumValue: Protobuf.Config_LoRaConfig_ModemPreset,
type="number" formatEnumName: true,
{...register("codingRate", { disabledBy: [
valueAsNumber: true {
})} fieldName: "usePreset"
/> }
</> ]
)} },
</FormSection> {
<FormSection title="Radio Settings"> type: "number",
<Controller name: "bandwidth",
name="txEnabled" label: "Bandwidth",
control={control} description: "Channel bandwidth in MHz",
render={({ field: { value, ...rest } }) => ( suffix: "MHz",
<Toggle disabledBy: [
label="Transmit Enabled" {
description="Enable/Disable transmit (TX) from the LoRa radio" fieldName: "usePreset",
checked={value} invert: true
{...rest} }
/> ]
)} },
/> {
<Select type: "number",
label="Region" name: "spreadFactor",
description="Sets the region for your node" label: "Spreading Factor",
{...register("region", { valueAsNumber: true })} description: "Indicates the number of chirps per symbol",
> suffix: "CPS",
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)} disabledBy: [
</Select> {
<Input fieldName: "usePreset",
label="Transmit Power" invert: true
description="Max transmit power in dBm" }
type="number" ]
{...register("txPower", { valueAsNumber: true })} },
/> {
<Input type: "number",
label="Channel Number" name: "codingRate",
description="LoRa channel number" label: "Coding Rate",
type="number" description: "The denominator of the coding rate",
{...register("channelNum", { valueAsNumber: true })} disabledBy: [
/> {
<Input fieldName: "usePreset",
label="Frequency Offset" invert: true
description="Frequency offset to correct for crystal calibration errors" }
suffix="Hz" ]
type="number" }
{...register("frequencyOffset", { valueAsNumber: true })} ]
/> },
<Controller {
name="overrideDutyCycle" label: "Radio Settings",
control={control} description: "Settings for the LoRa radio",
render={({ field: { value, ...rest } }) => ( fields: [
<Toggle {
label="Override Duty Cycle" type: "toggle",
description="Description" name: "txEnabled",
checked={value} label: "Tramsmit Enabled",
{...rest} description: "Enable/Disable transmit (TX) from the LoRa radio"
/> },
)} {
/> type: "number",
</FormSection> name: "txPower",
<Input label: "Transmit Power",
label="Hop Limit" description: "Max transmit power",
description="Maximum number of hops" suffix: "dBm"
suffix="Hops" },
type="number" {
{...register("hopLimit", { valueAsNumber: true })} type: "toggle",
name: "overrideDutyCycle",
label: "Override Duty Cycle",
description: "Override Duty Cycle"
},
{
type: "number",
name: "frequencyOffset",
label: "Frequency Offset",
description:
"Frequency offset to correct for crystal calibration errors",
suffix: "Hz"
},
{
type: "toggle",
name: "sx126xRxBoostedGain",
label: "Boosted RX Gain",
description: "Boosted RX gain"
},
{
type: "number",
name: "overrideFrequency",
label: "Override Frequency",
description: "Override frequency",
suffix: "Hz"
}
]
}
]}
/> />
</Form>
); );
}; };

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

@ -1,51 +1,12 @@
import { useEffect } from "react"; import type { NetworkValidation } from "@app/validation/config/network.js";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormSection } from "@components/form/FormSection.js";
import { Input } from "@components/form/Input.js";
import { IPInput } from "@components/form/IPInput.js";
import { Select } from "@components/form/Select.js";
import { Toggle } from "@components/form/Toggle.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { NetworkValidation } from "@app/validation/config/network.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { ErrorMessage } from "@hookform/error-message";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { DynamicForm } from "@app/components/DynamicForm.js";
export const Network = (): JSX.Element => { export const Network = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { register, handleSubmit, control, reset } = useForm<NetworkValidation>(
{
mode: "onChange",
defaultValues: config.network,
resolver: classValidatorResolver(NetworkValidation)
}
);
const wifiEnabled = useWatch({
control,
name: "wifiEnabled",
defaultValue: false
});
const ethEnabled = useWatch({
control,
name: "ethEnabled",
defaultValue: false
});
const ethMode = useWatch({ const onSubmit = (data: NetworkValidation) => {
control,
name: "addressMode",
defaultValue: Protobuf.Config_NetworkConfig_AddressMode.DHCP
});
useEffect(() => {
reset(config.network);
}, [reset, config.network]);
const onSubmit = handleSubmit((data) => {
setWorkingConfig( setWorkingConfig(
new Protobuf.Config({ new Protobuf.Config({
payloadVariant: { payloadVariant: {
@ -54,97 +15,143 @@ export const Network = (): JSX.Element => {
} }
}) })
); );
}); };
return ( return (
<Form onSubmit={onSubmit}> <DynamicForm<NetworkValidation>
<FormSection title="WiFi Config"> onSubmit={onSubmit}
<Controller defaultValues={config.network}
name="wifiEnabled" fieldGroups={[
control={control} {
render={({ field: { value, ...rest } }) => ( label: "WiFi Config",
<Toggle description: "WiFi radio configuration",
label="Enabled" fields: [
description="Enable or disable the WiFi radio" {
checked={value} type: "toggle",
{...rest} name: "wifiEnabled",
/> label: "Enabled",
)} description: "Enable or disable the WiFi radio"
/> },
<Input {
label="SSID" type: "text",
description="Network name" name: "wifiSsid",
disabled={!wifiEnabled} label: "SSID",
{...register("wifiSsid", { disabled: !wifiEnabled })} description: "Network name",
/> disabledBy: [
<Input {
label="PSK" fieldName: "wifiEnabled"
type="password" }
description="Network password" ]
disabled={!wifiEnabled} },
{...register("wifiPsk", { disabled: !wifiEnabled })} {
/> type: "password",
</FormSection> name: "wifiPsk",
<FormSection title="Ethernet Config"> label: "PSK",
<Controller description: "Network password",
name="ethEnabled" disabledBy: [
control={control} {
render={({ field: { value, ...rest } }) => ( fieldName: "wifiEnabled"
<Toggle }
label="Enabled" ]
description="Enable or disbale the Ethernet port" }
checked={value} ]
{...rest} },
/> {
)} label: "Ethernet Config",
/> description: "Ethernet port configuration",
</FormSection> fields: [
<FormSection title="IP Config"> {
<Select type: "toggle",
label="Address Mode" name: "ethEnabled",
description="Address assignment selection" label: "Enabled",
disabled={!(ethEnabled || wifiEnabled)} description: "Enable or disable the Ethernet port"
{...register("addressMode", { }
valueAsNumber: true ]
})} },
> {
{renderOptions(Protobuf.Config_NetworkConfig_AddressMode)} label: "IP Config",
</Select> description: "IP configuration",
{ethMode === Protobuf.Config_NetworkConfig_AddressMode.STATIC && ( fields: [
<> {
<IPInput type: "select",
label="IP" name: "addressMode",
description="IP Address" label: "Address Mode",
{...register("ipv4Config.ip", { valueAsNumber: true })} description: "Address assignment selection",
/> enumValue: Protobuf.Config_NetworkConfig_AddressMode
<IPInput },
label="Gateway" {
description="Default Gateway" type: "text",
{...register("ipv4Config.gateway", { valueAsNumber: true })} name: "ipv4Config.ip",
/> label: "IP",
<IPInput description: "IP Address",
label="Subnet" disabledBy: [
description="Subnet Mask" {
{...register("ipv4Config.subnet", { valueAsNumber: true })} fieldName: "addressMode",
/> selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP
<IPInput }
label="DNS" ]
description="DNS Server" },
{...register("ipv4Config.dns", { valueAsNumber: true })} {
/> type: "text",
</> name: "ipv4Config.gateway",
)} label: "Gateway",
</FormSection> description: "Default Gateway",
<Input disabledBy: [
label="NTP Server" {
description="NTP server for time synchronization" fieldName: "addressMode",
{...register("ntpServer")} selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP
/> }
<Input ]
label="Rsyslog Server" },
description="Rsyslog server for external logging" {
{...register("rsyslogServer")} type: "text",
name: "ipv4Config.subnet",
label: "Subnet",
description: "Subnet Mask",
disabledBy: [
{
fieldName: "addressMode",
selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP
}
]
},
{
type: "text",
name: "ipv4Config.dns",
label: "DNS",
description: "DNS Server",
disabledBy: [
{
fieldName: "addressMode",
selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP
}
]
}
]
},
{
label: "NTP Config",
description: "NTP configuration",
fields: [
{
type: "text",
name: "ntpServer",
label: "NTP Server"
}
]
},
{
label: "Rsyslog Config",
description: "Rsyslog configuration",
fields: [
{
type: "text",
name: "rsyslogServer",
label: "Rsyslog Server"
}
]
}
]}
/> />
</Form>
); );
}; };

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

@ -5,7 +5,6 @@ import { FormSection } from "@components/form/FormSection.js";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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";
@ -110,7 +109,7 @@ export const Position = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="gpsEnabled" name="gpsEnabled"
control={control} control={control}
@ -227,6 +226,6 @@ export const Position = (): JSX.Element => {
type="number" type="number"
{...register("txGpio", { valueAsNumber: true })} {...register("txGpio", { valueAsNumber: true })}
/> />
</Form> </form>
); );
}; };

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

@ -1,27 +1,12 @@
import { useEffect } from "react"; import type { PowerValidation } from "@app/validation/config/power.js";
import { Controller, useForm } from "react-hook-form";
import { FormSection } from "@components/form/FormSection.js";
import { Input } from "@components/form/Input.js";
import { Toggle } from "@components/form/Toggle.js";
import { PowerValidation } from "@app/validation/config/power.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
import { DynamicForm } from "@app/components/DynamicForm.js";
export const Power = (): JSX.Element => { export const Power = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { register, handleSubmit, reset, control } = useForm<PowerValidation>({
mode: "onChange",
defaultValues: config.power,
resolver: classValidatorResolver(PowerValidation)
});
useEffect(() => { const onSubmit = (data: PowerValidation) => {
reset(config.power);
}, [reset, config.power]);
const onSubmit = handleSubmit((data) => {
setWorkingConfig( setWorkingConfig(
new Protobuf.Config({ new Protobuf.Config({
payloadVariant: { payloadVariant: {
@ -30,72 +15,86 @@ export const Power = (): JSX.Element => {
} }
}) })
); );
}); };
return ( return (
<Form onSubmit={onSubmit}> <DynamicForm<PowerValidation>
<Input onSubmit={onSubmit}
label="Shutdown on battery delay" defaultValues={config.power}
description="Automatically shutdown node after this long when on battery, 0 for indefinite" fieldGroups={[
suffix="Seconds" {
type="number" label: "Power Config",
{...register("onBatteryShutdownAfterSecs", { valueAsNumber: true })} description: "Settings for the power module",
/> fields: [
<Controller {
name="isPowerSaving" type: "toggle",
control={control} name: "isPowerSaving",
render={({ field: { value, ...rest } }) => ( label: "Enable power saving mode",
<Toggle description:
label="Enable power saving mode" "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible."
description="Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible." },
checked={value} {
{...rest} type: "number",
/> name: "onBatteryShutdownAfterSecs",
)} label: "Shutdown on battery delay",
/> description:
<Input "Automatically shutdown node after this long when on battery, 0 for indefinite",
label="ADC Multiplier Override ratio" suffix: "Seconds"
description="Used for tweaking battery voltage reading" },
type="number" {
{...register("adcMultiplierOverride", { valueAsNumber: true })} type: "number",
/> name: "adcMultiplierOverride",
<FormSection title="Sleep Settings"> label: "ADC Multiplier Override ratio",
<Input description: "Used for tweaking battery voltage reading"
label="Minimum Wake Time" },
description="Minimum amount of time the device will stay awake for after receiving a packet" {
suffix="Seconds" type: "number",
type="number" name: "waitBluetoothSecs",
{...register("minWakeSecs", { valueAsNumber: true })} label: "No Connection Bluetooth Disabled",
/> description:
<Input "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
label="Mesh SDS Timeout" suffix: "Seconds"
description="The device will enter super deep sleep after this time" }
suffix="Seconds" ]
type="number" },
{...register("meshSdsTimeoutSecs", { valueAsNumber: true })} {
/> label: "Sleep Settings",
<Input description: "Sleep settings for the power module",
label="Super Deep Sleep Duration" fields: [
description="How long the device will be in super deep sleep for" {
suffix="Seconds" type: "number",
type="number" name: "meshSdsTimeoutSecs",
{...register("sdsSecs", { valueAsNumber: true })} label: "Mesh SDS Timeout",
/> description:
<Input "The device will enter super deep sleep after this time",
label="Light Sleep Duration" suffix: "Seconds"
description="How long the device will be in light sleep for" },
suffix="Seconds" {
type="number" type: "number",
{...register("lsSecs", { valueAsNumber: true })} name: "sdsSecs",
/> label: "Super Deep Sleep Duration",
</FormSection> description:
<Input "How long the device will be in super deep sleep for",
label="No Connection Bluetooth Disabled" suffix: "Seconds"
description="If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long" },
suffix="Seconds" {
type="number" type: "number",
{...register("waitBluetoothSecs", { valueAsNumber: true })} name: "lsSecs",
label: "Light Sleep Duration",
description: "How long the device will be in light sleep for",
suffix: "Seconds"
},
{
type: "number",
name: "minWakeSecs",
label: "Minimum Wake Time",
description:
"Minimum amount of time the device will stay awake for after receiving a packet",
suffix: "Seconds"
}
]
}
]}
/> />
</Form>
); );
}; };

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

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/UI/Button.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 { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";

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

@ -1,7 +1,7 @@
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.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/UI/Button.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 { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";
@ -70,7 +70,7 @@ export const HTTP = (): JSX.Element => {
)} )}
/> />
</div> </div>
<Button type="submit"> <Button>
<span>Connect</span> <span>Connect</span>
</Button> </Button>
</form> </form>

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

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { Button } from "@components/form/Button.js"; import { Button } from "@components/UI/Button.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 { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";

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

@ -1,9 +1,9 @@
import { Input } from "@components/form/Input.js"; import { Input } from "@components/UI/Input.js";
import { useDevice } from "@core/stores/deviceStore.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 { SendIcon } from "lucide-react"; 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"; import { Button } from "@components/UI/Button.js";
export interface MessageInputProps { export interface MessageInputProps {
channel: Channel; channel: Channel;
@ -42,14 +42,13 @@ export const MessageInput = ({ channel }: MessageInputProps): JSX.Element => {
<Input <Input
autoFocus autoFocus
minLength={2} minLength={2}
label=""
placeholder="Enter Message" placeholder="Enter Message"
value={messageDraft} value={messageDraft}
onChange={(e) => setMessageDraft(e.target.value)} onChange={(e) => setMessageDraft(e.target.value)}
/> />
</span> </span>
<Button variant="subtle"> <Button>
<SendIcon size={16} className="text-slate-500" /> <SendIcon size={16} />
</Button> </Button>
</div> </div>
</form> </form>

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

@ -1,8 +1,6 @@
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
import { Select } from "@components/form/Select.js"; import { Button } from "@components/UI/Button.js";
import { Button } from "@components/form/Button.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
enum LocationType { enum LocationType {
@ -23,9 +21,9 @@ export const NewLocationMessage = (): JSX.Element => {
> >
<Input label="Name" /> <Input label="Name" />
<Input label="Description" /> <Input label="Description" />
<Select label="Type" value={LocationType.MGRS}> {/* <Select label="Type" value={LocationType.MGRS}>
{renderOptions(LocationType)} {renderOptions(LocationType)}
</Select> </Select> */}
<Input label="Coordinates" /> <Input label="Coordinates" />
<Button <Button
onClick={() => { onClick={() => {

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

@ -1,12 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
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 { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.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";
@ -34,7 +31,7 @@ export const Audio = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="codec2Enabled" name="codec2Enabled"
control={control} control={control}
@ -53,13 +50,13 @@ export const Audio = (): JSX.Element => {
type="number" type="number"
{...register("pttPin", { valueAsNumber: true })} {...register("pttPin", { valueAsNumber: true })}
/> />
<Select {/* <Select
label="Bitrate" label="Bitrate"
description="Enter a description." description="Enter a description."
{...register("bitrate", { valueAsNumber: true })} {...register("bitrate", { valueAsNumber: true })}
> >
{renderOptions(Protobuf.ModuleConfig_AudioConfig_Audio_Baud)} {renderOptions(Protobuf.ModuleConfig_AudioConfig_Audio_Baud)}
</Select> </Select> */}
<Input <Input
label="i2SWs" label="i2SWs"
description="Enter a description." description="Enter a description."
@ -84,6 +81,6 @@ export const Audio = (): JSX.Element => {
type="number" type="number"
{...register("i2sSck", { valueAsNumber: true })} {...register("i2sSck", { valueAsNumber: true })}
/> />
</Form> </form>
); );
}; };

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

@ -1,12 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; import { Input } from "@components/form/Input.js";
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 { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.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";
@ -46,7 +43,7 @@ export const CannedMessage = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}
@ -87,7 +84,7 @@ export const CannedMessage = (): JSX.Element => {
disabled={moduleEnabled} disabled={moduleEnabled}
{...register("inputbrokerPinPress", { valueAsNumber: true })} {...register("inputbrokerPinPress", { valueAsNumber: true })}
/> />
<Select {/* <Select
label="Clockwise event" label="Clockwise event"
description="Select input event." description="Select input event."
disabled={moduleEnabled} disabled={moduleEnabled}
@ -116,7 +113,7 @@ export const CannedMessage = (): JSX.Element => {
{renderOptions( {renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)} )}
</Select> </Select> */}
<Controller <Controller
name="updown1Enabled" name="updown1Enabled"
control={control} control={control}
@ -147,6 +144,6 @@ export const CannedMessage = (): JSX.Element => {
/> />
)} )}
/> />
</Form> </form>
); );
}; };

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

@ -3,7 +3,6 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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";
@ -38,7 +37,7 @@ export const ExternalNotification = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}
@ -193,6 +192,6 @@ export const ExternalNotification = (): JSX.Element => {
valueAsNumber: true valueAsNumber: true
})} })}
/> />
</Form> </form>
); );
}; };

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

@ -3,7 +3,6 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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";
@ -44,7 +43,7 @@ export const MQTT = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}
@ -101,6 +100,6 @@ export const MQTT = (): JSX.Element => {
/> />
)} )}
/> />
</Form> </form>
); );
}; };

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

@ -3,7 +3,6 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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";
@ -39,7 +38,7 @@ export const RangeTest = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}
@ -69,6 +68,6 @@ export const RangeTest = (): JSX.Element => {
/> />
)} )}
/> />
</Form> </form>
); );
}; };

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

@ -3,12 +3,9 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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 { Select } from "@components/form/Select";
export const Serial = (): JSX.Element => { export const Serial = (): JSX.Element => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
@ -40,7 +37,7 @@ export const Serial = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}
@ -83,14 +80,14 @@ export const Serial = (): JSX.Element => {
valueAsNumber: true valueAsNumber: true
})} })}
/> />
<Select {/* <Select
label="Baud Rate" label="Baud Rate"
description="The serial baud rate" description="The serial baud rate"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("baud", { valueAsNumber: true })} {...register("baud", { valueAsNumber: true })}
> >
{renderOptions(Protobuf.ModuleConfig_SerialConfig_Serial_Baud)} {renderOptions(Protobuf.ModuleConfig_SerialConfig_Serial_Baud)}
</Select> </Select> */}
<Input <Input
type="number" type="number"
label="Timeout" label="Timeout"
@ -101,14 +98,14 @@ export const Serial = (): JSX.Element => {
valueAsNumber: true valueAsNumber: true
})} })}
/> />
<Select {/* <Select
label="Mode" label="Mode"
description="Select Mode" description="Select Mode"
disabled={!moduleEnabled} disabled={!moduleEnabled}
{...register("mode", { valueAsNumber: true })} {...register("mode", { valueAsNumber: true })}
> >
{renderOptions(Protobuf.ModuleConfig_SerialConfig_Serial_Mode)} {renderOptions(Protobuf.ModuleConfig_SerialConfig_Serial_Mode)}
</Select> </Select> */}
</Form> </form>
); );
}; };

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

@ -3,7 +3,6 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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";
@ -39,7 +38,7 @@ export const StoreForward = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}
@ -92,6 +91,6 @@ export const StoreForward = (): JSX.Element => {
valueAsNumber: true valueAsNumber: true
})} })}
/> />
</Form> </form>
); );
}; };

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

@ -3,7 +3,6 @@ import { Controller, useForm } from "react-hook-form";
import { Input } from "@components/form/Input.js"; 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 { useDevice } from "@core/stores/deviceStore.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";
@ -33,7 +32,7 @@ export const Telemetry = (): JSX.Element => {
}); });
return ( return (
<Form onSubmit={onSubmit}> <form onChange={onSubmit}>
<Controller <Controller
name="environmentMeasurementEnabled" name="environmentMeasurementEnabled"
control={control} control={control}
@ -79,6 +78,6 @@ export const Telemetry = (): JSX.Element => {
/> />
)} )}
/> />
</Form> </form>
); );
}; };

21
src/components/Sidebar.tsx

@ -7,10 +7,13 @@ import {
SettingsIcon, SettingsIcon,
LayersIcon, LayersIcon,
UsersIcon, UsersIcon,
EditIcon EditIcon,
LayoutGrid
} from "lucide-react"; } from "lucide-react";
import { Subtle } from "./UI/Typography/Subtle.js"; import { Subtle } from "./UI/Typography/Subtle.js";
import { SidebarItem } from "./UI/Sidebar/SidebarItem.js"; import { Button } from "./UI/Button.js";
import { SidebarSection } from "./UI/Sidebar/SidebarSection.js";
import { SidebarButton } from "./UI/Sidebar/sidebarButton.js";
export interface SidebarProps { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -56,9 +59,8 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
]; ];
return ( return (
<div className="min-w-[220px] flex-col border-r-[0.5px] bg-transparent"> <div className="min-w-[280px] max-w-min flex-col border-r-[0.5px] border-slate-300 bg-transparent dark:border-slate-700">
<div className="flex h-16 flex-col justify-center px-4"> <div className="flex justify-between px-8 py-6">
<div className="flex items-center justify-between">
<div> <div>
<span className="text-lg font-medium"> <span className="text-lg font-medium">
{myNode?.data.user?.shortName ?? "UNK"} {myNode?.data.user?.shortName ?? "UNK"}
@ -72,22 +74,21 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
<EditIcon size={16} /> <EditIcon size={16} />
</button> </button>
</div> </div>
</div>
<div className="mx-2.5 space-y-2"> <SidebarSection label="Navigation">
{pages.map((link) => ( {pages.map((link) => (
<SidebarItem <SidebarButton
key={link.page} key={link.page}
label={link.name} label={link.name}
icon={link.icon} icon={link.icon}
active={link.page === activePage}
onClick={() => { onClick={() => {
setActivePage(link.page); setActivePage(link.page);
}} }}
active={link.page === activePage}
/> />
))} ))}
</SidebarSection>
{children} {children}
</div> </div>
</div>
); );
}; };

12
src/components/Topbar.tsx

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

156
src/components/UI/Command.tsx

@ -0,0 +1,156 @@
import * as React from "react";
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@core/utils/cn.js";
import { Dialog, DialogContent } from "@components/UI/Dialog.js";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-lg bg-white dark:bg-slate-800",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl [&_[dialog-overlay]]:bg-red-100">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group]]:px-2 [&_[cmdk-group]_+[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700"
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden py-3 px-2 text-slate-700 dark:text-slate-400 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-slate-900 [&_[cmdk-group-heading]]:dark:text-slate-300",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-slate-100 dark:bg-slate-700", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-none aria-selected:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:aria-selected:bg-slate-700",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500",
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
};

24
src/components/UI/Input.tsx

@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@core/utils/cn.js";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
return (
<input
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

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

@ -1,30 +0,0 @@
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>
);

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

@ -1,32 +1,17 @@
import type { LucideIcon } from "lucide-react"; import { H4 } from "../Typography/H4.js";
export interface SidebarSectionProps { export interface SidebarSectionProps {
title: string; label: string;
action?: {
icon: LucideIcon;
onClick: () => void;
};
subheader?: string; subheader?: string;
children: React.ReactNode; children: React.ReactNode;
} }
export const SidebarSection = ({ export const SidebarSection = ({
title, label: title,
action,
children children
}: SidebarSectionProps): JSX.Element => ( }: SidebarSectionProps): JSX.Element => (
<div className="space-y-1.5"> <div className="px-4 py-2">
<div className="mb-2 flex items-center justify-between px-2"> <H4 className="mb-2 ml-2">{title}</H4>
<div className="font-medium">{title}</div> <div className="space-y-1">{children}</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> </div>
); );

29
src/components/UI/Sidebar/sidebarButton.tsx

@ -0,0 +1,29 @@
import type { LucideIcon } from "lucide-react";
import { Button } from "../Button.js";
export interface SidebarButtonProps {
label: string;
active?: boolean;
icon?: LucideIcon;
element?: JSX.Element;
onClick?: () => void;
}
export const SidebarButton = ({
label,
active,
icon: Icon,
element,
onClick
}: SidebarButtonProps): JSX.Element => (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className="w-full justify-start gap-2"
>
{Icon && <Icon size={16} />}
{element && element}
{label}
</Button>
);

22
src/components/UI/Toast.tsx

@ -4,7 +4,6 @@ import { VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@core/utils/cn.js"; import { cn } from "@core/utils/cn.js";
import { buttonVariants } from "./Button.js";
const ToastProvider = ToastPrimitives.Provider; const ToastProvider = ToastPrimitives.Provider;
@ -15,7 +14,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed bottom-6 right-6 z-50 max-h-screen w-full space-y-3 md:max-w-[400px]", "fixed top-0 z-50 flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:bottom-0 sm:right-0 sm:flex-col md:max-w-[420px]",
className className
)} )}
{...props} {...props}
@ -24,17 +23,18 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( 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", "data-[swipe=move]:transition-none grow-1 group relative pointer-events-auto 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-top-full data-[state=open]:sm:slide-in-from-bottom-full mt-4 data-[state=closed]:slide-out-to-right-full dark:border-slate-700 last:mt-0 sm:last:mt-4",
{ {
variants: { variants: {
status: { variant: {
default: default:
"bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700", "bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700",
destructive: "bg-red-500 text-white border-red-500" destructive:
"group destructive bg-red-600 text-white border-red-600 dark:border-red-600"
} }
}, },
defaultVariants: { defaultVariants: {
status: "default" variant: "default"
} }
} }
); );
@ -43,11 +43,11 @@ const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, status, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
ref={ref} ref={ref}
className={cn(toastVariants({ status }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
); );
@ -61,8 +61,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
buttonVariants({ size: "sm", variant: "outline" }), "inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-red-100 group-[.destructive]:hover:border-slate-50 group-[.destructive]:hover:bg-red-100 group-[.destructive]:hover:text-red-600 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800",
"h-8 shrink-0 px-3",
className className
)} )}
{...props} {...props}
@ -77,9 +76,10 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( 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", "absolute top-2 right-2 rounded-md p-1 text-slate-500 opacity-0 transition-opacity hover:text-slate-900 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:hover:text-slate-50",
className className
)} )}
toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />

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

@ -3,7 +3,7 @@ export interface H2Props {
} }
export const H2 = ({ children }: H2Props): JSX.Element => ( 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"> <h2 className="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} {children}
</h2> </h2>
); );

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

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

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

@ -1,9 +1,17 @@
import { cn } from "@app/core/utils/cn.js";
export interface H4Props { export interface H4Props {
className?: string;
children: React.ReactNode; children: React.ReactNode;
} }
export const H4 = ({ children }: H4Props): JSX.Element => ( export const H4 = ({ className, children }: H4Props): JSX.Element => (
<h4 className="mt-8 scroll-m-20 text-xl font-semibold tracking-tight"> <h4
className={cn(
"scroll-m-20 text-xl font-semibold tracking-tight",
className
)}
>
{children} {children}
</h4> </h4>
); );

6
src/components/form/BitwiseSelect.tsx

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { bitwiseDecode, bitwiseEncode, enumLike } from "@core/utils/bitwise.js"; import { bitwiseDecode, bitwiseEncode, enumLike } from "@core/utils/bitwise.js";
import { InfoWrapper } from "@components/form/InfoWrapper.js"; import { InfoWrapper } from "@components/form/InfoWrapper.js";
import { Listbox } from "@headlessui/react"; // import { Listbox } from "@headlessui/react";
import { Protobuf } from "@meshtastic/meshtasticjs"; import { Protobuf } from "@meshtastic/meshtasticjs";
export interface BitwiseSelectProps { export interface BitwiseSelectProps {
@ -50,7 +50,7 @@ export const BitwiseSelect = ({
return ( return (
<InfoWrapper label={label} description={description} error={error}> <InfoWrapper label={label} description={description} error={error}>
<Listbox {/* <Listbox
value={bitwiseDecode(selected, decodeEnun)} value={bitwiseDecode(selected, decodeEnun)}
onChange={(value) => { onChange={(value) => {
onChange(bitwiseEncode(value)); onChange(bitwiseEncode(value));
@ -71,7 +71,7 @@ export const BitwiseSelect = ({
</Listbox.Option> </Listbox.Option>
))} ))}
</Listbox.Options> </Listbox.Options>
</Listbox> </Listbox> */}
</InfoWrapper> </InfoWrapper>
); );
}; };

35
src/components/form/Button.tsx

@ -1,35 +0,0 @@
import type { ButtonHTMLAttributes, ComponentType, SVGProps } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
}
export const Button = ({
size = "md",
children,
disabled,
className,
...rest
}: ButtonProps): JSX.Element => {
return (
<button
className={`flex w-full select-none rounded-md bg-accentMuted px-3 text-textPrimary hover:brightness-hover focus:outline-none active:brightness-press ${
size === "sm"
? "h-8 text-sm"
: size === "md"
? "h-10 text-sm"
: "h-10 text-base"
} ${
disabled
? "cursor-not-allowed text-textSecondary brightness-disabled hover:brightness-disabled"
: ""
} ${className}`}
disabled={disabled}
{...rest}
>
<div className="m-auto flex shrink-0 items-center gap-2 font-medium">
{children}
</div>
</button>
);
};

22
src/components/form/Form.tsx

@ -1,22 +0,0 @@
import type { FormEvent, HTMLProps } from "react";
export interface FormProps extends HTMLProps<HTMLFormElement> {
onSubmit?: (event: FormEvent<HTMLFormElement>) => Promise<void>;
}
export const Form = ({
children,
onSubmit,
...props
}: FormProps): JSX.Element => {
return (
<form
className="w-full rounded-md bg-backgroundSecondary px-2"
onSubmit={onSubmit}
onChange={onSubmit}
{...props}
>
<div className="flex flex-col gap-3 p-4">{children}</div>
</form>
);
};

61
src/components/form/Select.tsx

@ -1,61 +0,0 @@
import { forwardRef, SelectHTMLAttributes } from "react";
import { InfoWrapper, InfoWrapperProps } from "@components/form/InfoWrapper.js";
export interface SelectProps
extends SelectHTMLAttributes<HTMLSelectElement>,
Omit<InfoWrapperProps, "children"> {
options?: string[];
action?: {
icon: JSX.Element;
action: () => void;
};
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
{
label,
description,
options,
action,
disabled,
error,
children,
...rest
}: SelectProps,
ref
) {
return (
<InfoWrapper label={label} description={description} error={error}>
<div className="flex rounded-md">
<select
ref={ref}
className={`flex h-10 w-full rounded-md border-transparent bg-backgroundPrimary px-3 text-sm text-textPrimary focus:border-transparent focus:outline-none focus:ring-2 focus:ring-accent ${
action ? "rounded-r-none" : ""
} ${
disabled
? "cursor-not-allowed text-textSecondary brightness-disabled hover:brightness-disabled"
: ""
}`}
disabled={disabled}
{...rest}
>
{options &&
options.map((option, index) => (
<option key={index}>{option}</option>
))}
{children}
</select>
{action && (
<button
type="button"
onClick={action.action}
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md bg-backgroundPrimary px-4 py-2 text-sm font-medium text-textSecondary brightness-hover hover:brightness-hover focus:outline-none focus:ring-2 focus:ring-accent active:brightness-press"
>
{action.icon}
</button>
)}
</div>
</InfoWrapper>
);
});

4
src/components/form/Toggle.tsx

@ -6,7 +6,7 @@ export interface ToggleProps {
label?: string; label?: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
onChange: (checked: boolean) => void; onChange?: (checked: boolean) => void;
} }
export const Toggle = ({ export const Toggle = ({
@ -21,7 +21,7 @@ export const Toggle = ({
<Switch <Switch
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onCheckedChange={(state) => onChange(state)} onCheckedChange={onChange}
/> />
</InfoWrapper> </InfoWrapper>
); );

4
src/core/stores/appStore.ts

@ -4,7 +4,7 @@ import { create } from "zustand";
export interface RasterSource { export interface RasterSource {
enabled: boolean; enabled: boolean;
title: string; title: string;
tiles: string[]; tiles: string;
tileSize: number; tileSize: number;
} }
@ -48,7 +48,7 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages", currentPage: "messages",
rasterSources: [], rasterSources: [],
commandPaletteOpen: false, commandPaletteOpen: false,
darkMode: true, darkMode: false,
accent: "orange", accent: "orange",
connectDialogOpen: false, connectDialogOpen: false,

13
src/core/utils/selectEnumOptions.tsx

@ -1,13 +0,0 @@
export const renderOptions = (enumValue: {
[s: string]: string | number;
}): JSX.Element[] => {
const optionsEnumValues = enumValue
? Object.entries(enumValue).filter((value) => typeof value[1] === "number")
: [];
return optionsEnumValues.map(([name, value], index) => (
<option key={index} value={value}>
{name}
</option>
));
};

4
src/index.css

@ -3,7 +3,7 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--backgroundPrimary: #f5f5f6; --backgroundPrimary: #ffffff;
--backgroundSecondary: #e6e9ed; --backgroundSecondary: #e6e9ed;
--textPrimary: #111132; --textPrimary: #111132;
--textSecondary: #64748b; --textSecondary: #64748b;
@ -15,7 +15,7 @@
} }
[data-theme="dark"] { [data-theme="dark"] {
--backgroundPrimary: #2d2d30; --backgroundPrimary: #0f172a;
--backgroundSecondary: #363638; --backgroundSecondary: #363638;
--textPrimary: #ebebeb; --textPrimary: #ebebeb;
--textSecondary: #bdbdbd; --textSecondary: #bdbdbd;

13
src/pages/Channels.tsx

@ -6,8 +6,9 @@ import { useDevice } from "@core/stores/deviceStore.js";
import { QrCodeIcon, ImportIcon } from "lucide-react"; import { QrCodeIcon, ImportIcon } from "lucide-react";
import { Protobuf, Types } from "@meshtastic/meshtasticjs"; import { Protobuf, Types } from "@meshtastic/meshtasticjs";
import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js"; import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js";
import { SidebarItem } from "@app/components/UI/Sidebar/SidebarItem.js";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@app/components/UI/Button.js";
import { SidebarButton } from "@app/components/UI/Sidebar/sidebarButton.js";
export const getChannelName = (channel: Protobuf.Channel) => export const getChannelName = (channel: Protobuf.Channel) =>
channel.settings?.name.length channel.settings?.name.length
@ -25,13 +26,12 @@ export const ChannelsPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar> <Sidebar>
<SidebarSection title="Channels"> <SidebarSection label="Channels">
{channels.map((channel) => ( {channels.map((channel) => (
<SidebarItem <SidebarButton
label={getChannelName(channel.config)}
key={channel.config.index} key={channel.config.index}
active={channel.config.index === activeChannel} active={channel.config.index === activeChannel}
onClick={() => setActiveChannel(channel.config.index)}
label={getChannelName(channel.config)}
element={ element={
<span <span
className={cn( className={cn(
@ -42,12 +42,13 @@ export const ChannelsPage = (): JSX.Element => {
)} )}
/> />
} }
onClick={() => setActiveChannel(channel.config.index)}
/> />
))} ))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<PageLayout <PageLayout
title={`Channel: ${ label={`Channel: ${
channels[activeChannel] channels[activeChannel]
? getChannelName(channels[activeChannel].config) ? getChannelName(channels[activeChannel].config)
: "Loading..." : "Loading..."

2
src/pages/Config/DeviceConfig.tsx

@ -51,7 +51,7 @@ export const DeviceConfig = (): JSX.Element => {
]; ];
return ( return (
<Tabs defaultValue="Device" className="w-[400px]"> <Tabs defaultValue="Device">
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger

2
src/pages/Config/ModuleConfig.tsx

@ -54,7 +54,7 @@ export const ModuleConfig = (): JSX.Element => {
]; ];
return ( return (
<Tabs defaultValue="MQTT" className="w-[400px]"> <Tabs defaultValue="MQTT">
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger key={tab.label} value={tab.label}> <TabsTrigger key={tab.label} value={tab.label}>

23
src/pages/Config/index.tsx

@ -4,9 +4,10 @@ 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 { PageLayout } from "@app/components/Topbar.js";
import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js"; import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js";
import { SidebarItem } from "@app/components/UI/Sidebar/SidebarItem.js";
import { useState } from "react"; import { useState } from "react";
import { useDevice } from "@app/core/stores/deviceStore.js"; import { useDevice } from "@app/core/stores/deviceStore.js";
import { Button } from "@app/components/UI/Button.js";
import { SidebarButton } from "@app/components/UI/Sidebar/sidebarButton.js";
export const ConfigPage = (): JSX.Element => { export const ConfigPage = (): JSX.Element => {
const { workingConfig, workingModuleConfig, connection } = useDevice(); const { workingConfig, workingModuleConfig, connection } = useDevice();
@ -17,31 +18,23 @@ export const ConfigPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar> <Sidebar>
<SidebarSection title="Config Sections"> <SidebarSection label="Config Sections">
<SidebarItem <SidebarButton
icon={SettingsIcon}
label="Device Config" label="Device Config"
active={activeConfigSection === "device"} active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")} onClick={() => setActiveConfigSection("device")}
icon={SettingsIcon}
/> />
<SidebarItem <SidebarButton
icon={BoxesIcon}
label="Module Config" label="Module Config"
active={activeConfigSection === "module"} active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")} onClick={() => setActiveConfigSection("module")}
icon={BoxesIcon}
/> />
</SidebarSection> </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> </Sidebar>
<PageLayout <PageLayout
title={ label={
activeConfigSection === "device" ? "Device Config" : "Module Config" activeConfigSection === "device" ? "Device Config" : "Module Config"
} }
actions={[ actions={[

34
src/pages/Map.tsx

@ -14,12 +14,14 @@ import {
PlusIcon PlusIcon
} from "lucide-react"; } from "lucide-react";
import { bbox, lineString } from "@turf/turf"; import { bbox, lineString } from "@turf/turf";
import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js";
import { Button } from "@app/components/UI/Button.js";
import { SidebarButton } from "@app/components/UI/Sidebar/sidebarButton.js";
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 { default: map } = useMap();
const tmp = useMap();
const getBBox = () => { const getBBox = () => {
const nodesWithPosition = nodes.filter((n) => n.data.position?.latitudeI); const nodesWithPosition = nodes.filter((n) => n.data.position?.latitudeI);
@ -52,32 +54,14 @@ export const MapPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar> <Sidebar>
<div className="space-y-1.5"> <SidebarSection label="Sources">
<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) => ( {rasterSources.map((source, index) => (
<li key={index}> <SidebarButton key={index} label={source.title} />
<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> </SidebarSection>
</div>
</Sidebar> </Sidebar>
<PageLayout <PageLayout
title="Map" label="Map"
actions={[ actions={[
{ {
icon: ZoomInIcon, icon: ZoomInIcon,
@ -125,11 +109,11 @@ export const MapPage = (): JSX.Element => {
</div> </div>
</Marker> </Marker>
))} ))}
{rasterSources.map((source, index) => ( {/* {rasterSources.map((source, index) => (
<Source key={index} type="raster" {...source}> <Source key={index} type="raster" {...source}>
<Layer type="raster" /> <Layer type="raster" />
</Source> </Source>
))} ))} */}
{nodes.map((n) => { {nodes.map((n) => {
if (n.data.position?.latitudeI) { if (n.data.position?.latitudeI) {
return ( return (

40
src/pages/Messages.tsx

@ -3,47 +3,27 @@ import { PageLayout } from "@app/components/Topbar.js";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.js"; import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { EditIcon, HashIcon } from "lucide-react"; import { HashIcon } from "lucide-react";
import { Protobuf, Types } from "@meshtastic/meshtasticjs"; import { Protobuf, Types } from "@meshtastic/meshtasticjs";
import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js"; import { SidebarSection } from "@app/components/UI/Sidebar/SidebarSection.js";
import { SidebarItem } from "@app/components/UI/Sidebar/SidebarItem.js";
import { useState } from "react"; import { useState } from "react";
import { getChannelName } from "./Channels.js"; import { getChannelName } from "./Channels.js";
import { SidebarButton } from "@app/components/UI/Sidebar/sidebarButton.js";
export const MessagesPage = (): JSX.Element => { export const MessagesPage = (): JSX.Element => {
const { channels, setActivePage, nodes, hardware } = useDevice(); const { channels, nodes, hardware } = useDevice();
const [activeChannel, setActiveChannel] = useState<Types.ChannelNumber>( const [activeChannel, setActiveChannel] = useState<Types.ChannelNumber>(
Types.ChannelNumber.PRIMARY Types.ChannelNumber.PRIMARY
); );
const tabs = channels.map((channel) => {
return {
label: channel.config.settings?.name.length
? channel.config.settings?.name
: channel.config.index === 0
? "Primary"
: `Ch ${channel.config.index}`,
element: () => <ChannelChat channel={channel} />,
disabled: channel.config.role === Protobuf.Channel_Role.DISABLED
};
});
return ( return (
<> <>
<Sidebar> <Sidebar>
<SidebarSection <SidebarSection label="Channels">
title="Channels"
action={{
icon: EditIcon,
onClick() {
setActivePage("channels");
}
}}
>
{channels {channels
.filter((ch) => ch.config.role !== Protobuf.Channel_Role.DISABLED) .filter((ch) => ch.config.role !== Protobuf.Channel_Role.DISABLED)
.map((channel) => ( .map((channel) => (
<SidebarItem <SidebarButton
key={channel.config.index} key={channel.config.index}
label={ label={
channel.config.settings?.name.length channel.config.settings?.name.length
@ -52,15 +32,17 @@ export const MessagesPage = (): JSX.Element => {
? "Primary" ? "Primary"
: `Ch ${channel.config.index}` : `Ch ${channel.config.index}`
} }
icon={HashIcon} active={channel.config.index === activeChannel}
onClick={() => setActiveChannel(channel.config.index)}
element={<HashIcon size={16} className="mr-2" />}
/> />
))} ))}
</SidebarSection> </SidebarSection>
<SidebarSection title="Peers"> <SidebarSection label="Peers">
{nodes {nodes
.filter((n) => n.data.num !== hardware.myNodeNum) .filter((n) => n.data.num !== hardware.myNodeNum)
.map((node) => ( .map((node) => (
<SidebarItem <SidebarButton
key={node.data.num} key={node.data.num}
label={node.data.user?.longName ?? "Unknown"} label={node.data.user?.longName ?? "Unknown"}
element={ element={
@ -71,7 +53,7 @@ export const MessagesPage = (): JSX.Element => {
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<PageLayout <PageLayout
title={`Messages: ${ label={`Messages: ${
channels[activeChannel] channels[activeChannel]
? getChannelName(channels[activeChannel].config) ? getChannelName(channels[activeChannel].config)
: "Loading..." : "Loading..."

6
src/pages/Peers.tsx

@ -40,7 +40,11 @@ export const PeersPage = (): JSX.Element => {
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? "UNK"}
</Mono>, </Mono>,
<TimeAgo timestamp={node.data.lastHeard * 1000} />, node.data.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.data.lastHeard * 1000} />
),
<Mono> <Mono>
{node.data.snr}db/ {node.data.snr}db/
{Math.min(Math.max((node.data.snr + 10) * 5, 0), 100)}%/ {Math.min(Math.max((node.data.snr + 10) * 5, 0), 100)}%/

6
src/validation/config/device.ts

@ -19,4 +19,10 @@ export class DeviceValidation
@IsInt() @IsInt()
buzzerGpio: number; buzzerGpio: number;
@IsEnum(Protobuf.Config_DeviceConfig_RebroadcastMode)
rebroadcastMode: Protobuf.Config_DeviceConfig_RebroadcastMode;
@IsInt()
nodeInfoBroadcastSecs: number;
} }

3
src/validation/config/lora.ts

@ -51,6 +51,9 @@ export class LoRaValidation
@IsBoolean() @IsBoolean()
sx126xRxBoostedGain: boolean; sx126xRxBoostedGain: boolean;
@IsInt()
overrideFrequency: number;
@IsArray() @IsArray()
ignoreIncoming: number[]; ignoreIncoming: number[];
} }

22
src/validation/rasterSource.ts

@ -0,0 +1,22 @@
import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator";
import type { RasterSource } from "@core/stores/appStore.js";
export class MapValidation {
@IsArray()
rasterSources: MapValidation_RasterSources[];
}
export class MapValidation_RasterSources implements RasterSource {
@IsBoolean()
enabled: boolean;
@IsString()
title: string;
@IsUrl()
tiles: string;
@IsNumber()
tileSize: number;
}
Loading…
Cancel
Save