feat(dashboard): Add global commands (#7782)

* add global commands

* update lock

* shorten keybinds

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2024-06-24 13:00:52 +02:00
committed by GitHub
parent 27bb93c5b5
commit aee75f6ba0
9 changed files with 710 additions and 177 deletions

View File

@@ -7,12 +7,22 @@ import {
Calendar,
CircleHalfSolid,
CogSixTooth,
Keyboard,
MagnifyingGlass,
SidebarLeft,
User as UserIcon,
} from "@medusajs/icons"
import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui"
import { PropsWithChildren } from "react"
import {
Avatar,
Button,
DropdownMenu,
Heading,
IconButton,
Kbd,
Text,
clx,
} from "@medusajs/ui"
import { PropsWithChildren, useState } from "react"
import {
Link,
Outlet,
@@ -22,31 +32,37 @@ import {
useNavigate,
} from "react-router-dom"
import { Skeleton } from "../../common/skeleton"
import { useTranslation } from "react-i18next"
import { useLogout } from "../../../hooks/api/auth"
import { useMe } from "../../../hooks/api/users"
import { queryClient } from "../../../lib/query-client"
import { KeybindProvider } from "../../../providers/keybind-provider"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { useSearch } from "../../../providers/search-provider"
import { useSidebar } from "../../../providers/sidebar-provider"
import { useTheme } from "../../../providers/theme-provider"
import { Skeleton } from "../../common/skeleton"
export const Shell = ({ children }: PropsWithChildren) => {
const globalShortcuts = useGlobalShortcuts()
return (
<div className="flex h-screen flex-col items-start overflow-hidden lg:flex-row">
<div>
<MobileSidebarContainer>{children}</MobileSidebarContainer>
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
<KeybindProvider shortcuts={globalShortcuts}>
<div className="flex h-screen flex-col items-start overflow-hidden lg:flex-row">
<div>
<MobileSidebarContainer>{children}</MobileSidebarContainer>
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
</div>
<div className="flex h-screen w-full flex-col overflow-auto">
<Topbar />
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
<Gutter>
<Outlet />
</Gutter>
</main>
</div>
</div>
<div className="flex h-screen w-full flex-col overflow-auto">
<Topbar />
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
<Gutter>
<Outlet />
</Gutter>
</main>
</div>
</div>
</KeybindProvider>
)
}
@@ -206,7 +222,7 @@ const Logout = () => {
const navigate = useNavigate()
const { mutateAsync: logoutMutation } = useLogout()
const handleLayout = async () => {
const handleLogout = async () => {
await logoutMutation(undefined, {
onSuccess: () => {
/**
@@ -219,7 +235,7 @@ const Logout = () => {
}
return (
<DropdownMenu.Item onClick={handleLayout}>
<DropdownMenu.Item onClick={handleLogout}>
<div className="flex items-center gap-x-2">
<ArrowRightOnRectangle className="text-ui-fg-subtle" />
<span>Logout</span>
@@ -241,35 +257,95 @@ const Profile = () => {
)
}
const LoggedInUser = () => {
const GlobalKeybindsModal = (props: {
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const { t } = useTranslation()
const globalShortcuts = useGlobalShortcuts()
return (
<DropdownMenu>
<UserBadge />
<DropdownMenu.Content align="center">
<Profile />
<DropdownMenu.Separator />
<Link
// TODO change link once docs are public
to="https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app/"
target="_blank"
>
<DropdownMenu.Item>
<BookOpen className="text-ui-fg-subtle mr-2" />
Documentation
<Dialog.Root {...props}>
<Dialog.Portal>
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] flex h-full max-h-[612px] w-full max-w-[560px] translate-x-[-50%] translate-y-[-50%] flex-col divide-y overflow-hidden rounded-lg">
<div className="px-6 py-4">
<Dialog.Title asChild>
<Heading>Keyboard Shortcuts</Heading>
</Dialog.Title>
</div>
<div className="flex flex-col divide-y overflow-y-auto">
{globalShortcuts.map((shortcut, index) => {
return (
<div
key={index}
className="text-ui-fg-subtle flex items-center justify-between px-6 py-3"
>
<Text size="small">{shortcut.label}</Text>
<div className="flex items-center gap-x-1.5">
{shortcut.keys.Mac?.map((key, index) => {
return <Kbd key={index}>{key}</Kbd>
})}
</div>
</div>
)
})}
</div>
<div className="flex items-center justify-end border-b px-6 py-4">
<Dialog.Close asChild>
<Button size="small">{t("actions.close")}</Button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
const LoggedInUser = () => {
const [openMenu, setOpenMenu] = useState(false)
const [openModal, setOpenModal] = useState(false)
const toggleModal = () => {
setOpenMenu(false)
setOpenModal(!openModal)
}
return (
<div>
<DropdownMenu open={openMenu} onOpenChange={setOpenMenu}>
<UserBadge />
<DropdownMenu.Content align="center">
<Profile />
<DropdownMenu.Separator />
<DropdownMenu.Item asChild>
<Link
// TODO change link once docs are public
to="https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app/"
target="_blank"
>
<BookOpen className="text-ui-fg-subtle mr-2" />
Documentation
</Link>
</DropdownMenu.Item>
</Link>
<Link to="https://medusajs.com/changelog/" target="_blank">
<DropdownMenu.Item>
<Calendar className="text-ui-fg-subtle mr-2" />
Changelog
<DropdownMenu.Item asChild>
<Link to="https://medusajs.com/changelog/" target="_blank">
<Calendar className="text-ui-fg-subtle mr-2" />
Changelog
</Link>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<ThemeToggle />
<DropdownMenu.Separator />
<Logout />
</DropdownMenu.Content>
</DropdownMenu>
<DropdownMenu.Item onClick={toggleModal}>
<Keyboard className="text-ui-fg-subtle mr-2" />
Keyboard shortcuts
</DropdownMenu.Item>
<DropdownMenu.Separator />
<ThemeToggle />
<DropdownMenu.Separator />
<Logout />
</DropdownMenu.Content>
</DropdownMenu>
<GlobalKeybindsModal open={openModal} onOpenChange={setOpenModal} />
</div>
)
}
@@ -306,6 +382,7 @@ const ToggleNotifications = () => {
}
const Searchbar = () => {
const { t } = useTranslation()
const { toggleSearch } = useSearch()
return (
@@ -316,7 +393,7 @@ const Searchbar = () => {
<MagnifyingGlass />
<div className="flex-1 text-left">
<Text size="small" leading="compact">
Jump to or search
{t("app.search.placeholder")}
</Text>
</div>
<Kbd className="rounded-full">K</Kbd>

View File

@@ -1,34 +1,74 @@
import { MagnifyingGlass } from "@medusajs/icons"
import { Kbd, Text, clx } from "@medusajs/ui"
import { Badge, Kbd, Text, clx } from "@medusajs/ui"
import * as Dialog from "@radix-ui/react-dialog"
import { Command } from "cmdk"
import {
ComponentPropsWithoutRef,
ElementRef,
HTMLAttributes,
forwardRef,
useEffect,
useMemo,
} from "react"
import { useTranslation } from "react-i18next"
import { useTranslation } from "react-i18next"
import { useLocation } from "react-router-dom"
import { Shortcut, ShortcutType } from "../../providers/keybind-provider"
import { useGlobalShortcuts } from "../../providers/keybind-provider/hooks"
import { useSearch } from "../../providers/search-provider"
export const Search = () => {
const { open, onOpenChange } = useSearch()
const links = useLinks()
const globalCommands = useGlobalShortcuts()
const location = useLocation()
const { t } = useTranslation()
useEffect(() => {
onOpenChange(false)
}, [location.pathname, onOpenChange])
const links = useMemo(() => {
const groups = new Map<ShortcutType, Shortcut[]>()
globalCommands.forEach((command) => {
const group = groups.get(command.type) || []
group.push(command)
groups.set(command.type, group)
})
return Array.from(groups).map(([title, items]) => ({
title,
items,
}))
}, [globalCommands])
const handleSelect = (callback: () => void) => {
callback()
onOpenChange(false)
}
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search" />
<CommandInput placeholder={t("app.search.placeholder")} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{t("general.noResultsTitle")}</CommandEmpty>
{links.map((group) => {
return (
<CommandGroup key={group.title} heading={group.title}>
<CommandGroup
key={group.title}
heading={t(`app.keyboardShortcuts.${group.title}`)}
>
{group.items.map((item) => {
return (
<CommandItem key={item.label}>
<CommandItem
key={item.label}
onSelect={() => handleSelect(item.callback)}
className="flex items-center justify-between"
>
<span>{item.label}</span>
<div className="flex items-center gap-x-1.5">
{item.keys.Mac?.map((key, index) => {
return <Kbd key={index}>{key}</Kbd>
})}
</div>
</CommandItem>
)
})}
@@ -40,86 +80,6 @@ export const Search = () => {
)
}
type CommandItemProps = {
label: string
}
type CommandGroupProps = {
title: string
items: CommandItemProps[]
}
const useLinks = (): CommandGroupProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
title: "Pages",
items: [
{
label: t("products.domain"),
},
{
label: t("categories.domain"),
},
{
label: t("collections.domain"),
},
{
label: t("giftCards.domain"),
},
{
label: t("orders.domain"),
},
{
label: t("draftOrders.domain"),
},
{
label: t("customers.domain"),
},
{
label: t("customerGroups.domain"),
},
{
label: t("discounts.domain"),
},
{
label: t("pricing.domain"),
},
],
},
{
title: "Settings",
items: [
{
label: t("profile.domain"),
},
{
label: t("store.domain"),
},
{
label: t("users.domain"),
},
{
label: t("regions.domain"),
},
{
label: t("locations.domain"),
},
{
label: t("salesChannels.domain"),
},
{
label: t("apiKeyManagement.domainTitle"),
},
],
},
],
[t]
)
}
const CommandPalette = forwardRef<
ElementRef<typeof Command>,
ComponentPropsWithoutRef<typeof Command>
@@ -133,36 +93,38 @@ const CommandPalette = forwardRef<
{...props}
/>
))
Command.displayName = Command.displayName
CommandPalette.displayName = Command.displayName
interface CommandDialogProps extends Dialog.DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
const { t } = useTranslation()
return (
<Dialog.Root {...props}>
<Dialog.Portal>
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
<Dialog.Content className="bg-ui-bg-base shadow-elevation-modal fixed left-[50%] top-[50%] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-xl p-0">
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[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">
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input]]:h-[52px]">
{children}
</CommandPalette>
<div className="flex items-center justify-between border-t px-4 pb-4 pt-3">
<div></div>
<div className="bg-ui-bg-field text-ui-fg-subtle flex items-center justify-end border-t px-4 py-3">
<div className="flex items-center gap-x-3">
<div className="flex items-center gap-x-2">
<Text size="xsmall" leading="compact">
Navigation
{t("app.search.navigation")}
</Text>
<div className="flex items-center gap-x-1">
<Kbd></Kbd>
<Kbd></Kbd>
<Kbd className="bg-ui-bg-field-component"></Kbd>
<Kbd className="bg-ui-bg-field-component"></Kbd>
</div>
</div>
<div className="bg-ui-border-strong h-3 w-px" />
<div className="flex items-center gap-x-2">
<Text size="xsmall" leading="compact">
Open Result
{t("app.search.openResult")}
</Text>
<Kbd></Kbd>
<Kbd className="bg-ui-bg-field-component"></Kbd>
</div>
</div>
</div>
@@ -175,19 +137,26 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
const CommandInput = forwardRef<
ElementRef<typeof Command.Input>,
ComponentPropsWithoutRef<typeof Command.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlass className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
ref={ref}
className={clx(
"placeholder:text-ui-fg-muted flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
>(({ className, ...props }, ref) => {
const { t } = useTranslation()
return (
<div className="flex flex-col border-b">
<div className="px-4 pt-4">
{/* TODO: Add filter once we have search engine */}
<Badge size="2xsmall">{t("app.search.allAreas")}</Badge>
</div>
<Command.Input
ref={ref}
className={clx(
"placeholder:text-ui-fg-muted flex h-10 w-full rounded-md bg-transparent p-4 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
})
CommandInput.displayName = Command.Input.displayName
@@ -198,7 +167,7 @@ const CommandList = forwardRef<
<Command.List
ref={ref}
className={clx(
"max-h-[300px] overflow-y-auto overflow-x-hidden pb-4",
"max-h-[300px] overflow-y-auto overflow-x-hidden px-2 pb-4",
className
)}
{...props}
@@ -223,7 +192,7 @@ const CommandGroup = forwardRef<
<Command.Group
ref={ref}
className={clx(
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden px-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
className
)}
{...props}
@@ -251,7 +220,7 @@ const CommandItem = forwardRef<
<Command.Item
ref={ref}
className={clx(
"aria-selected:bg-ui-bg-base-hover hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover txt-compact-small relative flex cursor-default select-none items-center rounded-md p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"aria-selected:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover txt-compact-small [&>svg]:text-ui-fg-subtle relative flex cursor-pointer select-none items-center gap-x-3 rounded-md p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
className
)}
{...props}
@@ -259,19 +228,3 @@ const CommandItem = forwardRef<
))
CommandItem.displayName = Command.Item.displayName
const CommandShortcut = ({
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={clx(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"

View File

@@ -85,7 +85,42 @@
"clearAll": "Clear all",
"apply": "Apply",
"add": "Add",
"select": "Select"
"select": "Select",
"logout": "Logout"
},
"app": {
"search": {
"allAreas": "All areas",
"navigation": "Navigation",
"openResult": "Open result",
"placeholder": "Jump to or find anything..."
},
"keyboardShortcuts": {
"pageShortcut": "Jump to",
"settingShortcut": "Settings",
"commandShortcut": "Commands",
"goToOrders": "Go to orders",
"goToProducts": "Go to products",
"goToCollections": "Go to collections",
"goToCategories": "Go to categories",
"goToCustomers": "Go to customers",
"goToCustomerGroups": "Go to customer groups",
"goToInventory": "Go to inventory",
"goToReservations": "Go to reservations",
"goToPriceLists": "Go to price lists",
"goToPromotions": "Go to promotions",
"goToCampaigns": "Go to campaigns",
"goToStore": "Go to store",
"goToUsers": "Go to users",
"goToRegions": "Go to regions",
"goToTaxRegions": "Go to tax region",
"goToSalesChannels": "Go to sales channels",
"goToProductTypes": "Go to product types",
"goToLocations": "Go to locations",
"goToPublishableApiKeys": "Go to publishable API keys",
"goToSecretApiKeys": "Go to secret API keys",
"goToWorkflows": "Go to workflows"
}
},
"filters": {
"date": {

View File

@@ -0,0 +1,285 @@
import debounceFn from "lodash/debounce"
import { useCallback, useContext, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useLogout } from "../../hooks/api/auth"
import { queryClient } from "../../lib/query-client"
import { KeybindContext } from "./keybind-context"
import { Shortcut } from "./types"
import { findShortcut } from "./utils"
export const useKeybind = () => {
const context = useContext(KeybindContext)
if (!context) {
throw new Error("useKeybind must be used within a KeybindProvider")
}
return context
}
export const useRegisterShortcut = () => {}
export const useShortcuts = ({
shortcuts = [],
debounce,
}: {
shortcuts?: Shortcut[]
debounce: number
}) => {
const [keys, setKeys] = useState<string[]>([])
// eslint-disable-next-line react-hooks/exhaustive-deps
const removeKeys = useCallback(
debounceFn(() => setKeys([]), debounce),
[]
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const invokeShortcut = useCallback(
debounceFn((shortcut: Shortcut | null) => {
if (shortcut && shortcut.callback) {
shortcut.callback()
setKeys([])
}
}, debounce / 2),
[]
)
useEffect(() => {
if (keys.length > 0 && shortcuts.length > 0) {
const shortcut = findShortcut(shortcuts, keys)
invokeShortcut(shortcut)
}
return () => invokeShortcut.cancel()
}, [keys, shortcuts, invokeShortcut])
useEffect(() => {
const listener = (event: KeyboardEvent) => {
const target = event.target as HTMLElement
/**
* Ignore key events from input, textarea and contenteditable elements
*/
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.contentEditable === "true"
) {
removeKeys()
return
}
setKeys((oldKeys) => [...oldKeys, event.key])
removeKeys()
}
window.addEventListener("keydown", listener)
return () => {
window.removeEventListener("keydown", listener)
}
}, [removeKeys])
}
export const useGlobalShortcuts = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync } = useLogout()
const handleLogout = async () => {
await mutateAsync(undefined, {
onSuccess: () => {
queryClient.clear()
navigate("/login")
},
})
}
const globalShortcuts: Shortcut[] = [
// Pages
{
keys: {
Mac: ["G", "O"],
},
label: t("app.keyboardShortcuts.goToOrders"),
type: "pageShortcut",
callback: () => navigate("/orders"),
},
{
keys: {
Mac: ["G", "P"],
},
label: t("app.keyboardShortcuts.goToProducts"),
type: "pageShortcut",
callback: () => navigate("/products"),
},
{
keys: {
Mac: ["G", "P", "C"],
},
label: t("app.keyboardShortcuts.goToCollections"),
type: "pageShortcut",
callback: () => navigate("/collections"),
},
{
keys: {
Mac: ["G", "P", "A"],
},
label: t("app.keyboardShortcuts.goToCategories"),
type: "pageShortcut",
callback: () => navigate("/categories"),
},
{
keys: {
Mac: ["G", "C"],
},
label: t("app.keyboardShortcuts.goToCustomers"),
type: "pageShortcut",
callback: () => navigate("/customers"),
},
{
keys: {
Mac: ["G", "C", "G"],
},
label: t("app.keyboardShortcuts.goToCustomerGroups"),
type: "pageShortcut",
callback: () => navigate("/customer-groups"),
},
{
keys: {
Mac: ["G", "I"],
},
label: t("app.keyboardShortcuts.goToInventory"),
type: "pageShortcut",
callback: () => navigate("/inventory"),
},
{
keys: {
Mac: ["G", "I", "R"],
},
label: t("app.keyboardShortcuts.goToReservations"),
type: "pageShortcut",
callback: () => navigate("/reservations"),
},
{
keys: {
Mac: ["G", "L"],
},
label: t("app.keyboardShortcuts.goToPriceLists"),
type: "pageShortcut",
callback: () => navigate("/pricing"),
},
{
keys: {
Mac: ["G", "R"],
},
label: t("app.keyboardShortcuts.goToPromotions"),
type: "pageShortcut",
callback: () => navigate("/promotions"),
},
{
keys: {
Mac: ["G", "R", "C"],
},
label: t("app.keyboardShortcuts.goToCampaigns"),
type: "pageShortcut",
callback: () => navigate("/campaigns"),
},
//
{
keys: {
Mac: ["G", "S", "S"],
},
label: t("app.keyboardShortcuts.goToStore"),
type: "settingShortcut",
callback: () => navigate("/settings/store"),
},
{
keys: {
Mac: ["G", "S", "U"],
},
label: t("app.keyboardShortcuts.goToUsers"),
type: "settingShortcut",
callback: () => navigate("/settings/users"),
},
{
keys: {
Mac: ["G", "S", "R"],
},
label: t("app.keyboardShortcuts.goToRegions"),
type: "settingShortcut",
callback: () => navigate("/settings/regions"),
},
{
keys: {
Mac: ["G", "S", "T"],
},
label: t("app.keyboardShortcuts.goToTaxRegions"),
type: "settingShortcut",
callback: () => navigate("/settings/taxes"),
},
{
keys: {
Mac: ["G", "S", "A"],
},
label: t("app.keyboardShortcuts.goToSalesChannels"),
type: "settingShortcut",
callback: () => navigate("/settings/sales-channels"),
},
{
keys: {
Mac: ["G", "S", "P"],
},
label: t("app.keyboardShortcuts.goToProductTypes"),
type: "settingShortcut",
callback: () => navigate("/settings/product-types"),
},
{
keys: {
Mac: ["G", "S", "L"],
},
label: t("app.keyboardShortcuts.goToLocations"),
type: "settingShortcut",
callback: () => navigate("/settings/locations"),
},
{
keys: {
Mac: ["G", "S", "J"],
},
label: t("app.keyboardShortcuts.goToPublishableApiKeys"),
type: "settingShortcut",
callback: () => navigate("/settings/publishable-api-keys"),
},
{
keys: {
Mac: ["G", "S", "K"],
},
label: t("app.keyboardShortcuts.goToSecretApiKeys"),
type: "settingShortcut",
callback: () => navigate("/settings/secret-api-keys"),
},
{
keys: {
Mac: ["G", "S", "W"],
},
label: t("app.keyboardShortcuts.goToWorkflows"),
type: "settingShortcut",
callback: () => navigate("/settings/workflows"),
},
// Commands
{
keys: {
Mac: ["B", "Y", "E"],
},
label: t("actions.logout"),
type: "commandShortcut",
callback: () => handleLogout(),
},
]
return globalShortcuts
}

View File

@@ -0,0 +1,3 @@
export { useRegisterShortcut } from "./hooks"
export * from "./keybind-provider"
export type { Shortcut, ShortcutType } from "./types"

View File

@@ -0,0 +1,4 @@
import { createContext } from "react"
import { KeybindContextState } from "./types"
export const KeybindContext = createContext<KeybindContextState | null>(null)

View File

@@ -0,0 +1,64 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { useShortcuts } from "./hooks"
import { KeybindContext } from "./keybind-context"
import { KeybindContextState, Shortcut } from "./types"
import {
findFirstPlatformMatch,
findShortcutIndex,
getShortcutKeys,
getShortcutWithDefaultValues,
} from "./utils"
type KeybindProviderProps = PropsWithChildren<{
shortcuts: Shortcut[]
debounce?: number
}>
export const KeybindProvider = ({
shortcuts,
debounce = 500,
children,
}: KeybindProviderProps) => {
const [storeShortcuts, setStoreCommands] = useState(
shortcuts.map((shr) => getShortcutWithDefaultValues(shr))
)
const registerShortcut = useCallback(
(shortcut: Shortcut) => {
setStoreCommands((prevShortcuts) => {
const idx = findShortcutIndex(shortcuts, getShortcutKeys(shortcut))
const newShortcuts = [...prevShortcuts]
if (idx > -1) {
newShortcuts[idx] = getShortcutWithDefaultValues(shortcut)
return prevShortcuts
}
return [...prevShortcuts, getShortcutWithDefaultValues(shortcut)]
})
},
[shortcuts]
)
const getKeysByPlatform = useCallback((command: Shortcut) => {
return findFirstPlatformMatch(command.keys)
}, [])
useShortcuts({ shortcuts: storeShortcuts, debounce })
const commandsContext = useMemo<KeybindContextState>(
() => ({
shortcuts: storeShortcuts,
registerShortcut,
getKeysByPlatform,
}),
[storeShortcuts, registerShortcut, getKeysByPlatform]
)
return (
<KeybindContext.Provider value={commandsContext}>
{children}
</KeybindContext.Provider>
)
}

View File

@@ -0,0 +1,20 @@
export type KeybindContextState = {}
export type Platform = "Mac" | "Windows" | "Linux"
export type Keys = {
[key in Platform]?: string[]
}
export type ShortcutType =
| "pageShortcut"
| "settingShortcut"
| "commandShortcut"
export type Shortcut = {
keys: Keys
type: ShortcutType
label: string
callback: () => void
_defaultKeys?: Keys
}

View File

@@ -0,0 +1,92 @@
import { Keys, Platform, Shortcut } from "./types"
export const findFirstPlatformMatch = (keys: Keys) => {
const match =
Object.entries(keys as Record<any, any>).filter(
([, value]) => value.length > 0
)[0] ?? []
return match.length
? {
platform: match[0] as Platform,
keys: match[1] as string[],
}
: null
}
export const getShortcutKeys = (shortcut: Shortcut) => {
const platform: Platform = "Mac"
const keys: string[] | undefined = shortcut.keys[platform]
if (!keys) {
const defaultPlatform = findFirstPlatformMatch(shortcut.keys)
console.warn(
`No keys found for platform "${platform}" in "${shortcut.label}" ${
defaultPlatform
? `using keys for platform "${defaultPlatform.platform}"`
: ""
}`
)
return defaultPlatform ? defaultPlatform.keys : []
}
return keys
}
const keysMatch = (keys1: string[], keys2: string[]) => {
return (
keys1.length === keys2.length &&
keys1.every(
(key, index) => key.toLowerCase() === keys2[index].toLowerCase()
)
)
}
export const findShortcutIndex = (shortcuts: Shortcut[], keys: string[]) => {
if (!keys.length) {
return -1
}
let index = 0
for (const shortcut of shortcuts) {
const shortcutKeys = getShortcutKeys(shortcut)
if (keysMatch(shortcutKeys, keys)) {
return index
}
index++
}
return -1
}
export const findShortcut = (shortcuts: Shortcut[], keys: string[]) => {
const shortcutIndex = findShortcutIndex(shortcuts, keys)
return shortcutIndex > -1 ? shortcuts[shortcutIndex] : null
}
export const getShortcutWithDefaultValues = (
shortcut: Shortcut,
platform: Platform = "Mac"
): Shortcut => {
const platforms: Platform[] = ["Mac", "Windows", "Linux"]
const defaultKeys = Object.values(shortcut.keys)[0] ?? shortcut.keys[platform]
const keys = platforms.reduce((acc, curr) => {
return {
...acc,
[curr]: shortcut.keys[curr] ?? defaultKeys,
}
}, {})
return {
...shortcut,
keys,
_defaultKeys: shortcut.keys,
}
}