Add command palette & cleanup

This commit is contained in:
Sacha Weatherstone
2022-10-08 16:44:07 +10:30
parent 9e331de44e
commit beba475864
19 changed files with 552 additions and 94 deletions

View File

@@ -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
View File

@@ -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:

View File

@@ -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>

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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}
/>
</>
);
};

View File

@@ -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("");
},
}}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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" : ""

View File

@@ -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"
}`}

View File

@@ -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;
}
})
);
},
});
})
);

View File

@@ -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>
),
]}
/>
);
};

View File

@@ -17,5 +17,5 @@ module.exports = {
},
extend: {},
},
plugins: [],
plugins: [require("@tailwindcss/forms")],
};