import { useCallback, useMemo, useState } from 'react'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useNavigate } from 'react-router'; import { Theme, useTheme } from 'remix-themes'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { DOCUMENTS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandShortcut, } from '@documenso/ui/primitives/command'; import { useToast } from '@documenso/ui/primitives/use-toast'; const DOCUMENTS_PAGES = [ { label: msg`All documents`, path: '/documents?status=ALL', shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), }, { label: msg`Draft documents`, path: '/documents?status=DRAFT' }, { label: msg`Completed documents`, path: '/documents?status=COMPLETED', }, { label: msg`Pending documents`, path: '/documents?status=PENDING' }, { label: msg`Inbox documents`, path: '/documents?status=INBOX' }, ]; const TEMPLATES_PAGES = [ { label: msg`All templates`, path: '/templates', shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), }, ]; const SETTINGS_PAGES = [ { label: msg`Settings`, path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), }, { label: msg`Profile`, path: '/settings/profile' }, { label: msg`Password`, path: '/settings/password' }, ]; export type AppCommandMenuProps = { open?: boolean; onOpenChange?: (_open: boolean) => void; }; export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const { _ } = useLingui(); const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(() => open ?? false); const [search, setSearch] = useState(''); const [pages, setPages] = useState([]); const { data: searchDocumentsData, isLoading: isSearchingDocuments } = trpcReact.document.searchDocuments.useQuery( { query: search, }, { placeholderData: (previousData) => previousData, // Do not batch this due to relatively long request time compared to // other queries which are generally batched with this. ...SKIP_QUERY_BATCH_META, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }, ); const searchResults = useMemo(() => { if (!searchDocumentsData) { return []; } return searchDocumentsData.map((document) => ({ label: document.title, path: document.path, value: document.value, })); }, [searchDocumentsData]); const currentPage = pages[pages.length - 1]; const toggleOpen = () => { setIsOpen((isOpen) => !isOpen); onOpenChange?.(!isOpen); if (isOpen) { setPages([]); setSearch(''); } }; const setOpen = useCallback( (open: boolean) => { setIsOpen(open); onOpenChange?.(open); if (!open) { setPages([]); setSearch(''); } }, [onOpenChange], ); const push = useCallback( (path: string) => { void navigate(path); setOpen(false); }, [setOpen], ); const addPage = (page: string) => { setPages((pages) => [...pages, page]); setSearch(''); }; const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]); useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); 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 ( {isSearchingDocuments ? (
) : ( No results found. )} {!currentPage && ( <> addPage('language')}> Change language addPage('theme')}> Change theme {searchResults.length > 0 && ( )} )} {currentPage === 'theme' && } {currentPage === 'language' && }
); } const Commands = ({ push, pages, }: { push: (_path: string) => void; pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[]; }) => { const { _ } = useLingui(); return pages.map((page, idx) => ( push(page.path)} > {typeof page.label === 'string' ? page.label : _(page.label)} {page.shortcut && {page.shortcut}} )); }; const ThemeCommands = () => { const { _ } = useLingui(); const [, setTheme] = useTheme(); const themes = [ { label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun }, { label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon }, { label: msg`System Theme`, theme: null, icon: Monitor }, ] as const; return themes.map((theme) => ( setTheme(theme.theme)} className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2" > {_(theme.label)} )); }; const LanguageCommands = () => { const { i18n, _ } = useLingui(); const { toast } = useToast(); const [isLoading, setIsLoading] = useState(false); const setLanguage = async (lang: string) => { if (isLoading || lang === i18n.locale) { return; } setIsLoading(true); try { await dynamicActivate(lang); const formData = new FormData(); formData.append('lang', lang); const response = await fetch('/api/locale', { method: 'post', body: formData, }); if (!response.ok) { throw new Error(response.statusText); } } catch (e) { console.error(`Failed to set language: ${e}`); toast({ title: _(msg`An unknown error occurred`), variant: 'destructive', description: _(msg`Unable to change the language at this time. Please try again later.`), }); } setIsLoading(false); }; return Object.values(SUPPORTED_LANGUAGES).map((language) => ( setLanguage(language.short)} className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2" > {language.full} )); };