19 changed files with 552 additions and 94 deletions
@ -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[email protected][email protected] |
|||
|
|||
devDependencies: |
|||
'@tailwindcss/forms': 0.5[email protected] |
|||
'@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/[email protected]: |
|||
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[email protected] |
|||
dev: true |
|||
|
|||
/@tailwindcss/line-clamp/[email protected]: |
|||
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: |
|||
|
|||
@ -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> |
|||
); |
|||
}; |
|||
@ -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> |
|||
); |
|||
}; |
|||
@ -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> |
|||
); |
|||
}; |
|||
@ -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> |
|||
</> |
|||
); |
|||
}; |
|||
@ -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> |
|||
); |
|||
}; |
|||
@ -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> |
|||
); |
|||
}; |
|||
@ -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} |
|||
/> |
|||
</> |
|||
); |
|||
}; |
|||
Loading…
Reference in new issue