diff --git a/apps/web/package.json b/apps/web/package.json index 90da68c4d..6fa900f70 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-hook-form": "^7.43.9", + "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "sharp": "0.32.5", diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index efd3aa2ea..2bbdfcc36 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -8,6 +8,7 @@ import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { CommandMenu } from '~/components/(dashboard)/common/command-menu'; import { Header } from '~/components/(dashboard)/layout/header'; import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; import { NextAuthProvider } from '~/providers/next-auth'; @@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({ return ( +
{children}
diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx new file mode 100644 index 000000000..cc597cfeb --- /dev/null +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Monitor, Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useHotkeys } from 'react-hotkeys-hook'; + +import { + DOCUMENTS_PAGE_SHORTCUT, + SETTINGS_PAGE_SHORTCUT, +} from '@documenso/lib/constants/keyboard-shortcuts'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from '@documenso/ui/primitives/command'; + +const DOCUMENTS_PAGES = [ + { + label: 'All documents', + path: '/documents?status=ALL', + shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), + }, + { label: 'Draft documents', path: '/documents?status=DRAFT' }, + { label: 'Completed documents', path: '/documents?status=COMPLETED' }, + { label: 'Pending documents', path: '/documents?status=PENDING' }, + { label: 'Inbox documents', path: '/documents?status=INBOX' }, +]; + +const SETTINGS_PAGES = [ + { label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') }, + { label: 'Profile', path: '/settings/profile' }, + { label: 'Password', path: '/settings/password' }, +]; + +export function CommandMenu() { + const { setTheme } = useTheme(); + const { push } = useRouter(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [pages, setPages] = useState([]); + const currentPage = pages[pages.length - 1]; + + const toggleOpen = () => { + setOpen((open) => !open); + }; + + const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); + const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); + + useHotkeys('ctrl+k', toggleOpen); + useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); + useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Escape goes to previous page + // Backspace goes to previous page when search is empty + if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) { + e.preventDefault(); + if (currentPage === undefined) { + setOpen(false); + } + setPages((pages) => pages.slice(0, -1)); + } + }; + + return ( + + + + No results found. + {!currentPage && ( + <> + + + + + + + + setPages([...pages, 'theme'])}>Change theme + + + )} + {currentPage === 'theme' && } + + + ); +} + +const Commands = ({ + push, + pages, +}: { + push: (_path: string) => void; + pages: { label: string; path: string; shortcut?: string }[]; +}) => { + return pages.map((page) => ( + push(page.path)}> + {page.label} + {page.shortcut && {page.shortcut}} + + )); +}; + +const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => { + const THEMES = useMemo( + () => [ + { label: 'Light Mode', theme: 'light', icon: Sun }, + { label: 'Dark Mode', theme: 'dark', icon: Moon }, + { label: 'System Theme', theme: 'system', icon: Monitor }, + ], + [], + ); + + return THEMES.map((theme) => ( + setTheme(theme.theme)}> + + {theme.label} + + )); +}; diff --git a/package-lock.json b/package-lock.json index b1c97d01c..208e8bbc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "packages/*" ], "dependencies": { + "react-hotkeys-hook": "^4.4.1", "recharts": "^2.7.2" }, "devDependencies": { @@ -102,6 +103,7 @@ "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-hook-form": "^7.43.9", + "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "sharp": "0.32.5", @@ -15858,6 +15860,15 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-hotkeys-hook": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.1.tgz", + "integrity": "sha512-sClBMBioFEgFGYLTWWRKvhxcCx1DRznd+wkFHwQZspnRBkHTgruKIHptlK/U/2DPX8BhHoRGzpMVWUXMmdZlmw==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-icons": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz", diff --git a/package.json b/package.json index 5bbd4f431..a8d49f365 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "packages/*" ], "dependencies": { + "react-hotkeys-hook": "^4.4.1", "recharts": "^2.7.2" } } diff --git a/packages/lib/constants/keyboard-shortcuts.ts b/packages/lib/constants/keyboard-shortcuts.ts new file mode 100644 index 000000000..896b4abf5 --- /dev/null +++ b/packages/lib/constants/keyboard-shortcuts.ts @@ -0,0 +1,2 @@ +export const SETTINGS_PAGE_SHORTCUT = 'N+S'; +export const DOCUMENTS_PAGE_SHORTCUT = 'N+D'; diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 9b97168be..67cd3f487 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -25,13 +25,18 @@ const Command = React.forwardRef< Command.displayName = CommandPrimitive.displayName; -type CommandDialogProps = DialogProps; +type CommandDialogProps = DialogProps & { + commandProps?: React.ComponentPropsWithoutRef; +}; -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { +const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => { return ( - + {children}