mirror of
https://github.com/meshtastic/web.git
synced 2026-01-26 08:19:13 -05:00
Add command palette & cleanup
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"zustand": "4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@types/chrome": "^0.0.197",
|
||||
"@types/geodesy": "^2.2.3",
|
||||
"@types/node": "^18.8.3",
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -7,6 +7,7 @@ specifiers:
|
||||
'@hookform/resolvers': ^2.9.8
|
||||
'@meshtastic/eslint-config': ^1.0.8
|
||||
'@meshtastic/meshtasticjs': ^0.6.104
|
||||
'@tailwindcss/forms': ^0.5.3
|
||||
'@tailwindcss/line-clamp': ^0.4.2
|
||||
'@tailwindcss/typography': ^0.5.7
|
||||
'@types/chrome': ^0.0.197
|
||||
@@ -76,6 +77,7 @@ dependencies:
|
||||
zustand: 4.1.1_immer@9.0.15+react@18.2.0
|
||||
|
||||
devDependencies:
|
||||
'@tailwindcss/forms': 0.5.3_tailwindcss@3.1.8
|
||||
'@types/chrome': 0.0.197
|
||||
'@types/geodesy': 2.2.3
|
||||
'@types/node': 18.8.3
|
||||
@@ -774,6 +776,15 @@ packages:
|
||||
resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==}
|
||||
dev: false
|
||||
|
||||
/@tailwindcss/forms/0.5.3_tailwindcss@3.1.8:
|
||||
resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1'
|
||||
dependencies:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.1.8_postcss@8.4.17
|
||||
dev: true
|
||||
|
||||
/@tailwindcss/line-clamp/0.4.2_tailwindcss@3.1.8:
|
||||
resolution: {integrity: sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==}
|
||||
peerDependencies:
|
||||
@@ -2883,6 +2894,11 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/mini-svg-data-uri/1.4.4:
|
||||
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/minimatch/3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
dependencies:
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useAppStore } from "@app/core/stores/appStore.js";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
|
||||
import { CommandPalette } from "./components/CommandPalette/Index.js";
|
||||
import { DeviceSelector } from "./components/DeviceSelector.js";
|
||||
import { DialogManager } from "./components/Dialog/DialogManager.js";
|
||||
import { NewDevice } from "./components/NewDevice.js";
|
||||
import { PageNav } from "./components/PageNav.js";
|
||||
import { Sidebar } from "./components/Sidebar.js";
|
||||
@@ -25,11 +27,13 @@ export const App = (): JSX.Element => {
|
||||
|
||||
{device && (
|
||||
<DeviceWrapper device={device}>
|
||||
<CommandPalette />
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
duration: 2000,
|
||||
}}
|
||||
/>
|
||||
<DialogManager />
|
||||
<Sidebar />
|
||||
<PageNav />
|
||||
<MapProvider>
|
||||
|
||||
35
src/components/CommandPalette/GroupView.tsx
Normal file
35
src/components/CommandPalette/GroupView.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import type { Group } from "./Index.js";
|
||||
|
||||
export interface GroupViewProps {
|
||||
group: Group;
|
||||
}
|
||||
|
||||
export const GroupView = ({ group }: GroupViewProps): JSX.Element => {
|
||||
return (
|
||||
<Combobox.Option
|
||||
value={group.name}
|
||||
className={({ active }) =>
|
||||
`flex cursor-default select-none items-center rounded-md px-3 py-2 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<group.icon
|
||||
className={`h-6 w-6 flex-none text-gray-900 text-opacity-40 ${
|
||||
active ? "text-opacity-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="ml-3 flex-auto truncate">{group.name}</span>
|
||||
{active && <ChevronRightIcon className="h-5 text-gray-400" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
};
|
||||
268
src/components/CommandPalette/Index.tsx
Normal file
268
src/components/CommandPalette/Index.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Contextual
|
||||
* - Reset nodedb
|
||||
* - Map commands
|
||||
* - Disconnect
|
||||
* Debug commands
|
||||
* - Re-configure
|
||||
* - clear parts of store (messages, positions, telemetry etc)
|
||||
*
|
||||
* Application
|
||||
* - Light/Dark mode
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
import { useDevice } from "@app/core/providers/useDevice.js";
|
||||
import { useAppStore } from "@app/core/stores/appStore.js";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import {
|
||||
ArchiveBoxXMarkIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
BeakerIcon,
|
||||
BugAntIcon,
|
||||
Cog8ToothIcon,
|
||||
CubeTransparentIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
IdentificationIcon,
|
||||
InboxIcon,
|
||||
LinkIcon,
|
||||
MapIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
QrCodeIcon,
|
||||
Square3Stack3DIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
WindowIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { GroupView } from "./GroupView.js";
|
||||
import { NoResults } from "./NoResults.js";
|
||||
import { PaletteTransition } from "./PaletteTransition.js";
|
||||
import { SearchBox } from "./SearchBox.js";
|
||||
import { SearchResult } from "./SearchResult.js";
|
||||
|
||||
export interface Group {
|
||||
name: string;
|
||||
icon: (props: React.ComponentProps<"svg">) => JSX.Element;
|
||||
commands: Command[];
|
||||
}
|
||||
export interface Command {
|
||||
name: string;
|
||||
icon: (props: React.ComponentProps<"svg">) => JSX.Element;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export const CommandPalette = (): JSX.Element => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { setQRDialogOpen, setActivePage, connection } = useDevice();
|
||||
const { setSelectedDevice, removeDevice, selectedDevice } = useAppStore();
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
name: "Goto",
|
||||
icon: LinkIcon,
|
||||
commands: [
|
||||
{
|
||||
name: "Messages",
|
||||
icon: InboxIcon,
|
||||
action() {
|
||||
setActivePage("messages");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Map",
|
||||
icon: MapIcon,
|
||||
action() {
|
||||
setActivePage("map");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Extensions",
|
||||
icon: BeakerIcon,
|
||||
action() {
|
||||
setActivePage("extensions");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Config",
|
||||
icon: Cog8ToothIcon,
|
||||
action() {
|
||||
setActivePage("config");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Channels",
|
||||
icon: Square3Stack3DIcon,
|
||||
action() {
|
||||
setActivePage("channels");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Peers",
|
||||
icon: UsersIcon,
|
||||
action() {
|
||||
setActivePage("peers");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Info",
|
||||
icon: IdentificationIcon,
|
||||
action() {
|
||||
setActivePage("info");
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Manage",
|
||||
icon: DevicePhoneMobileIcon,
|
||||
commands: [
|
||||
{
|
||||
name: "Switch Node",
|
||||
icon: ArrowsRightLeftIcon,
|
||||
},
|
||||
{
|
||||
name: "Connect New Node",
|
||||
icon: PlusIcon,
|
||||
action() {
|
||||
setSelectedDevice(0);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Contextual",
|
||||
icon: CubeTransparentIcon,
|
||||
commands: [
|
||||
{
|
||||
name: "QR Code Generator",
|
||||
icon: QrCodeIcon,
|
||||
action() {
|
||||
setQRDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Reset Peers",
|
||||
icon: TrashIcon,
|
||||
},
|
||||
{
|
||||
name: "Disconnect",
|
||||
icon: XCircleIcon,
|
||||
action() {
|
||||
void connection?.disconnect();
|
||||
setSelectedDevice(0);
|
||||
removeDevice(selectedDevice ?? 0);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Debug",
|
||||
icon: BugAntIcon,
|
||||
commands: [
|
||||
{
|
||||
name: "Reconfigure",
|
||||
icon: ArrowPathIcon,
|
||||
action() {
|
||||
void connection?.configure();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Clear Messages",
|
||||
icon: ArchiveBoxXMarkIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Application",
|
||||
icon: WindowIcon,
|
||||
commands: [
|
||||
{
|
||||
name: "Toggle Dark Mode",
|
||||
icon: MoonIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "k") {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
const filtered =
|
||||
query === ""
|
||||
? []
|
||||
: groups
|
||||
.map((group) => {
|
||||
return {
|
||||
...group,
|
||||
commands: group.commands.filter((command) => {
|
||||
return `${group.name} ${command.name}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase());
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.commands.length);
|
||||
|
||||
return (
|
||||
<Transition.Root
|
||||
show={open}
|
||||
as={Fragment}
|
||||
afterLeave={() => setQuery("")}
|
||||
appear
|
||||
>
|
||||
<Dialog as="div" className="relative z-10" onClose={setOpen}>
|
||||
<PaletteTransition>
|
||||
<Dialog.Panel className="mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 overflow-hidden rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Combobox<Command | string>
|
||||
onChange={(input) => {
|
||||
if (typeof input === "string") {
|
||||
setQuery(input);
|
||||
} else if (input.action) {
|
||||
setOpen(false);
|
||||
input.action();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchBox setQuery={setQuery} />
|
||||
|
||||
{query === "" || filtered.length > 0 ? (
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
<li className="p-2">
|
||||
<ul className="flex flex-col gap-2 text-sm text-gray-700">
|
||||
{filtered.map((group, index) => (
|
||||
<SearchResult key={index} group={group} />
|
||||
))}
|
||||
{query === "" &&
|
||||
groups.map((group, index) => (
|
||||
<GroupView key={index} group={group} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</Combobox.Options>
|
||||
) : (
|
||||
query !== "" && filtered.length === 0 && <NoResults />
|
||||
)}
|
||||
</Combobox>
|
||||
</Dialog.Panel>
|
||||
</PaletteTransition>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
16
src/components/CommandPalette/NoResults.tsx
Normal file
16
src/components/CommandPalette/NoResults.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from "react";
|
||||
|
||||
import { CommandLineIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { Mono } from "../Mono.js";
|
||||
|
||||
export const NoResults = (): JSX.Element => {
|
||||
return (
|
||||
<div className="py-14 px-14 text-center">
|
||||
<CommandLineIcon className="mx-auto h-6 text-slate-500" />
|
||||
<Mono className="tracking-tighter">
|
||||
Query does not match any avaliable commands
|
||||
</Mono>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
src/components/CommandPalette/PaletteTransition.tsx
Normal file
42
src/components/CommandPalette/PaletteTransition.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type React from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { Transition } from "@headlessui/react";
|
||||
|
||||
export interface PaletteTransitionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PaletteTransition = ({
|
||||
children,
|
||||
}: PaletteTransitionProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 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-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
{children}
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
21
src/components/CommandPalette/SearchBox.tsx
Normal file
21
src/components/CommandPalette/SearchBox.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export interface SearchBoxProps {
|
||||
setQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
export const SearchBox = ({ setQuery }: SearchBoxProps): JSX.Element => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40" />
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-gray-900 placeholder-gray-500 focus:ring-0"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
src/components/CommandPalette/SearchResult.tsx
Normal file
46
src/components/CommandPalette/SearchResult.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import type { Group } from "./Index.js";
|
||||
|
||||
export interface SearchResultProps {
|
||||
group: Group;
|
||||
}
|
||||
|
||||
export const SearchResult = ({ group }: SearchResultProps): JSX.Element => {
|
||||
return (
|
||||
<div className="rounded-md border border-gray-300 py-2 shadow-md">
|
||||
<div className="flex items-center px-3 py-2">
|
||||
<group.icon className="h-6 w-6 flex-none text-gray-900 text-opacity-40" />
|
||||
<span className="ml-3 flex-auto truncate">{group.name}</span>
|
||||
</div>
|
||||
{group.commands.map((command, index) => (
|
||||
<Combobox.Option
|
||||
key={index}
|
||||
value={command}
|
||||
className={({ active }) =>
|
||||
`mr-2 ml-4 flex cursor-pointer select-none items-center rounded-md px-3 py-1 ${
|
||||
active ? "bg-gray-900 bg-opacity-5 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.name}</span>
|
||||
{active && (
|
||||
<ChevronRightIcon className="ml-auto h-4 text-gray-400" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import type React from "react";
|
||||
import { useAppStore } from "@app/core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@app/core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { CommandLineIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { Mono } from "./Mono.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ export const DeviceSelector = (): JSX.Element => {
|
||||
const { selectedDevice, setSelectedDevice } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-16 items-center whitespace-nowrap bg-slate-50 py-12 [writing-mode:vertical-rl]">
|
||||
<div className="flex h-full w-16 items-center whitespace-nowrap bg-slate-50 pt-12 [writing-mode:vertical-rl]">
|
||||
<Mono>Connected Devices</Mono>
|
||||
<span className="mt-6 flex gap-4 font-bold text-slate-900">
|
||||
{getDevices().map((device) => (
|
||||
@@ -45,6 +45,13 @@ export const DeviceSelector = (): JSX.Element => {
|
||||
</div>
|
||||
</span>
|
||||
<img src="Logo_Black.svg" className="mt-auto px-3" />
|
||||
<div className="my-4 flex flex-col gap-2 [writing-mode:horizontal-tb]">
|
||||
<CommandLineIcon className="h-6 text-slate-400" />
|
||||
<Mono className="text-xs">
|
||||
<kbd className="rounded-md bg-slate-200 p-0.5 pr-1 italic">Ctrl</kbd>+
|
||||
<kbd className="rounded-md bg-slate-200 p-0.5 pr-1 italic">K</kbd>
|
||||
</Mono>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
21
src/components/Dialog/DialogManager.tsx
Normal file
21
src/components/Dialog/DialogManager.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useDevice } from "@app/core/providers/useDevice.js";
|
||||
|
||||
import { QRDialog } from "./QRDialog.js";
|
||||
|
||||
export const DialogManager = (): JSX.Element => {
|
||||
const { channels, config, QRDialogOpen, setQRDialogOpen } = useDevice();
|
||||
return (
|
||||
<>
|
||||
<QRDialog
|
||||
isOpen={QRDialogOpen}
|
||||
close={() => {
|
||||
setQRDialogOpen(false);
|
||||
}}
|
||||
channels={channels.map((ch) => ch.config)}
|
||||
loraConfig={config.lora}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -64,38 +64,40 @@ export const QRDialog = ({
|
||||
<span className="text-sm text-slate-600">
|
||||
The current LoRa configuration will also be shared.
|
||||
</span>
|
||||
{channels.map((channel) => (
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
disabled={
|
||||
channel.index === 0 ||
|
||||
channel.role === Protobuf.Channel_Role.DISABLED
|
||||
}
|
||||
label={
|
||||
channel.settings?.name.length
|
||||
? channel.settings.name
|
||||
: channel.role === Protobuf.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.index}`
|
||||
}
|
||||
checked={
|
||||
channel.index === 0 ||
|
||||
selectedChannels.includes(channel.index)
|
||||
}
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) => c !== channel.index)
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([
|
||||
...selectedChannels,
|
||||
channel.index,
|
||||
]);
|
||||
<div className="flex flex-col gap-1">
|
||||
{channels.map((channel) => (
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
disabled={
|
||||
channel.index === 0 ||
|
||||
channel.role === Protobuf.Channel_Role.DISABLED
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
label={
|
||||
channel.settings?.name.length
|
||||
? channel.settings.name
|
||||
: channel.role === Protobuf.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.index}`
|
||||
}
|
||||
checked={
|
||||
channel.index === 0 ||
|
||||
selectedChannels.includes(channel.index)
|
||||
}
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) => c !== channel.index)
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([
|
||||
...selectedChannels,
|
||||
channel.index,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="m-auto flex">
|
||||
@@ -107,7 +109,7 @@ export const QRDialog = ({
|
||||
value={QRCodeURL}
|
||||
action={{
|
||||
icon: <ClipboardIcon className="h-4" />,
|
||||
action: () => {
|
||||
action() {
|
||||
console.log("");
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -3,39 +3,20 @@ import { forwardRef, InputHTMLAttributes } from "react";
|
||||
|
||||
export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
description?: string;
|
||||
options?: string[];
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
action?: {
|
||||
icon: JSX.Element;
|
||||
action: () => void;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
function Input(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
prefix,
|
||||
suffix,
|
||||
action,
|
||||
error,
|
||||
children,
|
||||
...rest
|
||||
}: CheckboxProps,
|
||||
ref
|
||||
) {
|
||||
function Input({ label, disabled, ...rest }: CheckboxProps, ref) {
|
||||
return (
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
className={`h-4 w-4 rounded border-transparent bg-orange-100 text-orange-500 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${
|
||||
disabled ? "cursor-not-allowed text-orange-200" : ""
|
||||
}`}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
@@ -43,7 +24,6 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
<label htmlFor="comments" className="font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
<p className="text-gray-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`flex h-10 w-full rounded-md bg-orange-100 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 ${
|
||||
className={`flex h-10 w-full rounded-md border-transparent bg-orange-100 px-3 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${
|
||||
prefix ? "rounded-l-none" : ""
|
||||
} ${action ? "rounded-r-none" : ""}`}
|
||||
{...rest}
|
||||
@@ -46,7 +46,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.action}
|
||||
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md bg-orange-200 px-4 py-2 text-sm font-medium hover:bg-orange-300 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md bg-orange-200 px-4 py-2 text-sm font-medium hover:bg-orange-300 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
|
||||
<div className="flex">
|
||||
<select
|
||||
ref={ref}
|
||||
className={`flex h-10 w-full rounded-md bg-orange-100 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 ${
|
||||
className={`flex h-10 w-full rounded-md border-transparent bg-orange-100 px-3 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 ${
|
||||
prefix ? "rounded-l-none" : ""
|
||||
} ${action ? "rounded-r-none" : ""} ${
|
||||
disabled ? "cursor-not-allowed" : ""
|
||||
|
||||
@@ -40,7 +40,6 @@ export const Toggle = ({
|
||||
} ${disabled ? "cursor-not-allowed bg-orange-400" : ""}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface Device {
|
||||
waypoints: Protobuf.Waypoint[];
|
||||
regionUnset: boolean;
|
||||
currentMetrics: Protobuf.DeviceMetrics;
|
||||
QRDialogOpen: boolean;
|
||||
|
||||
setReady(ready: boolean): void;
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
@@ -72,6 +73,7 @@ export interface Device {
|
||||
addMessage: (message: MessageWithAck) => void;
|
||||
addWaypointMessage: (message: WaypointIDWithAck) => void;
|
||||
ackMessage: (channelIndex: number, messageId: number) => void;
|
||||
setQRDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
@@ -107,6 +109,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
waypoints: [],
|
||||
regionUnset: false,
|
||||
currentMetrics: Protobuf.DeviceMetrics.create(),
|
||||
QRDialogOpen: false,
|
||||
|
||||
setReady: (ready: boolean) => {
|
||||
set(
|
||||
@@ -475,6 +478,16 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
},
|
||||
setQRDialogOpen: (open: boolean) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.QRDialogOpen = open;
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Channel } from "@app/components/PageComponents/Channel.js";
|
||||
import { Button } from "@components/Button.js";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.js";
|
||||
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { QrCodeIcon } from "@heroicons/react/24/outline";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export const ChannelsPage = (): JSX.Element => {
|
||||
const { channels, config } = useDevice();
|
||||
const [QRDialogOpen, setQRDialogOpen] = useState(false);
|
||||
const { channels, setQRDialogOpen } = useDevice();
|
||||
|
||||
const tabs: TabType[] = channels.map((channel) => {
|
||||
return {
|
||||
@@ -25,31 +22,21 @@ export const ChannelsPage = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<QRDialog
|
||||
isOpen={QRDialogOpen}
|
||||
close={() => {
|
||||
setQRDialogOpen(false);
|
||||
}}
|
||||
channels={channels.map((ch) => ch.config)}
|
||||
loraConfig={config.lora}
|
||||
/>
|
||||
<TabbedContent
|
||||
tabs={tabs}
|
||||
actions={[
|
||||
() => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconBefore={<QrCodeIcon className="w-4" />}
|
||||
onClick={() => {
|
||||
setQRDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
QR Code
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
<TabbedContent
|
||||
tabs={tabs}
|
||||
actions={[
|
||||
() => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconBefore={<QrCodeIcon className="w-4" />}
|
||||
onClick={() => {
|
||||
setQRDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
QR Code
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,5 +17,5 @@ module.exports = {
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user