From beba475864c9c8bd43a140af9ecbf20dda5379d8 Mon Sep 17 00:00:00 2001 From: Sacha Weatherstone Date: Sat, 8 Oct 2022 16:44:07 +1030 Subject: [PATCH] Add command palette & cleanup --- package.json | 1 + pnpm-lock.yaml | 16 ++ src/App.tsx | 4 + src/components/CommandPalette/GroupView.tsx | 35 +++ src/components/CommandPalette/Index.tsx | 268 ++++++++++++++++++ src/components/CommandPalette/NoResults.tsx | 16 ++ .../CommandPalette/PaletteTransition.tsx | 42 +++ src/components/CommandPalette/SearchBox.tsx | 21 ++ .../CommandPalette/SearchResult.tsx | 46 +++ src/components/DeviceSelector.tsx | 11 +- src/components/Dialog/DialogManager.tsx | 21 ++ src/components/Dialog/QRDialog.tsx | 66 ++--- src/components/form/Checkbox.tsx | 30 +- src/components/form/Input.tsx | 4 +- src/components/form/Select.tsx | 2 +- src/components/form/Toggle.tsx | 1 - src/core/stores/deviceStore.ts | 13 + src/pages/Channels.tsx | 47 ++- tailwind.config.cjs | 2 +- 19 files changed, 552 insertions(+), 94 deletions(-) create mode 100644 src/components/CommandPalette/GroupView.tsx create mode 100644 src/components/CommandPalette/Index.tsx create mode 100644 src/components/CommandPalette/NoResults.tsx create mode 100644 src/components/CommandPalette/PaletteTransition.tsx create mode 100644 src/components/CommandPalette/SearchBox.tsx create mode 100644 src/components/CommandPalette/SearchResult.tsx create mode 100644 src/components/Dialog/DialogManager.tsx diff --git a/package.json b/package.json index d3ad53f0..efd5257a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5a9f12..8c7a2f37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/App.tsx b/src/App.tsx index 8d9ac723..b3e79c30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 && ( + + diff --git a/src/components/CommandPalette/GroupView.tsx b/src/components/CommandPalette/GroupView.tsx new file mode 100644 index 00000000..44174faf --- /dev/null +++ b/src/components/CommandPalette/GroupView.tsx @@ -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 ( + + `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.name} + {active && } + + )} + + ); +}; diff --git a/src/components/CommandPalette/Index.tsx b/src/components/CommandPalette/Index.tsx new file mode 100644 index 00000000..d93ade5b --- /dev/null +++ b/src/components/CommandPalette/Index.tsx @@ -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 ( + setQuery("")} + appear + > + + + + + onChange={(input) => { + if (typeof input === "string") { + setQuery(input); + } else if (input.action) { + setOpen(false); + input.action(); + } + }} + > + + + {query === "" || filtered.length > 0 ? ( + +
  • +
      + {filtered.map((group, index) => ( + + ))} + {query === "" && + groups.map((group, index) => ( + + ))} +
    +
  • +
    + ) : ( + query !== "" && filtered.length === 0 && + )} + +
    +
    +
    +
    + ); +}; diff --git a/src/components/CommandPalette/NoResults.tsx b/src/components/CommandPalette/NoResults.tsx new file mode 100644 index 00000000..a5229edb --- /dev/null +++ b/src/components/CommandPalette/NoResults.tsx @@ -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 ( +
    + + + Query does not match any avaliable commands + +
    + ); +}; diff --git a/src/components/CommandPalette/PaletteTransition.tsx b/src/components/CommandPalette/PaletteTransition.tsx new file mode 100644 index 00000000..a8e9e627 --- /dev/null +++ b/src/components/CommandPalette/PaletteTransition.tsx @@ -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 ( + <> + +
    + + +
    + + {children} + +
    + + ); +}; diff --git a/src/components/CommandPalette/SearchBox.tsx b/src/components/CommandPalette/SearchBox.tsx new file mode 100644 index 00000000..b6f8b72f --- /dev/null +++ b/src/components/CommandPalette/SearchBox.tsx @@ -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 ( +
    + + setQuery(event.target.value)} + /> +
    + ); +}; diff --git a/src/components/CommandPalette/SearchResult.tsx b/src/components/CommandPalette/SearchResult.tsx new file mode 100644 index 00000000..c3149532 --- /dev/null +++ b/src/components/CommandPalette/SearchResult.tsx @@ -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 ( +
    +
    + + {group.name} +
    + {group.commands.map((command, index) => ( + + `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.name} + {active && ( + + )} + + )} + + ))} +
    + ); +}; diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx index 25b78fb0..2d96443d 100644 --- a/src/components/DeviceSelector.tsx +++ b/src/components/DeviceSelector.tsx @@ -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 ( -
    +
    Connected Devices {getDevices().map((device) => ( @@ -45,6 +45,13 @@ export const DeviceSelector = (): JSX.Element => {
    +
    + + + Ctrl+ + K + +
    ); }; diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx new file mode 100644 index 00000000..75d203f0 --- /dev/null +++ b/src/components/Dialog/DialogManager.tsx @@ -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 ( + <> + { + setQRDialogOpen(false); + }} + channels={channels.map((ch) => ch.config)} + loraConfig={config.lora} + /> + + ); +}; diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index d7de5be9..70e57e4c 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -64,38 +64,40 @@ export const QRDialog = ({ The current LoRa configuration will also be shared. - {channels.map((channel) => ( - { - if (selectedChannels.includes(channel.index)) { - setSelectedChannels( - selectedChannels.filter((c) => c !== channel.index) - ); - } else { - setSelectedChannels([ - ...selectedChannels, - channel.index, - ]); +
    + {channels.map((channel) => ( + - ))} + 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, + ]); + } + }} + /> + ))} +
    @@ -107,7 +109,7 @@ export const QRDialog = ({ value={QRCodeURL} action={{ icon: , - action: () => { + action() { console.log(""); }, }} diff --git a/src/components/form/Checkbox.tsx b/src/components/form/Checkbox.tsx index 5a104042..aa87e165 100644 --- a/src/components/form/Checkbox.tsx +++ b/src/components/form/Checkbox.tsx @@ -3,39 +3,20 @@ import { forwardRef, InputHTMLAttributes } from "react"; export interface CheckboxProps extends InputHTMLAttributes { label: string; - description?: string; - options?: string[]; - prefix?: string; - suffix?: string; - action?: { - icon: JSX.Element; - action: () => void; - }; - error?: string; } export const Checkbox = forwardRef( - function Input( - { - label, - description, - options, - prefix, - suffix, - action, - error, - children, - ...rest - }: CheckboxProps, - ref - ) { + function Input({ label, disabled, ...rest }: CheckboxProps, ref) { return (
    @@ -43,7 +24,6 @@ export const Checkbox = forwardRef( -

    {description}

    ); diff --git a/src/components/form/Input.tsx b/src/components/form/Input.tsx index 1b10bc98..2b84923b 100644 --- a/src/components/form/Input.tsx +++ b/src/components/form/Input.tsx @@ -32,7 +32,7 @@ export const Input = forwardRef(function Input( )} (function Input( diff --git a/src/components/form/Select.tsx b/src/components/form/Select.tsx index 60aff2fb..93c12cc0 100644 --- a/src/components/form/Select.tsx +++ b/src/components/form/Select.tsx @@ -40,7 +40,7 @@ export const Select = forwardRef(function Input(