diff --git a/.gitignore b/.gitignore index 74531104..83eb1972 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.code-workspace .env build/ diff --git a/src/components/Drawer/FormDrawer/FormDrawer.component.tsx b/src/components/Drawer/FormDrawer/FormDrawer.component.tsx index 81368916..e3bf0795 100644 --- a/src/components/Drawer/FormDrawer/FormDrawer.component.tsx +++ b/src/components/Drawer/FormDrawer/FormDrawer.component.tsx @@ -1,4 +1,4 @@ -import { useScreenSize } from '@/hooks'; +import { useKeyPress, useScreenSize } from '@/hooks'; import { drawerWidth } from '@/style/theme/theme'; import { Box, @@ -9,11 +9,13 @@ import { IconButton, Alert, CircularProgress, + ButtonProps, } from '@mui/material'; import React from 'react'; import { ActionPaper } from '../../Base'; import { CloseRounded, DoneRounded, ErrorRounded } from '@mui/icons-material'; import { type TFormDrawerState } from './FormDrawer.reducer'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; export type TFormDrawerProps = { state?: TFormDrawerState; @@ -22,6 +24,7 @@ export type TFormDrawerProps = { onSubmit: (event: React.FormEvent) => void; onClose: () => void; closeOnBackdropClick?: boolean; + withHotkey?: boolean; }; export const FormDrawer: React.FC> = ({ @@ -32,8 +35,21 @@ export const FormDrawer: React.FC> = ( closeOnBackdropClick = false, heading = 'Drawer', children, + withHotkey = false, }) => { const screenSize = useScreenSize(); + const saveBtnRef = React.createRef(); + + useKeyPress( + 'S', + (event) => { + event.preventDefault(); + if (!withHotkey) return; + if (!saveBtnRef.current) return console.error('saveBtnRef is null'); + saveBtnRef.current.click(); + }, + { requiresCtrl: true } + ); const DrawerAnchor: DrawerProps['anchor'] = React.useMemo(() => { return screenSize === 'small' ? 'bottom' : 'right'; @@ -100,30 +116,16 @@ export const FormDrawer: React.FC> = ( pt: 0, }} > - - {state !== undefined ? ( - + + {withHotkey ? ( + + + ) : ( - + )} @@ -131,3 +133,28 @@ export const FormDrawer: React.FC> = ( ); }; + +const SaveButton: React.FC<{ state?: TFormDrawerState } & Pick> = + React.forwardRef(({ state }, ref) => { + return ( + + ); + }); diff --git a/src/components/HotkeyBadge.component.tsx b/src/components/HotkeyBadge.component.tsx new file mode 100644 index 00000000..0e16d752 --- /dev/null +++ b/src/components/HotkeyBadge.component.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Badge, Box, type BadgeProps } from '@mui/material'; +import { KeyboardCommandKeyRounded } from '@mui/icons-material'; +import { useWindowDimensions } from '@/hooks'; + +export type THotKeyBadgeProps = { hotkey: string } & BadgeProps; + +export const HotkeyBadge: React.FC = ({ hotkey, ...props }) => { + const { breakpoint } = useWindowDimensions(); + if (breakpoint === 'xs' || breakpoint === 'sm') { + return {props.children}; + } + return ( + + + {hotkey.toUpperCase()} + + } + {...props} + > + {props.children} + + ); +}; diff --git a/src/components/Layout/Drawer/DrawerHamburger.component.tsx b/src/components/Layout/Drawer/DrawerHamburger.component.tsx index 393602cb..656a6601 100644 --- a/src/components/Layout/Drawer/DrawerHamburger.component.tsx +++ b/src/components/Layout/Drawer/DrawerHamburger.component.tsx @@ -3,17 +3,19 @@ import { MenuRounded as MenuIcon, MenuOpenRounded as MenuOpenIcon } from '@mui/i import { IconButton, type IconButtonProps } from '@mui/material'; import { useScreenSize } from '@/hooks'; import { useDrawerStore } from './Drawer.store'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; export type TDrawerHeaderProps = IconButtonProps; export const DrawerHamburger: React.FC = ({ ...iconButtonProps }) => { const screenSize = useScreenSize(); const { open, toggle } = useDrawerStore(); - return ( - toggle()} {...iconButtonProps}> - - + + toggle()} {...iconButtonProps}> + + + ); }; diff --git a/src/core/Auth/Layout/Auth.layout.tsx b/src/core/Auth/Layout/Auth.layout.tsx index 974dac05..47379be5 100644 --- a/src/core/Auth/Layout/Auth.layout.tsx +++ b/src/core/Auth/Layout/Auth.layout.tsx @@ -1,13 +1,24 @@ import React from 'react'; import { Box, Container } from '@mui/material'; import { AppBar, Footer } from '@/components/Layout'; -import { Drawer } from '@/components/Layout/Drawer'; +import { Drawer, useDrawerStore } from '@/components/Layout/Drawer'; import { Main } from '@/components/Base'; import { FilterDrawer } from '@/core/Filter'; +import { useKeyPress } from '@/hooks'; export type TAuthLayout = React.PropsWithChildren; export const AuthLayout: React.FC = ({ children }) => { + const { toggle } = useDrawerStore(); + useKeyPress( + 'b', + (event) => { + event.preventDefault(); + toggle(); + }, + { requiresCtrl: true } + ); + return ( diff --git a/src/core/Budget/BudgetList.component.tsx b/src/core/Budget/BudgetList.component.tsx index 48521c0e..8e837f93 100644 --- a/src/core/Budget/BudgetList.component.tsx +++ b/src/core/Budget/BudgetList.component.tsx @@ -9,6 +9,8 @@ import { BudgetService, CreateBudgetDrawer, EditBudgetDrawer, useFetchBudgetProg import { useSnackbarContext } from '../Snackbar'; import { useAuthContext } from '../Auth'; import { TBudget } from '@budgetbuddyde/types'; +import { useKeyPress } from '@/hooks'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; export type TBudgetListProps = {}; @@ -24,6 +26,10 @@ export const BudgetList: React.FC = () => { const [showCreateBudgetDrawer, setShowCreateBudgetDrawer] = React.useState(false); const [showEditBudgetDrawer, setShowEditBudgetDrawer] = React.useState(false); const [editBudget, setEditBudget] = React.useState(null); + useKeyPress('a', (event) => { + event.preventDefault(); + setShowCreateBudgetDrawer(true); + }); const handler: Pick = { async onEdit(budget) { @@ -70,9 +76,11 @@ export const BudgetList: React.FC = () => { - setShowCreateBudgetDrawer(true)}> - - + + setShowCreateBudgetDrawer(true)}> + + + diff --git a/src/core/Budget/CreateBudgetDrawer.component.tsx b/src/core/Budget/CreateBudgetDrawer.component.tsx index 3b8f61b6..420d26a3 100644 --- a/src/core/Budget/CreateBudgetDrawer.component.tsx +++ b/src/core/Budget/CreateBudgetDrawer.component.tsx @@ -91,6 +91,7 @@ export const CreateBudgetDrawer: React.FC = ({ open, o heading="Set Budget" onClose={handler.onClose} closeOnBackdropClick + withHotkey > diff --git a/src/core/Budget/EditBudgetDrawer.component.tsx b/src/core/Budget/EditBudgetDrawer.component.tsx index 70e195b3..7ee802fd 100644 --- a/src/core/Budget/EditBudgetDrawer.component.tsx +++ b/src/core/Budget/EditBudgetDrawer.component.tsx @@ -110,6 +110,7 @@ export const EditBudgetDrawer: React.FC = ({ heading="Set Budget" onClose={handler.onClose} closeOnBackdropClick + withHotkey > diff --git a/src/core/Category/CreateCategoryDrawer.component.tsx b/src/core/Category/CreateCategoryDrawer.component.tsx index cca5d204..3154fa70 100644 --- a/src/core/Category/CreateCategoryDrawer.component.tsx +++ b/src/core/Category/CreateCategoryDrawer.component.tsx @@ -91,6 +91,7 @@ export const CreateCategoryDrawer: React.FC = ({ heading="Create Category" onClose={handler.onClose} closeOnBackdropClick + withHotkey > = ({ setDrawerState({ type: 'RESET' }); }} closeOnBackdropClick + withHotkey > = ({ heading="Create Payment-Method" onClose={handler.onClose} closeOnBackdropClick + withHotkey > {(['name', 'address', 'provider'] as Partial[]).map( (name) => { diff --git a/src/core/PaymentMethod/EditPaymentMethodDrawer.component.tsx b/src/core/PaymentMethod/EditPaymentMethodDrawer.component.tsx index 25e652ec..f533bb99 100644 --- a/src/core/PaymentMethod/EditPaymentMethodDrawer.component.tsx +++ b/src/core/PaymentMethod/EditPaymentMethodDrawer.component.tsx @@ -96,6 +96,7 @@ export const EditPaymentMethodDrawer: React.FC = ({ heading="Edit Payment-Method" onClose={handler.onClose} closeOnBackdropClick + withHotkey > {(['name', 'address', 'provider'] as Partial[]).map( (name) => { diff --git a/src/core/Subscription/CreateSubscriptionDrawer.component.tsx b/src/core/Subscription/CreateSubscriptionDrawer.component.tsx index 70729c5d..38877de7 100644 --- a/src/core/Subscription/CreateSubscriptionDrawer.component.tsx +++ b/src/core/Subscription/CreateSubscriptionDrawer.component.tsx @@ -120,6 +120,7 @@ export const CreateSubscriptionDrawer: React.FC heading="Create Subscription" onClose={handler.onClose} closeOnBackdropClick + withHotkey > {screenSize === 'small' ? ( diff --git a/src/core/Subscription/EditSubscriptionDrawer.component.tsx b/src/core/Subscription/EditSubscriptionDrawer.component.tsx index 7e84643b..58eb9189 100644 --- a/src/core/Subscription/EditSubscriptionDrawer.component.tsx +++ b/src/core/Subscription/EditSubscriptionDrawer.component.tsx @@ -150,6 +150,7 @@ export const EditSubscriptionDrawer: React.FC = ({ heading="Edit Subscription" onClose={handler.onClose} closeOnBackdropClick + withHotkey > {screenSize === 'small' ? ( diff --git a/src/core/Transaction/CreateTransactionDrawer.component.tsx b/src/core/Transaction/CreateTransactionDrawer.component.tsx index 92128523..d82e1bdd 100644 --- a/src/core/Transaction/CreateTransactionDrawer.component.tsx +++ b/src/core/Transaction/CreateTransactionDrawer.component.tsx @@ -151,6 +151,7 @@ export const CreateTransactionDrawer: React.FC = heading="Create Transaction" onClose={handler.onClose} closeOnBackdropClick + withHotkey > {screenSize === 'small' ? ( diff --git a/src/core/Transaction/EditTransactionDrawer.component.tsx b/src/core/Transaction/EditTransactionDrawer.component.tsx index cd6370c1..89281719 100644 --- a/src/core/Transaction/EditTransactionDrawer.component.tsx +++ b/src/core/Transaction/EditTransactionDrawer.component.tsx @@ -145,6 +145,7 @@ export const EditTransactionDrawer: React.FC = ({ heading="Create Transaction" onClose={handler.onClose} closeOnBackdropClick + withHotkey > {screenSize === 'small' ? ( diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 94bb5aab..b2b72ccd 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useWindowDimensions.hook'; export * from './useScreenSize.hook'; export * from './useEntityDrawer.reducer'; +export * from './useKeyPress.hook'; diff --git a/src/hooks/useKeyPress.hook.ts b/src/hooks/useKeyPress.hook.ts new file mode 100644 index 00000000..a50e2444 --- /dev/null +++ b/src/hooks/useKeyPress.hook.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useScreenSize } from './useScreenSize.hook'; + +export const useKeyPress = ( + targetKey: string, + callback: (event: KeyboardEvent) => void, + options?: { + requiresCtrl?: boolean; + } +) => { + const screenSize = useScreenSize(); + + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key === targetKey.toLowerCase()) { + if (options?.requiresCtrl && !event.ctrlKey) { + return; + } + callback(event); + } + }; + + useEffect(() => { + if (screenSize === 'small') return; + + window.addEventListener('keydown', handleKeyPress); + + return () => { + window.removeEventListener('keydown', handleKeyPress); + }; + }, [targetKey, callback, screenSize, options]); +}; diff --git a/src/routes/Categories.route.tsx b/src/routes/Categories.route.tsx index f8583e1b..f87dbdd1 100644 --- a/src/routes/Categories.route.tsx +++ b/src/routes/Categories.route.tsx @@ -22,11 +22,13 @@ import { DescriptionTableCellStyle } from '@/style/DescriptionTableCell.style'; import type { TCategory } from '@budgetbuddyde/types'; import { AddRounded, DeleteRounded, EditRounded } from '@mui/icons-material'; import { Checkbox, Grid, IconButton, TableCell, TableRow, Typography } from '@mui/material'; -import { CreateEntityDrawerState, useEntityDrawer } from '@/hooks'; +import { CreateEntityDrawerState, useEntityDrawer, useKeyPress } from '@/hooks'; import React from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { SearchInput } from '@/components/Base/Search'; import { type ISelectionHandler } from '@/components/Base/Select'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; +import { ToggleFilterDrawerButton } from '@/core/Filter'; interface ICategoriesHandler { onSearch: (keyword: string) => void; @@ -59,6 +61,14 @@ export const Categories = () => { const [deleteCategories, setDeleteCategories] = React.useState([]); const [selectedCategories, setSelectedCategories] = React.useState([]); const [keyword, setKeyword] = React.useState(''); + useKeyPress( + 'a', + (event) => { + event.preventDefault(); + dispatchCreateDrawer({ type: 'open' }); + }, + { requiresCtrl: true } + ); const displayedCategories: TCategory[] = React.useMemo(() => { if (keyword.length == 0) return categories; return categories.filter(({ name }) => name.toLowerCase().includes(keyword.toLowerCase())); @@ -183,11 +193,15 @@ export const Categories = () => { )} tableActions={ + + - handler.onCreateCategory()}> - - + + handler.onCreateCategory()}> + + + } withSelection diff --git a/src/routes/PaymentMethods.route.tsx b/src/routes/PaymentMethods.route.tsx index dfa56373..3805f142 100644 --- a/src/routes/PaymentMethods.route.tsx +++ b/src/routes/PaymentMethods.route.tsx @@ -21,9 +21,11 @@ import { AddRounded, DeleteRounded, EditRounded } from '@mui/icons-material'; import { Table } from '@/components/Base/Table'; import { AppConfig } from '@/app.config'; import { DescriptionTableCellStyle } from '@/style/DescriptionTableCell.style'; -import { useEntityDrawer, CreateEntityDrawerState } from '@/hooks'; +import { useEntityDrawer, CreateEntityDrawerState, useKeyPress } from '@/hooks'; import { useNavigate, useLocation } from 'react-router-dom'; import { type ISelectionHandler } from '@/components/Base/Select'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; +import { ToggleFilterDrawerButton } from '@/core/Filter'; interface IPaymentMethodsHandler { onSearch: (keyword: string) => void; @@ -56,6 +58,14 @@ export const PaymentMethods = () => { const [deletePaymentMethods, setDeletePaymentMethods] = React.useState([]); const [selectedPaymentMethods, setSelectedPaymentMethods] = React.useState([]); const [keyword, setKeyword] = React.useState(''); + useKeyPress( + 'a', + (event) => { + event.preventDefault(); + dispatchCreateDrawer({ type: 'open' }); + }, + { requiresCtrl: true } + ); const displayedPaymentMethods: TPaymentMethod[] = React.useMemo(() => { if (keyword.length == 0) return paymentMethods; return paymentMethods.filter(({ name }) => name.toLowerCase().includes(keyword.toLowerCase())); @@ -194,11 +204,15 @@ export const PaymentMethods = () => { )} tableActions={ + + - handler.onCreatePaymentMethod()}> - - + + handler.onCreatePaymentMethod()}> + + + } withSelection diff --git a/src/routes/Subscriptions.route.tsx b/src/routes/Subscriptions.route.tsx index 19862438..4a914617 100644 --- a/src/routes/Subscriptions.route.tsx +++ b/src/routes/Subscriptions.route.tsx @@ -14,7 +14,11 @@ import { AddRounded, DeleteRounded, EditRounded } from '@mui/icons-material'; import { Box, Checkbox, Grid, IconButton, TableCell, TableRow, Typography } from '@mui/material'; import { useSnackbarContext } from '@/core/Snackbar'; import { useAuthContext } from '@/core/Auth'; -import { TSubscription, TTransaction, TUpdateSubscriptionPayload } from '@budgetbuddyde/types'; +import { + type TSubscription, + type TTransaction, + type TUpdateSubscriptionPayload, +} from '@budgetbuddyde/types'; import { Table } from '@/components/Base/Table'; import { AppConfig } from '@/app.config'; import { DescriptionTableCellStyle } from '@/style/DescriptionTableCell.style'; @@ -22,10 +26,12 @@ import { DeleteDialog } from '@/components/DeleteDialog.component'; import { determineNextExecution, determineNextExecutionDate } from '@/utils'; import { CreateTransactionDrawer } from '@/core/Transaction'; import { filterSubscriptions } from '@/utils/filter.util'; -import { useFilterStore } from '@/core/Filter'; +import { ToggleFilterDrawerButton, useFilterStore } from '@/core/Filter'; import { CategoryChip } from '@/core/Category'; import { PaymentMethodChip } from '@/core/PaymentMethod'; import { type ISelectionHandler } from '@/components/Base/Select'; +import { useKeyPress } from '@/hooks'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; interface ISubscriptionsHandler { onSearch: (keyword: string) => void; @@ -60,6 +66,14 @@ export const Subscriptions = () => { ); const [selectedSubscriptions, setSelectedSubscriptions] = React.useState([]); const [keyword, setKeyword] = React.useState(''); + useKeyPress( + 'a', + (event) => { + event.preventDefault(); + setShowCreateSubscriptionDrawer(true); + }, + { requiresCtrl: true } + ); const displayedSubscriptions: TSubscription[] = React.useMemo(() => { return filterSubscriptions(keyword, filters, subscriptions); }, [subscriptions, keyword, filters]); @@ -267,11 +281,15 @@ export const Subscriptions = () => { )} tableActions={ + + - setShowCreateSubscriptionDrawer(true)}> - - + + setShowCreateSubscriptionDrawer(true)}> + + + } withSelection diff --git a/src/routes/Transactions.route.tsx b/src/routes/Transactions.route.tsx index de9e9a54..04a4237a 100644 --- a/src/routes/Transactions.route.tsx +++ b/src/routes/Transactions.route.tsx @@ -11,7 +11,7 @@ import { useFetchTransactions, } from '@/core/Transaction'; import { Checkbox, Grid, IconButton, TableCell, TableRow, Typography } from '@mui/material'; -import { TTransaction } from '@budgetbuddyde/types'; +import { type TTransaction } from '@budgetbuddyde/types'; import { DeleteDialog } from '@/components/DeleteDialog.component'; import { SearchInput } from '@/components/Base/Search'; import { AddRounded, DeleteRounded, EditRounded } from '@mui/icons-material'; @@ -19,11 +19,13 @@ import { Table } from '@/components/Base/Table'; import { AppConfig } from '@/app.config'; import { format } from 'date-fns'; import { DescriptionTableCellStyle } from '@/style/DescriptionTableCell.style'; -import { useFilterStore } from '@/core/Filter'; +import { ToggleFilterDrawerButton, useFilterStore } from '@/core/Filter'; import { filterTransactions } from '@/utils/filter.util'; import { CategoryChip } from '@/core/Category'; import { PaymentMethodChip } from '@/core/PaymentMethod'; import { type ISelectionHandler } from '@/components/Base/Select'; +import { HotkeyBadge } from '@/components/HotkeyBadge.component'; +import { useKeyPress } from '@/hooks'; interface ITransactionsHandler { onSearch: (keyword: string) => void; @@ -49,6 +51,14 @@ export const Transactions = () => { const [deleteTransactions, setDeleteTransactions] = React.useState([]); const [selectedTransactions, setSelectedTransactions] = React.useState([]); const [keyword, setKeyword] = React.useState(''); + useKeyPress( + 'a', + (event) => { + event.preventDefault(); + setShowCreateTransactionDrawer(true); + }, + { requiresCtrl: true } + ); const displayedTransactions: TTransaction[] = React.useMemo(() => { return filterTransactions(keyword, filters, transactions); }, [transactions, keyword, filters]); @@ -192,11 +202,15 @@ export const Transactions = () => { )} tableActions={ + + - setShowCreateTransactionDrawer(true)}> - - + + setShowCreateTransactionDrawer(true)}> + + + } withSelection