mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
feat: add command menu and keyboard shortcuts (#337)
This commit is contained in:
@ -36,6 +36,7 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
|
|||||||
@ -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 { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
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 { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
|
<CommandMenu />
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|||||||
133
apps/web/src/components/(dashboard)/common/command-menu.tsx
Normal file
133
apps/web/src/components/(dashboard)/common/command-menu.tsx
Normal file
@ -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<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
{!currentPage && (
|
||||||
|
<>
|
||||||
|
<CommandGroup heading="Documents">
|
||||||
|
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Settings">
|
||||||
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Preferences">
|
||||||
|
<CommandItem onSelect={() => setPages([...pages, 'theme'])}>Change theme</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Commands = ({
|
||||||
|
push,
|
||||||
|
pages,
|
||||||
|
}: {
|
||||||
|
push: (_path: string) => void;
|
||||||
|
pages: { label: string; path: string; shortcut?: string }[];
|
||||||
|
}) => {
|
||||||
|
return pages.map((page) => (
|
||||||
|
<CommandItem key={page.path} onSelect={() => push(page.path)}>
|
||||||
|
{page.label}
|
||||||
|
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||||
|
</CommandItem>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
|
||||||
|
<theme.icon className="mr-2" />
|
||||||
|
{theme.label}
|
||||||
|
</CommandItem>
|
||||||
|
));
|
||||||
|
};
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"recharts": "^2.7.2"
|
"recharts": "^2.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -102,6 +103,7 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
@ -15858,6 +15860,15 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18"
|
"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": {
|
"node_modules/react-icons": {
|
||||||
"version": "4.11.0",
|
"version": "4.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz",
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"recharts": "^2.7.2"
|
"recharts": "^2.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
packages/lib/constants/keyboard-shortcuts.ts
Normal file
2
packages/lib/constants/keyboard-shortcuts.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const SETTINGS_PAGE_SHORTCUT = 'N+S';
|
||||||
|
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';
|
||||||
@ -25,13 +25,18 @@ const Command = React.forwardRef<
|
|||||||
|
|
||||||
Command.displayName = CommandPrimitive.displayName;
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
type CommandDialogProps = DialogProps;
|
type CommandDialogProps = DialogProps & {
|
||||||
|
commandProps?: React.ComponentPropsWithoutRef<typeof CommandPrimitive>;
|
||||||
|
};
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||||
<Command 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">
|
<Command
|
||||||
|
{...commandProps}
|
||||||
|
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-4 [&_[cmdk-item]_svg]:w-4"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user