diff --git a/apps/web/src/components/ReactiveButton.tsx b/apps/web/src/components/ReactiveButton.tsx index 73340f73..5fddaeee 100644 --- a/apps/web/src/components/ReactiveButton.tsx +++ b/apps/web/src/components/ReactiveButton.tsx @@ -2,8 +2,10 @@ import Link from "next/link"; import { useState } from "react"; import { twMerge } from "tailwind-merge"; +import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts"; import LottieIcon from "~/components/LottieIcon"; import { useIsMobile } from "~/hooks/useMediaQuery"; +import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts"; const Button: React.FC<{ href: string; @@ -12,10 +14,20 @@ const Button: React.FC<{ json: object; isCollapsed?: boolean; onCloseSideNav?: () => void; -}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => { + keyboardShortcut: KeyboardShortcut; +}> = ({ + href, + current, + name, + json, + isCollapsed = false, + keyboardShortcut, + onCloseSideNav, +}) => { const [isHovered, setIsHovered] = useState(false); const [index, setIndex] = useState(0); const isMobile = useIsMobile(); + const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut); const handleMouseEnter = () => { setIsHovered(true); @@ -34,18 +46,27 @@ const Button: React.FC<{ onMouseEnter={handleMouseEnter} onClick={handleClick} className={twMerge( - "group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000", + "group flex h-[34px] items-center justify-between rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000", current ? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000" : "text-neutral-600 dark:bg-dark-100 dark:text-dark-900", - isCollapsed - ? "justify-start gap-x-3 md:justify-center md:gap-x-0" - : "gap-x-3", )} title={isCollapsed ? name : undefined} > - - {name} +
+ + {name} +
+ {!isCollapsed && ( +
{shortcutKeys}
+ )} ); }; diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx index 68383de6..7b9361dd 100644 --- a/apps/web/src/components/SideNavigation.tsx +++ b/apps/web/src/components/SideNavigation.tsx @@ -15,6 +15,7 @@ import { twMerge } from "tailwind-merge"; import type { Subscription } from "@kan/shared/utils"; import { hasActiveSubscription } from "@kan/shared/utils"; +import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts"; import boardsIconDark from "~/assets/boards-dark.json"; import boardsIconLight from "~/assets/boards-light.json"; import membersIconDark from "~/assets/members-dark.json"; @@ -86,26 +87,59 @@ export default function SideNavigation({ const isDarkMode = resolvedTheme === "dark"; - const navigation = [ + const navigation: { + name: string; + href: string; + icon: object; + keyboardShortcut: KeyboardShortcut; + }[] = [ { name: t`Boards`, href: "/boards", icon: isDarkMode ? boardsIconDark : boardsIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "B" }], + action: () => router.push("/boards"), + group: "NAVIGATION", + description: t`Go to boards`, + }, }, { name: t`Templates`, href: "/templates", icon: isDarkMode ? templatesIconDark : templatesIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "T" }], + action: () => router.push("/templates"), + group: "NAVIGATION", + description: t`Go to templates`, + }, }, { name: t`Members`, href: "/members", icon: isDarkMode ? membersIconDark : membersIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "M" }], + action: () => router.push("/members"), + group: "NAVIGATION", + description: t`Go to members`, + }, }, { name: t`Settings`, href: "/settings", icon: isDarkMode ? settingsIconDark : settingsIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "S" }], + action: () => router.push("/settings"), + group: "NAVIGATION", + description: t`Go to settings`, + }, }, ]; @@ -163,6 +197,7 @@ export default function SideNavigation({ json={item.icon} isCollapsed={isCollapsed} onCloseSideNav={onCloseSideNav} + keyboardShortcut={item.keyboardShortcut} /> ))} diff --git a/apps/web/src/components/Tooltip.tsx b/apps/web/src/components/Tooltip.tsx new file mode 100644 index 00000000..a6a30ba5 --- /dev/null +++ b/apps/web/src/components/Tooltip.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import type { Placement } from "tippy.js"; +import { useEffect, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import tippy from "tippy.js"; + +interface TooltipProps { + children: ReactNode; + content: ReactNode; + placement?: Placement; + delay?: number | [number, number]; +} + +export function Tooltip({ + children, + content, + placement = "bottom", + delay = [500, 0], +}: TooltipProps) { + const triggerRef = useRef(null); + const rootRef = useRef(null); + + useEffect(() => { + if (!triggerRef.current) return; + + const container = document.createElement("div"); + const root = createRoot(container); + rootRef.current = root; + root.render(content); + + const instance = tippy(triggerRef.current, { + content: container, + placement, + delay, + interactive: false, + theme: "tooltip", + }); + + return () => { + instance.destroy(); + rootRef.current?.unmount(); + }; + }, [content, placement, delay]); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/UserMenu.tsx b/apps/web/src/components/UserMenu.tsx index 5d53cf03..0a483385 100644 --- a/apps/web/src/components/UserMenu.tsx +++ b/apps/web/src/components/UserMenu.tsx @@ -10,6 +10,7 @@ import { twMerge } from "tailwind-merge"; import { authClient } from "@kan/auth/client"; import { useIsMobile } from "~/hooks/useMediaQuery"; +import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts"; import { useModal } from "~/providers/modal"; import { getAvatarUrl } from "~/utils/helpers"; @@ -31,6 +32,7 @@ export default function UserMenu({ const router = useRouter(); const { theme, setTheme } = useTheme(); const { openModal } = useModal(); + const { openLegend } = useKeyboardShortcuts(); const isMobile = useIsMobile(); const handleLogout = async () => { @@ -169,6 +171,19 @@ export default function UserMenu({
+ + + { - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === "k") { - event.preventDefault(); - setIsOpen(true); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, []); + const { tooltipContent: commandPaletteShortcutTooltipContent } = + useKeyboardShortcut({ + type: "PRESS", + stroke: { + key: "k", + modifiers: ["META"], + }, + action: () => setIsOpen(true), + description: t`Open command menu`, + group: "GENERAL", + }); return ( <> @@ -84,15 +86,17 @@ export default function WorkspaceMenu({ )} - + + +
)} diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 7dc7ea90..7379cd8e 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -12,6 +12,7 @@ import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; import { useEffect } from "react"; +import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts"; import { LinguiProviderWrapper } from "~/providers/lingui"; import { ModalProvider } from "~/providers/modal"; import { PopupProvider } from "~/providers/popup"; @@ -80,21 +81,23 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => { )}