tag
14 | * @param {boolean} [showAvatar] - user's avatar picture is shown when true
15 | * @param {object} [user] - logged user data {name, email, avatar...}
16 | */
17 | const UserInfo = ({ className, showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
18 | const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
19 | const srcAvatar = user?.avatar ? user?.avatar : undefined;
20 | const userPhoneOrEmail = user?.phone || (user?.email as string);
21 |
22 | return (
23 |
24 | {showAvatar ? (
25 |
26 |
35 |
36 | ) : null}
37 |
38 | {fullName || 'Current User'}
39 |
40 | {userPhoneOrEmail || 'Loading...'}
41 |
42 | );
43 | };
44 |
45 | export default UserInfo;
46 |
--------------------------------------------------------------------------------
/src/components/UserInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import UserInfo from './UserInfo';
2 |
3 | export { UserInfo as default, UserInfo };
4 |
--------------------------------------------------------------------------------
/src/components/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Components configuration
3 | */
4 | export const CONTENT_MAX_WIDTH = 800;
5 | export const CONTENT_MIN_WIDTH = 320; // CONTENT_MAX_WIDTH - Sidebar width
6 |
7 | /**
8 | * AppAlert and AppSnackBarAlert components
9 | */
10 | export const APP_ALERT_SEVERITY = 'error'; // 'error' | 'info'| 'success' | 'warning'
11 | export const APP_ALERT_VARIANT = 'filled'; // 'filled' | 'outlined' | 'standard'
12 |
13 | /**
14 | * AppButton component
15 | */
16 | export const APP_BUTTON_VARIANT = 'contained'; // | 'text' | 'outlined'
17 | export const APP_BUTTON_MARGIN = 1;
18 |
19 | /**
20 | * AppIcon component
21 | */
22 | export const APP_ICON_SIZE = 24;
23 |
24 | /**
25 | * AppLink component
26 | */
27 | export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary'
28 | export const APP_LINK_UNDERLINE = 'hover'; // 'always
29 |
30 | /**
31 | * AppLoading component
32 | */
33 | export const APP_LOADING_COLOR = 'primary'; // 'secondary'
34 | export const APP_LOADING_SIZE = '3rem'; // 40
35 | export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular'
36 |
37 | /**
38 | * AppSection component
39 | */
40 | export const APP_SECTION_VARIANT = 'subtitle2'; // 'subtitle1' | 'body1' | 'h6'
41 |
42 | /**
43 | * AppSnackBar and AppSnackBarProvider components
44 | */
45 | export const APP_SNACKBAR_MAX_COUNT = 5; // Used in AppSnackBarProvider from notistack npm
46 | export const APP_SNACKBAR_AUTO_HIDE_DURATION = 3000; // Set to null if want to disable AutoHide feature
47 | export const APP_SNACKBAR_ANCHOR_ORIGIN_VERTICAL = 'bottom'; // 'bottom | 'top'
48 | export const APP_SNACKBAR_ANCHOR_ORIGIN_HORIZONTAL = 'center'; // 'center' | 'left' | 'right'
49 |
--------------------------------------------------------------------------------
/src/components/dialogs/CommonDialog.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, ReactNode, SyntheticEvent, useCallback } from 'react';
2 | import { Dialog, DialogActions, DialogContent, DialogProps } from '@mui/material';
3 | import { AppButton } from '..';
4 | import { AppDialogTitle } from './components';
5 | import { ColorName } from '../../utils/style';
6 | import { useDialogMinWidth } from './utils';
7 |
8 | interface Props extends DialogProps {
9 | data?: unknown;
10 | title?: string;
11 | text?: string;
12 | body?: ReactNode;
13 | hideCancelButton?: boolean;
14 | confirmButtonText?: string;
15 | confirmButtonColor?: ColorName;
16 | onConfirm?: (data: unknown) => void;
17 | onClose?: (event: SyntheticEvent) => void;
18 | }
19 |
20 | /**
21 | * Shows generic "Common" dialog
22 | * @component CommonDialog
23 | * @param {function} props.onConfirm - event for Confirm button, called as onConfirm(data)
24 | * @param {function} props.onClose - event for Close and Cancel buttons and the backdrop
25 | */
26 | const CommonDialog: FunctionComponent
= ({
27 | open = false, // Don't show dialog by default
28 | data, // optional data passed to onConfirm callback
29 | title = 'Missing title...',
30 | text = 'Text is missing...',
31 | body, // JSX to render instead of .text
32 | hideCancelButton = false,
33 | confirmButtonText = 'Confirm',
34 | confirmButtonColor = 'primary',
35 | onConfirm,
36 | onClose,
37 | ...restOfProps
38 | }) => {
39 | const paperMinWidth = useDialogMinWidth();
40 |
41 | const handleOnConfirm = useCallback(() => {
42 | if (onConfirm && typeof onConfirm === 'function') {
43 | onConfirm(data);
44 | }
45 | }, [data, onConfirm]);
46 |
47 | return (
48 |
70 | );
71 | };
72 |
73 | export default CommonDialog;
74 |
--------------------------------------------------------------------------------
/src/components/dialogs/CompositionDialog.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, ReactNode, SyntheticEvent } from 'react';
2 | import { Box, Dialog, DialogActions, DialogContent, DialogProps } from '@mui/material';
3 | import { AppDialogTitle } from './components';
4 | import { useDialogMinWidth } from './utils';
5 |
6 | interface Props extends DialogProps {
7 | title?: string;
8 | content?: ReactNode;
9 | actions?: ReactNode;
10 | onClose?: (event: SyntheticEvent) => void;
11 | }
12 |
13 | /**
14 | * Makes composition of Content and Actions inside the Dialog.
15 | * @component CompositionDialog
16 | */
17 | const CompositionDialog: FunctionComponent = ({
18 | actions,
19 | open = false, // Don't show dialog by default
20 | children = null,
21 | content = null,
22 | title = 'Missing title...',
23 | onClose,
24 | ...restOfProps
25 | }) => {
26 | const paperMinWidth = useDialogMinWidth();
27 |
28 | return (
29 |
53 | );
54 | };
55 |
56 | export default CompositionDialog;
57 |
--------------------------------------------------------------------------------
/src/components/dialogs/components/AppDialogTitle.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, SyntheticEvent } from 'react';
2 | import { DialogTitle, DialogTitleProps, Typography, Stack, useTheme } from '@mui/material';
3 | import { AppIconButton } from '../../';
4 |
5 | interface Props extends DialogTitleProps {
6 | onClose?: (event: SyntheticEvent) => void;
7 | }
8 |
9 | /**
10 | * Renders Material UI Dialog Title with optional (x) button to close the dialog
11 | * @param {function} [onClose] - when set the (x) button added to Dialog Title and event called on button click
12 | */
13 | const AppDialogTitle: FunctionComponent = ({ children, onClose, ...props }) => {
14 | const theme = useTheme();
15 | return (
16 |
17 |
18 |
26 | {children}
27 |
28 |
29 | {Boolean(onClose) ? (
30 |
42 | ) : null}
43 |
44 | );
45 | };
46 |
47 | export default AppDialogTitle;
48 |
--------------------------------------------------------------------------------
/src/components/dialogs/components/index.tsx:
--------------------------------------------------------------------------------
1 | import AppDialogTitle from './AppDialogTitle';
2 |
3 | export { AppDialogTitle };
4 |
--------------------------------------------------------------------------------
/src/components/dialogs/index.tsx:
--------------------------------------------------------------------------------
1 | import CommonDialog from './CommonDialog';
2 | import CompositionDialog from './CompositionDialog';
3 |
4 | export { CommonDialog, CompositionDialog };
5 |
--------------------------------------------------------------------------------
/src/components/dialogs/utils.ts:
--------------------------------------------------------------------------------
1 | import { useTheme } from '@mui/material';
2 | import { useOnWideScreen } from '../../hooks/layout';
3 |
4 | /**
5 | * Returns the width of the dialog's body based on the screen size
6 | * @returns {number} width of the dialog's body
7 | */
8 | export function useDialogMinWidth() {
9 | const theme = useTheme();
10 | const onWideScreen = useOnWideScreen();
11 | const paperMinWidth = onWideScreen ? theme.breakpoints.values.md / 2 : theme.breakpoints.values.sm / 2;
12 | return paperMinWidth;
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | import AppAlert from './AppAlert';
2 | import AppButton from './AppButton';
3 | import AppForm from './AppForm';
4 | import AppIcon from './AppIcon';
5 | import AppIconButton from './AppIconButton';
6 | import AppLink from './AppLink';
7 | import AppLoading from './AppLoading';
8 | import AppView from './AppView';
9 | import ErrorBoundary from './ErrorBoundary';
10 |
11 | export { ErrorBoundary, AppAlert, AppForm, AppButton, AppIcon, AppIconButton, AppLink, AppLoading, AppView };
12 |
--------------------------------------------------------------------------------
/src/hooks/auth.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { useAppStore } from '../store';
4 |
5 | type CurrentUser = {
6 | id?: string;
7 | email?: string;
8 | phone?: string;
9 | avatar?: string;
10 | name?: string;
11 | };
12 |
13 | /**
14 | * Hook to get currently logged user
15 | * @returns {object | undefined} user data as object or undefined if user is not logged in
16 | */
17 | export function useCurrentUser(): CurrentUser | undefined {
18 | const [state] = useAppStore();
19 | return state.currentUser;
20 | }
21 |
22 | /**
23 | * Hook to detect is current user authenticated or not
24 | * @returns {boolean} true if user is authenticated, false otherwise
25 | */
26 | export function useIsAuthenticated() {
27 | const [state] = useAppStore();
28 | let result = state.isAuthenticated;
29 |
30 | // TODO: AUTH: add access token verification or other authentication check here
31 | // result = Boolean(sessionStorageGet('access_token', ''));
32 |
33 | return result;
34 | }
35 |
36 | /**
37 | * Returns event handler to Logout current user
38 | * @returns {function} calling this event logs out current user
39 | */
40 | export function useEventLogout() {
41 | const navigate = useNavigate();
42 | const [, dispatch] = useAppStore();
43 |
44 | return useCallback(() => {
45 | // TODO: AUTH: add auth and tokens cleanup here
46 | // sessionStorageDelete('access_token');
47 |
48 | dispatch({ type: 'LOG_OUT' });
49 | navigate('/', { replace: true }); // Redirect to home page by reloading the App
50 | }, [dispatch, navigate]);
51 | }
52 |
53 | /**
54 | * Adds watchdog and calls different callbacks on user login and logout
55 | * @param {function} afterLogin callback to call after user login
56 | * @param {function} afterLogout callback to call after user logout
57 | */
58 | export function useAuthWatchdog(afterLogin: () => void, afterLogout: () => void) {
59 | const [state, dispatch] = useAppStore();
60 |
61 | useEffect(() => {
62 | if (state.isAuthenticated) {
63 | afterLogin?.();
64 | } else {
65 | afterLogout?.();
66 | }
67 | }, [state.isAuthenticated, dispatch, afterLogin, afterLogout]);
68 | }
69 |
--------------------------------------------------------------------------------
/src/hooks/authFirebase.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from 'react';
2 | import { useAppStore } from '../store';
3 | import { User as FirebaseUser, getAuth, onAuthStateChanged, signOut } from 'firebase/auth';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | type CurrentUser = FirebaseUser | null | undefined; // Firebase User can be null, we also support undefined
7 |
8 | /**
9 | * Hook to get currently logged user
10 | * @returns {object | undefined} user data as object or undefined if user is not logged in
11 | */
12 | export function useCurrentUser(): CurrentUser {
13 | let result;
14 | try {
15 | const auth = getAuth();
16 | result = auth.currentUser;
17 | } catch (error) {
18 | // Do nothing
19 | }
20 | return result;
21 | }
22 |
23 | /**
24 | * Hook to detect is current user authenticated or not
25 | * @returns {boolean} true if user is authenticated, false otherwise
26 | */
27 | export function useIsAuthenticated(): boolean {
28 | const currentUser = useCurrentUser();
29 | return Boolean(currentUser);
30 | }
31 |
32 | /**
33 | * Returns event handler to Logout current user
34 | * @returns {function} calling this event logs out current user
35 | */
36 | export function useEventLogout(): () => void {
37 | const navigate = useNavigate();
38 | const [, dispatch] = useAppStore();
39 |
40 | return useCallback(async () => {
41 | // TODO: AUTH: add auth and tokens cleanup here
42 |
43 | // Firebase sign out
44 | try {
45 | const auth = getAuth();
46 | await signOut(auth);
47 | } catch (error) {
48 | console.error(error);
49 | }
50 |
51 | dispatch({ type: 'LOG_OUT' });
52 | navigate('/', { replace: true }); // Redirect to home page by reloading the App
53 | }, [dispatch, navigate]);
54 | }
55 |
56 | /**
57 | * Adds Firebase Auth watchdog and calls different callbacks on login and logout
58 | * @param {function} afterLogin callback to call after user login
59 | * @param {function} afterLogout callback to call after user logout
60 | */
61 | export function useAuthWatchdog(afterLogin: () => void, afterLogout: () => void) {
62 | const [, dispatch] = useAppStore();
63 |
64 | useEffect(() => {
65 | const auth = getAuth();
66 | onAuthStateChanged(auth, (firebaseUser) => {
67 | if (firebaseUser) {
68 | // Add Firebase User to AppStore
69 | console.warn('Firebase user is logged in - uid:', firebaseUser?.uid);
70 | dispatch({ type: 'LOG_IN', payload: firebaseUser });
71 | // Call callback if any
72 | afterLogin?.();
73 | } else {
74 | // Remove Firebase User from AppStore
75 | console.warn('Firebase user is logged out');
76 | dispatch({ type: 'LOG_OUT' });
77 | // Call callback if any
78 | afterLogout?.();
79 | }
80 | });
81 | }, [dispatch, afterLogin, afterLogout]);
82 | }
83 |
--------------------------------------------------------------------------------
/src/hooks/event.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useAppStore } from '../store';
3 |
4 | /**
5 | * Returns event handler to toggle Dark/Light modes
6 | * @returns {function} calling this event toggles dark/light mode
7 | */
8 | export function useEventSwitchDarkMode() {
9 | const [state, dispatch] = useAppStore();
10 |
11 | return useCallback(() => {
12 | dispatch({
13 | type: 'DARK_MODE',
14 | payload: !state.darkMode,
15 | });
16 | }, [state, dispatch]);
17 | }
18 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth'; // OR './authFirebase';
2 | export * from './event';
3 | export * from './layout';
4 |
--------------------------------------------------------------------------------
/src/hooks/layout.ts:
--------------------------------------------------------------------------------
1 | import { useMediaQuery, useTheme } from '@mui/material';
2 | import { useCallback, useEffect, useState } from 'react';
3 |
4 | /**
5 | * Hook to detect onMobile vs. onDesktop using "resize" event listener
6 | * @returns {boolean} true when on onMobile, false when on onDesktop
7 | */
8 | export function useOnMobileByTrackingWindowsResize() {
9 | const theme = useTheme();
10 | const [onMobile, setOnMobile] = useState(false);
11 |
12 | const handleResize = useCallback(() => {
13 | setOnMobile(window.innerWidth < theme.breakpoints.values.sm); // sx, sm are "onMobile"
14 | }, [theme.breakpoints.values.sm]);
15 |
16 | useEffect(() => {
17 | window.addEventListener('resize', handleResize); // Set resize listener
18 |
19 | return () => {
20 | window.removeEventListener('resize', handleResize); // Remove resize listener
21 | };
22 | }, [handleResize]);
23 |
24 | return onMobile;
25 | }
26 |
27 | /**
28 | * Hook to detect onMobile vs. onDesktop using Media Query
29 | * @returns {boolean} true when on onMobile, false when on onDesktop
30 | */
31 | export function useOnMobileByMediaQuery() {
32 | const theme = useTheme();
33 | return useMediaQuery(theme.breakpoints.down('sm'));
34 | }
35 |
36 | // export const useOnMobile = useOnMobileByTrackingWindowsResize;
37 | export const useOnMobile = useOnMobileByMediaQuery;
38 |
39 | /**
40 | * Hook to detect Wide Screen (lg, xl) using Media Query
41 | * @returns {boolean} true when on screen is wide enough
42 | */
43 | export function useOnWideScreen() {
44 | const theme = useTheme();
45 | return useMediaQuery(theme.breakpoints.up('md'));
46 | }
47 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
4 | 'Droid Sans', 'Helvetica Neue', sans-serif;
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 | }
8 |
9 | code {
10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
11 | }
12 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import MainApp from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
8 | root.render(
9 |
10 |
11 |
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/layout/BottomBar/BottomBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent, FunctionComponent, useCallback } from 'react';
2 | import { useLocation, useNavigate } from 'react-router';
3 | import { BottomNavigation, BottomNavigationAction } from '@mui/material';
4 | import AppIcon from '../../components/AppIcon';
5 | import { LinkToPage } from '../../utils/type';
6 |
7 | interface Props {
8 | items: Array;
9 | }
10 |
11 | /**
12 | * Renders horizontal Navigation Bar using MUI BottomNavigation component
13 | * @component BottomBar
14 | */
15 | const BottomBar: FunctionComponent = ({ items }) => {
16 | const navigate = useNavigate();
17 | const location = useLocation();
18 |
19 | const onNavigationChange = useCallback(
20 | (event: ChangeEvent<{}>, newValue: string) => {
21 | navigate(newValue);
22 | },
23 | [navigate]
24 | );
25 |
26 | return (
27 |
32 | {items.map(({ title, path, icon }) => (
33 | } />
34 | ))}
35 |
36 | );
37 | };
38 |
39 | export default BottomBar;
40 |
--------------------------------------------------------------------------------
/src/layout/BottomBar/index.tsx:
--------------------------------------------------------------------------------
1 | import BottomBar from './BottomBar';
2 |
3 | export { BottomBar as default, BottomBar };
4 |
--------------------------------------------------------------------------------
/src/layout/PrivateLayout.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, FunctionComponent, PropsWithChildren } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { Stack } from '@mui/material';
4 | import { AppIconButton, ErrorBoundary } from '../components';
5 | import { LinkToPage } from '../utils/type';
6 | import { useOnMobile } from '../hooks/layout';
7 | import {
8 | SIDE_BAR_DESKTOP_ANCHOR,
9 | SIDE_BAR_MOBILE_ANCHOR,
10 | SIDE_BAR_WIDTH,
11 | TOP_BAR_DESKTOP_HEIGHT,
12 | TOP_BAR_MOBILE_HEIGHT,
13 | } from './config';
14 | import TopBar from './TopBar';
15 | import SideBar from './SideBar';
16 |
17 | // TODO: change to your app name or other word
18 | const TITLE_PRIVATE = '_TITLE_ app'; // Title for pages after authentication
19 |
20 | /**
21 | * SideBar navigation items with links
22 | */
23 | const SIDE_BAR_ITEMS: Array = [
24 | {
25 | title: 'Home',
26 | path: '/',
27 | icon: 'home',
28 | },
29 | {
30 | title: 'Profile (404)',
31 | path: '/user',
32 | icon: 'account',
33 | },
34 | {
35 | title: 'About',
36 | path: '/about',
37 | icon: 'info',
38 | },
39 | ];
40 |
41 | if (process.env.REACT_APP_DEBUG === 'true') {
42 | SIDE_BAR_ITEMS.push({
43 | title: '[Debug Tools]',
44 | path: '/dev',
45 | icon: 'settings',
46 | });
47 | }
48 |
49 | /**
50 | * Renders "Private Layout" composition
51 | * @layout PrivateLayout
52 | */
53 | const PrivateLayout: FunctionComponent = ({ children }) => {
54 | const navigation = useNavigate();
55 | const [sideBarVisible, setSideBarVisible] = useState(false);
56 | const onMobile = useOnMobile();
57 |
58 | // Variant 1 - Sidebar is static on desktop and is a drawer on mobile
59 | const sidebarOpen = onMobile ? sideBarVisible : true;
60 | const sidebarVariant = onMobile ? 'temporary' : 'persistent';
61 |
62 | // Variant 2 - Sidebar is drawer on mobile and desktop
63 | // const sidebarOpen = sideBarVisible;
64 | // const sidebarVariant = 'temporary';
65 |
66 | const title = TITLE_PRIVATE;
67 | document.title = title; // Also Update Tab Title
68 |
69 | const onLogoClick = useCallback(() => {
70 | // Navigate to first SideBar's item or to '/' when clicking on Logo/Menu icon when SideBar is already visible
71 | navigation(SIDE_BAR_ITEMS?.[0]?.path || '/');
72 | }, [navigation]);
73 |
74 | const onSideBarOpen = () => {
75 | if (!sideBarVisible) setSideBarVisible(true); // Don't re-render Layout when SideBar is already open
76 | };
77 |
78 | const onSideBarClose = () => {
79 | if (sideBarVisible) setSideBarVisible(false); // Don't re-render Layout when SideBar is already closed
80 | };
81 |
82 | // console.log(
83 | // 'Render using PrivateLayout, onMobile:',
84 | // onMobile,
85 | // 'sidebarOpen:',
86 | // sidebarOpen,
87 | // 'sidebarVariant:',
88 | // sidebarVariant
89 | // );
90 |
91 | return (
92 |
101 |
102 | }
104 | title={title}
105 | />
106 |
107 |
114 |
115 |
116 |
125 | {children}
126 |
127 |
128 | );
129 | };
130 |
131 | export default PrivateLayout;
132 |
--------------------------------------------------------------------------------
/src/layout/PublicLayout.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, PropsWithChildren, useCallback, useState } from 'react';
2 | import { Stack } from '@mui/material/';
3 | import { useAppStore } from '../store/AppStore';
4 | import { ErrorBoundary, AppIconButton } from '../components';
5 | import { LinkToPage } from '../utils/type';
6 | import { useOnMobile } from '../hooks/layout';
7 | import { BOTTOM_BAR_DESKTOP_VISIBLE, TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
8 | import { useEventSwitchDarkMode } from '../hooks/event';
9 | import TopBar from './TopBar';
10 | import SideBar from './SideBar';
11 | import BottomBar from './BottomBar';
12 |
13 | // TODO: change to your app name or other word
14 | const TITLE_PUBLIC = '_TITLE_ app'; // Title for pages without/before authentication
15 |
16 | /**
17 | * SideBar navigation items with links
18 | */
19 | const SIDE_BAR_ITEMS: Array = [
20 | {
21 | title: 'Log In',
22 | path: '/auth/login',
23 | icon: 'login',
24 | },
25 | {
26 | title: 'Sign Up',
27 | path: '/auth/signup',
28 | icon: 'signup',
29 | },
30 | {
31 | title: 'About',
32 | path: '/about',
33 | icon: 'info',
34 | },
35 | ];
36 |
37 | if (process.env.REACT_APP_DEBUG === 'true') {
38 | SIDE_BAR_ITEMS.push({
39 | title: '[Debug Tools]',
40 | path: '/dev',
41 | icon: 'settings',
42 | });
43 | }
44 |
45 | /**
46 | * BottomBar navigation items with links
47 | */
48 | const BOTTOM_BAR_ITEMS: Array = [
49 | {
50 | title: 'Log In',
51 | path: '/auth/login',
52 | icon: 'login',
53 | },
54 | {
55 | title: 'Sign Up',
56 | path: '/auth/signup',
57 | icon: 'signup',
58 | },
59 | {
60 | title: 'About',
61 | path: '/about',
62 | icon: 'info',
63 | },
64 | ];
65 |
66 | /**
67 | * Renders "Public Layout" composition
68 | * @layout PublicLayout
69 | */
70 | const PublicLayout: FunctionComponent = ({ children }) => {
71 | const onMobile = useOnMobile();
72 | const onSwitchDarkMode = useEventSwitchDarkMode();
73 | const [sideBarVisible, setSideBarVisible] = useState(false);
74 | const [state] = useAppStore();
75 | const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
76 |
77 | // Variant 1 - Sidebar is static on desktop and is a drawer on mobile
78 | // const sidebarOpen = onMobile ? sideBarVisible : true;
79 | // const sidebarVariant = onMobile ? 'temporary' : 'persistent';
80 |
81 | // Variant 2 - Sidebar is drawer on mobile and desktop
82 | const sidebarOpen = sideBarVisible;
83 | const sidebarVariant = 'temporary';
84 |
85 | const title = TITLE_PUBLIC;
86 | document.title = title; // Also Update Tab Title
87 |
88 | const onSideBarOpen = useCallback(() => {
89 | if (!sideBarVisible) setSideBarVisible(true); // Don't re-render Layout when SideBar is already open
90 | }, [sideBarVisible]);
91 |
92 | const onSideBarClose = useCallback(() => {
93 | if (sideBarVisible) setSideBarVisible(false); // Don't re-render Layout when SideBar is already closed
94 | }, [sideBarVisible]);
95 |
96 | // console.log(
97 | // 'Render using PublicLayout, onMobile:',
98 | // onMobile,
99 | // 'sidebarOpen:',
100 | // sidebarOpen,
101 | // 'sidebarVariant:',
102 | // sidebarVariant
103 | // );
104 |
105 | return (
106 |
112 |
113 | }
115 | title={title}
116 | endNode={
117 |
123 | }
124 | />
125 |
126 |
133 |
134 |
135 |
142 | {children}
143 |
144 |
145 | {bottomBarVisible && }
146 |
147 | );
148 | };
149 |
150 | export default PublicLayout;
151 |
--------------------------------------------------------------------------------
/src/layout/SideBar/SideBar.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, useCallback, MouseEvent } from 'react';
2 | import { Stack, Divider, Drawer, DrawerProps, FormControlLabel, Switch, Tooltip } from '@mui/material';
3 | import { AppIconButton } from '../../components';
4 | import { useAppStore } from '../../store/AppStore';
5 | import { LinkToPage } from '../../utils/type';
6 | import { useEventLogout, useEventSwitchDarkMode, useIsAuthenticated, useOnMobile } from '../../hooks';
7 | import SideBarNavList from './SideBarNavList';
8 | import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
9 | import UserInfo from '../../components/UserInfo';
10 |
11 | interface Props extends Pick {
12 | items: Array;
13 | }
14 |
15 | /**
16 | * Renders SideBar with Menu and User details
17 | * Actually for Authenticated users only, rendered in "Private Layout"
18 | * @component SideBar
19 | * @param {string} anchor - 'left' or 'right'
20 | * @param {boolean} open - the Drawer is visible when true
21 | * @param {string} variant - variant of the Drawer, one of 'permanent', 'persistent', 'temporary'
22 | * @param {function} onClose - called when the Drawer is closing
23 | */
24 | const SideBar: FunctionComponent = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
25 | const [state] = useAppStore();
26 | // const isAuthenticated = state.isAuthenticated; // Variant 1
27 | const isAuthenticated = useIsAuthenticated(); // Variant 2
28 | const onMobile = useOnMobile();
29 |
30 | const onSwitchDarkMode = useEventSwitchDarkMode();
31 | const onLogout = useEventLogout();
32 |
33 | const handleAfterLinkClick = useCallback(
34 | (event: MouseEvent) => {
35 | if (variant === 'temporary' && typeof onClose === 'function') {
36 | onClose(event, 'backdropClick');
37 | }
38 | },
39 | [variant, onClose]
40 | );
41 |
42 | return (
43 |
56 |
64 | {isAuthenticated && (
65 | <>
66 |
67 |
68 | >
69 | )}
70 |
71 |
72 |
73 |
74 |
75 |
84 |
85 | }
88 | />
89 |
90 |
91 | {isAuthenticated && }
92 |
93 |
94 |
95 | );
96 | };
97 |
98 | export default SideBar;
99 |
--------------------------------------------------------------------------------
/src/layout/SideBar/SideBarNavItem.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, MouseEventHandler } from 'react';
2 | import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
3 | import { AppIcon, AppLink } from '../../components';
4 | import { LinkToPage } from '../../utils/type';
5 | import { useLocation } from 'react-router';
6 |
7 | interface Props extends LinkToPage {
8 | openInNewTab?: boolean;
9 | selected?: boolean;
10 | onClick?: MouseEventHandler;
11 | }
12 |
13 | /**
14 | * Renders Navigation Item for SideBar, detects current url and sets selected state if needed
15 | * @component SideBarNavItem
16 | */
17 | const SideBarNavItem: FunctionComponent = ({
18 | openInNewTab,
19 | icon,
20 | path,
21 | selected: propSelected = false,
22 | subtitle,
23 | title,
24 | onClick,
25 | }) => {
26 | const location = useLocation();
27 | const selected = propSelected || (path && path.length > 1 && location.pathname.startsWith(path)) || false;
28 |
29 | return (
30 |
38 | {icon && }
39 |
40 |
41 | );
42 | };
43 |
44 | export default SideBarNavItem;
45 |
--------------------------------------------------------------------------------
/src/layout/SideBar/SideBarNavList.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, MouseEventHandler } from 'react';
2 | import List from '@mui/material/List';
3 | import SideBarNavItem from './SideBarNavItem';
4 | import { LinkToPage } from '../../utils/type';
5 |
6 | interface Props {
7 | items: Array;
8 | showIcons?: boolean;
9 | onClick?: MouseEventHandler;
10 | }
11 |
12 | /**
13 | * Renders list of Navigation Items inside SideBar
14 | * @component SideBarNavList
15 | * @param {array} items - list of objects to render as navigation items
16 | * @param {boolean} [showIcons] - icons in navigation items are visible when true
17 | * @param {function} [onAfterLinkClick] - optional callback called when some navigation item was clicked
18 | */
19 | const SideBarNavList: FunctionComponent = ({ items, showIcons, onClick, ...restOfProps }) => {
20 | return (
21 |
22 | {items.map(({ icon, path, title }) => (
23 |
30 | ))}
31 |
32 | );
33 | };
34 |
35 | export default SideBarNavList;
36 |
--------------------------------------------------------------------------------
/src/layout/SideBar/index.tsx:
--------------------------------------------------------------------------------
1 | import SideBar from './SideBar';
2 |
3 | export { SideBar as default, SideBar };
4 |
--------------------------------------------------------------------------------
/src/layout/TopBar/TopBar.tsx:
--------------------------------------------------------------------------------
1 | import { AppBar, Toolbar, Typography } from '@mui/material';
2 | import { FunctionComponent, ReactNode } from 'react';
3 |
4 | interface Props {
5 | endNode?: ReactNode;
6 | startNode?: ReactNode;
7 | title?: string;
8 | }
9 |
10 | /**
11 | * Renders TopBar composition
12 | * @component TopBar
13 | */
14 | const TopBar: FunctionComponent = ({ endNode, startNode, title = '', ...restOfProps }) => {
15 | return (
16 |
25 |
26 | {startNode}
27 |
28 |
37 | {title}
38 |
39 |
40 | {endNode}
41 |
42 |
43 | );
44 | };
45 |
46 | export default TopBar;
47 |
--------------------------------------------------------------------------------
/src/layout/TopBar/index.tsx:
--------------------------------------------------------------------------------
1 | import TopBar from './TopBar';
2 |
3 | export { TopBar as default, TopBar };
4 |
--------------------------------------------------------------------------------
/src/layout/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Layout configuration
3 | */
4 |
5 | /**
6 | * SideBar configuration
7 | */
8 | export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';
9 | export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';
10 | export const SIDE_BAR_WIDTH = '240px';
11 |
12 | /**
13 | * TopBar configuration
14 | */
15 | export const TOP_BAR_MOBILE_HEIGHT = '56px';
16 | export const TOP_BAR_DESKTOP_HEIGHT = '64px';
17 |
18 | /**
19 | * BottomBar configuration
20 | */
21 | export const BOTTOM_BAR_DESKTOP_VISIBLE = false; // true;
22 |
--------------------------------------------------------------------------------
/src/layout/index.tsx:
--------------------------------------------------------------------------------
1 | import PrivateLayout from './PrivateLayout';
2 | import PublicLayout from './PublicLayout';
3 |
4 | export { PublicLayout as default, PublicLayout, PrivateLayout };
5 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/routes/PrivateRoutes.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Route, Routes } from 'react-router-dom';
2 | import { PrivateLayout } from '../layout';
3 | import { NotFoundView } from '../views';
4 | import AboutView from '../views/About';
5 | import DevView from '../views/Dev';
6 | import WelcomeView from '../views/Welcome';
7 |
8 | /**
9 | * List of routes available for authenticated users
10 | * Also renders the "Private Layout" composition
11 | * @routes PrivateRoutes
12 | */
13 | const PrivateRoutes = () => {
14 | return (
15 |
16 |
17 | } />
18 | }
22 | />
23 | } />
24 | {process.env.REACT_APP_DEBUG && } />}
25 | } />
26 |
27 |
28 | );
29 | };
30 |
31 | export default PrivateRoutes;
32 |
--------------------------------------------------------------------------------
/src/routes/PublicRoutes.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes } from 'react-router-dom';
2 | import { PublicLayout } from '../layout';
3 | import { NotFoundView } from '../views';
4 | import AboutView from '../views/About';
5 | import DevView from '../views/Dev';
6 | import LoginEmailView from '../views/Auth/Login/LoginEmailView';
7 | import AuthRoutes from '../views/Auth';
8 |
9 | /**
10 | * List of routes available for anonymous users
11 | * Also renders the "Public Layout" composition
12 | * @routes PublicRoutes
13 | */
14 | const PublicRoutes = () => {
15 | return (
16 |
17 |
18 | } />
19 | } />
20 | } />
21 | {process.env.REACT_APP_DEBUG === 'true' && } />}
22 | } />
23 |
24 |
25 | );
26 | };
27 |
28 | export default PublicRoutes;
29 |
--------------------------------------------------------------------------------
/src/routes/Routes.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import { BrowserRouter } from 'react-router-dom';
3 | import { AppLoading } from '../components';
4 | import { useAuthWatchdog, useIsAuthenticated } from '../hooks';
5 | import PublicRoutes from './PublicRoutes';
6 | import PrivateRoutes from './PrivateRoutes';
7 |
8 | /**
9 | * Renders routes depending on Authenticated or Anonymous users
10 | * @component Routes
11 | */
12 | const Routes = () => {
13 | const [loading, setLoading] = useState(true);
14 | const [refresh, setRefresh] = useState(0);
15 | const isAuthenticated = useIsAuthenticated();
16 |
17 | const afterLogin = useCallback(() => {
18 | setRefresh((old) => old + 1); // Force re-render
19 | setLoading(false);
20 | }, []);
21 |
22 | const afterLogout = useCallback(() => {
23 | setRefresh((old) => old + 1); // Force re-render
24 | setLoading(false);
25 | }, []);
26 |
27 | // Create Auth watchdog, that calls our callbacks wen user is logged in or logged out
28 | useAuthWatchdog(afterLogin, afterLogout);
29 |
30 | if (loading) {
31 | return ;
32 | }
33 |
34 | console.log(`Routes() - isAuthenticated: ${isAuthenticated}, refreshCount: ${refresh}`);
35 | return (
36 | {isAuthenticated ? : }
37 | );
38 | };
39 | export default Routes;
40 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import Routes from './Routes';
2 |
3 | export { Routes as default, Routes };
4 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/store/AppReducer.ts:
--------------------------------------------------------------------------------
1 | import { localStorageSet } from '../utils/localStorage';
2 | import { AppStoreState } from './AppStore';
3 |
4 | /**
5 | * Reducer for global AppStore using "Redux styled" actions
6 | * @param {object} state - current/default state
7 | * @param {string} action.type - unique name of the action
8 | * @param {*} [action.payload] - optional data object or the function to get data object
9 | */
10 | const AppReducer: React.Reducer = (state, action) => {
11 | // console.log('AppReducer() - action:', action);
12 | switch (action.type || action.action) {
13 | case 'CURRENT_USER':
14 | return {
15 | ...state,
16 | currentUser: action?.currentUser || action?.payload,
17 | };
18 | case 'SIGN_UP':
19 | case 'LOG_IN':
20 | return {
21 | ...state,
22 | isAuthenticated: true,
23 | };
24 | case 'LOG_OUT':
25 | return {
26 | ...state,
27 | isAuthenticated: false,
28 | currentUser: undefined, // Also reset previous user data
29 | };
30 | case 'DARK_MODE': {
31 | const darkMode = action?.darkMode ?? action?.payload;
32 | localStorageSet('darkMode', darkMode);
33 | return {
34 | ...state,
35 | darkMode,
36 | };
37 | }
38 | default:
39 | return state;
40 | }
41 | };
42 |
43 | export default AppReducer;
44 |
--------------------------------------------------------------------------------
/src/store/AppStore.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | useReducer,
4 | useContext,
5 | FunctionComponent,
6 | Dispatch,
7 | ComponentType,
8 | PropsWithChildren,
9 | } from 'react';
10 | import useMediaQuery from '@mui/material/useMediaQuery';
11 | import AppReducer from './AppReducer';
12 | import { localStorageGet } from '../utils/localStorage';
13 |
14 | /**
15 | * AppState structure and initial values
16 | */
17 | export interface AppStoreState {
18 | darkMode: boolean;
19 | isAuthenticated: boolean;
20 | currentUser?: object | undefined;
21 | }
22 | const INITIAL_APP_STATE: AppStoreState = {
23 | darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore
24 | isAuthenticated: false, // Overridden in AppStore by checking auth token
25 | };
26 |
27 | /**
28 | * Instance of React Context for global AppStore
29 | */
30 | type AppContextReturningType = [AppStoreState, Dispatch];
31 | const AppContext = createContext([INITIAL_APP_STATE, () => null]);
32 |
33 | /**
34 | * Main global Store as HOC with React Context API
35 | *
36 | * import {AppStoreProvider} from './store'
37 | * ...
38 | *
39 | *
40 | *
41 | */
42 | const AppStoreProvider: FunctionComponent = ({ children }) => {
43 | const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
44 | const previousDarkMode = Boolean(localStorageGet('darkMode'));
45 | // const tokenExists = Boolean(loadToken());
46 |
47 | const initialState: AppStoreState = {
48 | ...INITIAL_APP_STATE,
49 | darkMode: previousDarkMode || prefersDarkMode,
50 | // isAuthenticated: tokenExists,
51 | };
52 | const value: AppContextReturningType = useReducer(AppReducer, initialState);
53 |
54 | return {children};
55 | };
56 |
57 | /**
58 | * Hook to use the AppStore in functional components
59 | *
60 | * import {useAppStore} from './store'
61 | * ...
62 | * const [state, dispatch] = useAppStore();
63 | */
64 | const useAppStore = (): AppContextReturningType => useContext(AppContext);
65 |
66 | /**
67 | * HOC to inject the ApStore to class component, also works for functional components
68 | *
69 | * import {withAppStore} from './store'
70 | * ...
71 | * class MyComponent
72 | * ...
73 | * export default withAppStore(MyComponent)
74 | */
75 | interface WithAppStoreProps {
76 | store: object;
77 | }
78 | const withAppStore =
79 | (Component: ComponentType): FunctionComponent =>
80 | (props) => {
81 | return ;
82 | };
83 |
84 | export { AppStoreProvider as AppStore, AppContext, useAppStore, withAppStore };
85 |
--------------------------------------------------------------------------------
/src/store/index.tsx:
--------------------------------------------------------------------------------
1 | import { AppStore, AppContext, useAppStore, withAppStore } from './AppStore';
2 |
3 | export { AppStore as default, AppStore, AppContext, useAppStore, withAppStore };
4 |
--------------------------------------------------------------------------------
/src/theme/AppThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, useMemo, PropsWithChildren } from 'react';
2 | import { CacheProvider, EmotionCache } from '@emotion/react';
3 | import { createTheme, CssBaseline, ThemeProvider } from '@mui/material';
4 | import { useAppStore } from '../store';
5 | import DARK_THEME from './dark';
6 | import LIGHT_THEME from './light';
7 | import createEmotionCache from './createEmotionCache';
8 |
9 | function getThemeByDarkMode(darkMode: boolean) {
10 | return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME);
11 | }
12 |
13 | // Client-side cache, shared for the whole session of the user in the browser.
14 | const CLIENT_SIDE_EMOTION_CACHE = createEmotionCache();
15 |
16 | interface Props extends PropsWithChildren {
17 | emotionCache?: EmotionCache; // You can omit it if you don't want to use Emotion styling library
18 | }
19 |
20 | /**
21 | * Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App
22 | * The Light or Dark themes applied depending on global .darkMode state
23 | * @param {EmotionCache} [emotionCache] - shared Emotion's cache to use in the App
24 | */
25 | const AppThemeProvider: FunctionComponent = ({ children, emotionCache = CLIENT_SIDE_EMOTION_CACHE }) => {
26 | const [state] = useAppStore();
27 |
28 | const theme = useMemo(
29 | () => getThemeByDarkMode(state.darkMode),
30 | [state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes
31 | );
32 |
33 | return (
34 |
35 | {/* use this instead of Emotion's if you want to use alternate styling library */}
36 |
37 |
38 | {children}
39 |
40 | {/* */}
41 |
42 | );
43 | };
44 |
45 | export default AppThemeProvider;
46 |
--------------------------------------------------------------------------------
/src/theme/colors.ts:
--------------------------------------------------------------------------------
1 | import { PaletteOptions, SimplePaletteColorOptions } from '@mui/material';
2 |
3 | const COLOR_PRIMARY: SimplePaletteColorOptions = {
4 | main: '#64B5F6',
5 | contrastText: '#000000',
6 | // light: '#64B5F6',
7 | // dark: '#64B5F6',
8 | };
9 |
10 | const COLOR_SECONDARY: SimplePaletteColorOptions = {
11 | main: '#EF9A9A',
12 | contrastText: '#000000',
13 | // light: '#EF9A9A',
14 | // dark: '#EF9A9A',
15 | };
16 |
17 | /**
18 | * MUI colors set to use in theme.palette
19 | */
20 | export const PALETTE_COLORS: Partial = {
21 | primary: COLOR_PRIMARY,
22 | secondary: COLOR_SECONDARY,
23 | // error: COLOR_ERROR,
24 | // warning: COLOR_WARNING;
25 | // info: COLOR_INFO;
26 | // success: COLOR_SUCCESS;
27 | };
28 |
--------------------------------------------------------------------------------
/src/theme/createEmotionCache.ts:
--------------------------------------------------------------------------------
1 | import createCache, { EmotionCache } from '@emotion/cache';
2 |
3 | /**
4 | * Creates an emotion cache with .prepend option set to true.
5 | * This moves MUI styles to the top of the so they're loaded first.
6 | * It allows overriding MUI styles with other styling solutions, like CSS modules.
7 | * @returns {EmotionCache}
8 | */
9 | export function createEmotionCache(): EmotionCache {
10 | return createCache({ key: 'css', prepend: true });
11 | }
12 |
13 | export default createEmotionCache;
14 |
--------------------------------------------------------------------------------
/src/theme/dark.ts:
--------------------------------------------------------------------------------
1 | import { ThemeOptions } from '@mui/material';
2 | import { PALETTE_COLORS } from './colors';
3 |
4 | /**
5 | * MUI theme options for "Dark Mode"
6 | */
7 | export const DARK_THEME: ThemeOptions = {
8 | palette: {
9 | mode: 'dark',
10 | // background: {
11 | // paper: '#424242', // Gray 800 - Background of "Paper" based component
12 | // default: '#121212',
13 | // },
14 | ...PALETTE_COLORS,
15 | },
16 | };
17 |
18 | export default DARK_THEME;
19 |
--------------------------------------------------------------------------------
/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import AppThemeProvider from './AppThemeProvider';
2 | import DARK_THEME from './dark';
3 | import LIGHT_THEME from './light';
4 |
5 | export * from './createEmotionCache';
6 | export {
7 | LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default
8 | DARK_THEME,
9 | LIGHT_THEME,
10 | AppThemeProvider,
11 | };
12 |
--------------------------------------------------------------------------------
/src/theme/light.ts:
--------------------------------------------------------------------------------
1 | import { ThemeOptions } from '@mui/material';
2 | import { PALETTE_COLORS } from './colors';
3 |
4 | /**
5 | * MUI theme options for "Light Mode"
6 | */
7 | export const LIGHT_THEME: ThemeOptions = {
8 | palette: {
9 | mode: 'light',
10 | // background: {
11 | // paper: '#f5f5f5', // Gray 100 - Background of "Paper" based component
12 | // default: '#FFFFFF',
13 | // },
14 | ...PALETTE_COLORS,
15 | },
16 | };
17 |
18 | export default LIGHT_THEME;
19 |
--------------------------------------------------------------------------------
/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import { format } from 'date-fns';
2 |
3 | export const FORMAT_DATE_TIME = 'yyyy-MM-dd HH:mm:ss';
4 | export const FORMAT_DATE_ONLY = 'yyyy-MM-dd';
5 | export const FORMAT_TIME_ONLY = 'HH:mm:ss';
6 |
7 | /**
8 | * Main Data and Time conversion utility to keep formats the same across entire Application
9 | * @param {string|object} dateOrString - date to show as UTC string or Date object instance
10 | * @param {string} [dateFormat] - time conversion template in 'date-fns' format, `FORMAT_DATE_TIME` by default
11 | * @param {string} [fallbackValue] - optional fallback value if data conversion is not possible
12 | */
13 | export function dateToString(dateOrString: string | Date, dateFormat = FORMAT_DATE_TIME, fallbackValue = ''): string {
14 | const date = typeof dateOrString === 'object' ? dateOrString : new Date(dateOrString);
15 | let result;
16 | try {
17 | result = format(date, dateFormat);
18 | } catch (error) {
19 | result = fallbackValue;
20 | }
21 | return result;
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/environment.ts:
--------------------------------------------------------------------------------
1 | export const IS_SERVER = typeof window === 'undefined';
2 | export const IS_BROWSER = typeof window !== 'undefined' && typeof window?.document !== 'undefined';
3 | /* eslint-disable no-restricted-globals */
4 | export const IS_WEBWORKER =
5 | typeof self === 'object' && self.constructor && self.constructor.name === 'DedicatedWorkerGlobalScope';
6 | /* eslint-enable no-restricted-globals */
7 |
8 | export function getCurrentVersion(): string {
9 | return process.env?.npm_package_version ?? process.env.REACT_APP_VERSION ?? 'unknown';
10 | }
11 |
12 | export function getCurrentEnvironment(): string {
13 | return process.env?.NODE_ENV ?? 'development';
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/form.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback, SyntheticEvent, ChangeEvent } from 'react';
2 | import validate from 'validate.js';
3 | import { ObjectPropByName } from './type';
4 |
5 | // Same props to style Input, TextField, and so on across the Application
6 | export const SHARED_CONTROL_PROPS = {
7 | variant: 'outlined',
8 | margin: 'normal', // 'dense', 'none'
9 | fullWidth: true,
10 | } as const;
11 |
12 | // "Schema" for formState
13 | interface FormState {
14 | values: object; // List of Input Values as string|boolean
15 | touched?: object; // List of Inputs have been touched as boolean
16 | errors?: object; // List of Errors for every field as array[] of strings
17 | }
18 |
19 | /**
20 | * Basic object to use as initial value for formState
21 | * Usage: const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
22 | */
23 | export const DEFAULT_FORM_STATE: FormState = {
24 | values: {},
25 | touched: {},
26 | errors: {},
27 | };
28 |
29 | /**
30 | * Reusable event to cancel the default behavior
31 | */
32 | export const eventPreventDefault = (event: SyntheticEvent) => {
33 | event.preventDefault();
34 | };
35 |
36 | /**
37 | * Verifies does the From field with given Name has the Error
38 | */
39 | export const formHasError = (formState: FormState, fieldName: string): boolean => {
40 | return Boolean(
41 | (formState.touched as ObjectPropByName)[fieldName] && (formState.errors as ObjectPropByName)[fieldName]
42 | );
43 | };
44 |
45 | /**
46 | * Returns text of "top most" Error for the Form field by given Name.
47 | * Returns null if there is no Error.
48 | */
49 | export const formGetError = (formState: FormState, fieldName: string): string => {
50 | return formHasError(formState, fieldName) ? (formState.errors as ObjectPropByName)[fieldName]?.[0] : null;
51 | };
52 |
53 | // Params for useAppForm() hook
54 | interface UseAppFormParams {
55 | validationSchema: object;
56 | initialValues: object;
57 | }
58 |
59 | // Return type for useAppForm() hook
60 |
61 | interface UseAppFormReturn {
62 | formState: FormState;
63 | setFormState: (formState: FormState) => void;
64 | onFieldChange: (event: ChangeEvent) => void;
65 | fieldGetError: (fieldName: string) => string;
66 | fieldHasError: (fieldName: string) => boolean;
67 | isFormValid: () => boolean;
68 | isFormTouched: () => boolean;
69 | }
70 |
71 | /**
72 | * Application "standard" From as Hook
73 | * Note: the "name" prop of all Form controls must be set! We use event.target?.name for binding data.
74 | * Usage: const [formState, setFormState, onFieldChange, fieldGetError, fieldHasError] = useAppForm({
75 | validationSchema: XXX_FORM_SCHEMA,
76 | initialValues: {name: 'John Doe'},
77 | });
78 | * @param {object} options.validationSchema - validation schema in 'validate.js' format
79 | * @param {object} [options.initialValues] - optional initialization data for formState.values
80 | */
81 | export function useAppForm({ validationSchema, initialValues = {} }: UseAppFormParams): UseAppFormReturn {
82 | // Validate params
83 | if (!validationSchema) {
84 | throw new Error('useAppForm() - the option `validationSchema` is required');
85 | }
86 | if (typeof validationSchema !== 'object') {
87 | throw new Error('useAppForm() - the option `validationSchema` should be an object');
88 | }
89 | if (typeof initialValues !== 'object') {
90 | throw new Error('useAppForm() - the option `initialValues` should be an object');
91 | }
92 |
93 | // Create Form state and apply initialValues if set
94 | const [formState, setFormState] = useState({ ...DEFAULT_FORM_STATE, values: initialValues });
95 |
96 | // Validation by 'validate.js' on every formState.values change
97 | useEffect(() => {
98 | const errors = validate(formState.values, validationSchema);
99 | setFormState((currentFormState) => ({
100 | ...currentFormState,
101 | errors: errors || {},
102 | }));
103 | }, [validationSchema, formState.values]);
104 |
105 | // Event to call on every Input change. Note: the "name" props of the Input control must be set!
106 | const onFieldChange = useCallback((event: ChangeEvent) => {
107 | const name = event.target?.name;
108 | const value =
109 | event.target?.type === 'checkbox'
110 | ? event.target?.checked // Checkbox Input
111 | : event.target?.value; // Any other Input
112 |
113 | setFormState((formState) => ({
114 | ...formState,
115 | values: {
116 | ...formState.values,
117 | [name]: value,
118 | },
119 | touched: {
120 | ...formState.touched,
121 | [name]: true,
122 | },
123 | }));
124 | }, []);
125 |
126 | // Returns text of "top most" Error for the Field by given Name or null
127 | const fieldGetError = (fieldName: string): string => formGetError(formState, fieldName);
128 |
129 | // Verifies does the Field with given Name has the Error
130 | const fieldHasError = (fieldName: string): boolean => formHasError(formState, fieldName);
131 |
132 | // Verifies does form has any error
133 | const isFormValid = () => Object.keys(formState?.errors ?? {}).length < 1;
134 |
135 | // Verifies does any of the form fields has been touched
136 | const isFormTouched = () => Object.keys(formState?.touched ?? {}).length > 0;
137 |
138 | // Return state and methods
139 | return {
140 | formState,
141 | isFormValid,
142 | isFormTouched,
143 | onFieldChange,
144 | fieldGetError,
145 | fieldHasError,
146 | setFormState,
147 | };
148 | }
149 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './environment';
2 | export * from './date';
3 | export * from './form';
4 | export * from './localStorage';
5 | export * from './navigation';
6 | export * from './sessionStorage';
7 | export * from './path';
8 | export * from './sleep';
9 | export * from './type';
10 | export * from './text';
11 |
--------------------------------------------------------------------------------
/src/utils/localStorage.ts:
--------------------------------------------------------------------------------
1 | import { IS_SERVER } from './environment';
2 |
3 | /* eslint-disable @typescript-eslint/no-explicit-any */
4 |
5 | /**
6 | * Smartly reads value from localStorage
7 | */
8 | export function localStorageGet(name: string, defaultValue: any = ''): any {
9 | if (IS_SERVER) {
10 | return defaultValue; // We don't have access to localStorage on the server
11 | }
12 |
13 | const valueFromStore = localStorage.getItem(name);
14 | if (valueFromStore === null) return defaultValue; // No value in store, return default one
15 |
16 | try {
17 | const jsonParsed: unknown = JSON.parse(valueFromStore);
18 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
19 | return jsonParsed; // We successfully parse JS value from the store
20 | }
21 | } catch (error) {
22 | // Do nothing
23 | }
24 |
25 | return valueFromStore; // Return string value as it is
26 | }
27 |
28 | /**
29 | * Smartly writes value into localStorage
30 | */
31 | export function localStorageSet(name: string, value: any) {
32 | if (IS_SERVER) {
33 | return; // Do nothing on server side
34 | }
35 | if (typeof value === 'undefined') {
36 | return; // Do not store undefined values
37 | }
38 | let valueAsString: string;
39 | if (typeof value === 'object') {
40 | valueAsString = JSON.stringify(value);
41 | } else {
42 | valueAsString = String(value);
43 | }
44 |
45 | localStorage.setItem(name, valueAsString);
46 | }
47 |
48 | /* eslint-enable @typescript-eslint/no-explicit-any */
49 |
50 | /**
51 | * Deletes value by name from localStorage, if specified name is empty entire localStorage is cleared.
52 | */
53 | export function localStorageDelete(name: string) {
54 | if (IS_SERVER) {
55 | return; // Do nothing on server side
56 | }
57 | if (name) {
58 | localStorage.removeItem(name);
59 | } else {
60 | localStorage.clear();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/utils/navigation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Disables "Back" button for current page
3 | * Usage: Call function in useEffect( ,[]) or directly
4 | */
5 | export function disableBackNavigation() {
6 | window.history.pushState(null, '', window.location.href);
7 | window.onpopstate = function () {
8 | window.history.go(1);
9 | };
10 | }
11 |
12 | /**
13 | * Navigates to the specified URL with options
14 | */
15 | export function navigateTo(url: string, replaceInsteadOfPush = false, optionalTitle = '') {
16 | if (replaceInsteadOfPush) {
17 | window.history.replaceState(null, optionalTitle, url);
18 | } else {
19 | window.history.pushState(null, optionalTitle, url);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/path.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Checks last char of the string is it a slash or not.
3 | * @param {string} path - the path to check.
4 | * @returns {boolean} true if last char is a slash.
5 | */
6 | export function hasTrailingSlash(path: string): boolean {
7 | return (
8 | typeof path === 'string' && (path?.charAt(path?.length - 1) === '/' || path?.charAt(path?.length - 1) === '\\')
9 | );
10 | }
11 |
12 | /**
13 | * Adds a slash to the path if it doesn't have one.
14 | */
15 | export function addTrailingSlash(path: string): string {
16 | return hasTrailingSlash(path) ? path : path + '/';
17 | }
18 |
19 | /**
20 | * Removes ending slash from the path if it has one.
21 | */
22 | export function removeTrailingSlash(path: string): string {
23 | return hasTrailingSlash(path) ? path.slice(0, -1) : path;
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/sessionStorage.ts:
--------------------------------------------------------------------------------
1 | import { IS_SERVER } from './environment';
2 |
3 | /* eslint-disable @typescript-eslint/no-explicit-any */
4 |
5 | /**
6 | * Smartly reads value from sessionStorage
7 | */
8 | export function sessionStorageGet(name: string, defaultValue: any = ''): any {
9 | if (IS_SERVER) {
10 | return defaultValue; // We don't have access to sessionStorage on the server
11 | }
12 |
13 | const valueFromStore = sessionStorage.getItem(name);
14 | if (valueFromStore === null) return defaultValue; // No value in store, return default one
15 |
16 | try {
17 | const jsonParsed: unknown = JSON.parse(valueFromStore);
18 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
19 | return jsonParsed; // We successfully parse JS value from the store
20 | }
21 | } catch (error) {
22 | // Do nothing
23 | }
24 |
25 | return valueFromStore; // Return string value as it is
26 | }
27 |
28 | /**
29 | * Smartly writes value into sessionStorage
30 | */
31 | export function sessionStorageSet(name: string, value: any) {
32 | if (IS_SERVER) {
33 | return; // Do nothing on server side
34 | }
35 | if (typeof value === 'undefined') {
36 | return; // Do not store undefined values
37 | }
38 | let valueAsString: string;
39 | if (typeof value === 'object') {
40 | valueAsString = JSON.stringify(value);
41 | } else {
42 | valueAsString = String(value);
43 | }
44 |
45 | sessionStorage.setItem(name, valueAsString);
46 | }
47 |
48 | /* eslint-enable @typescript-eslint/no-explicit-any */
49 |
50 | /**
51 | * Deletes value by name from sessionStorage, if specified name is empty entire sessionStorage is cleared.
52 | */
53 | export function sessionStorageDelete(name: string) {
54 | if (IS_SERVER) {
55 | return; // Do nothing on server side
56 | }
57 | if (name) {
58 | sessionStorage.removeItem(name);
59 | } else {
60 | sessionStorage.clear();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/utils/sleep.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Delays code executions for specific amount of time. Must be called with await!
3 | * @param {number} interval - number of milliseconds to wait for
4 | */
5 | export async function sleep(interval = 1000) {
6 | return new Promise((resolve) => setTimeout(resolve, interval));
7 | }
8 |
9 | export default sleep;
10 |
--------------------------------------------------------------------------------
/src/utils/style.ts:
--------------------------------------------------------------------------------
1 | import { Theme } from '@mui/material/styles';
2 |
3 | export type ColorName =
4 | | 'default' // MUI 5.x removes 'default' form Button, we need to fix this
5 | | 'primary'
6 | | 'secondary'
7 | | 'error' // Missing in MUI 4.x
8 | | 'warning' // Missing in MUI 4.x
9 | | 'info' // Missing in MUI 4.x
10 | | 'success' // Missing in MUI 4.x
11 | | 'false' // Missing in MUI 5.x
12 | | 'true'; // Missing in MUI 5.x
13 |
14 | /**
15 | * Makes style to use for Material UI Paper components across the App
16 | */
17 | export const paperStyle = (theme: Theme) => ({
18 | paddingTop: theme.spacing(1),
19 | paddingBottom: theme.spacing(1),
20 | paddingLeft: theme.spacing(2),
21 | paddingRight: theme.spacing(2),
22 | });
23 |
24 | /**
25 | * Makes style for Forms across the App
26 | */
27 | export const formStyle = (theme: Theme) => ({
28 | width: '100%',
29 | maxWidth: '40rem', // 640px
30 | });
31 |
32 | /**
33 | * Makes style to use with Material UI dialogs across the App
34 | */
35 | export const dialogStyles = (
36 | theme: Theme
37 | ): { xButton: any; paper: any; formControl: any; content: any; actions: any } => ({
38 | xButton: {
39 | position: 'absolute',
40 | right: theme.spacing(0.5),
41 | top: theme.spacing(0.5),
42 | },
43 | paper: {
44 | [theme.breakpoints.up('md')]: {
45 | minWidth: theme.breakpoints.values.md / 2,
46 | },
47 | [theme.breakpoints.down('md')]: {
48 | minWidth: theme.breakpoints.values.sm / 2,
49 | },
50 | },
51 | formControl: {
52 | marginTop: theme.spacing(1),
53 | marginBottom: theme.spacing(1),
54 | },
55 | content: {
56 | paddingTop: theme.spacing(1),
57 | paddingBottom: theme.spacing(1),
58 | },
59 | actions: {
60 | paddingLeft: theme.spacing(3),
61 | paddingRight: theme.spacing(3),
62 | },
63 | });
64 |
65 | /**
66 | * Makes "filled" styles for Material UI names 'primary', 'secondary', 'warning', and so on
67 | */
68 | export const filledStylesByNames = (theme: Theme) => ({
69 | // Standard MUI names
70 | default: {
71 | // MUI 5.x removes 'default' color from Button, we need to fix this
72 | backgroundColor: theme.palette.grey[300],
73 | color: 'rgba(0, 0, 0, 0.87)', // Value as theme.palette.text.primary in Light Mode
74 | },
75 | primary: {
76 | backgroundColor: theme.palette.primary.main,
77 | color: theme.palette.primary.contrastText,
78 | },
79 | secondary: {
80 | backgroundColor: theme.palette.secondary.main,
81 | color: theme.palette.secondary.contrastText,
82 | },
83 | error: {
84 | backgroundColor: theme.palette.error.main,
85 | color: theme.palette.error.contrastText,
86 | },
87 | warning: {
88 | backgroundColor: theme.palette.warning.main,
89 | color: theme.palette.warning.contrastText,
90 | },
91 | info: {
92 | backgroundColor: theme.palette.info.main,
93 | color: theme.palette.info.contrastText,
94 | },
95 | success: {
96 | backgroundColor: theme.palette.success.main,
97 | color: theme.palette.success.contrastText,
98 | },
99 | // Boolean
100 | false: {
101 | backgroundColor: theme.palette.error.main,
102 | color: theme.palette.error.contrastText,
103 | },
104 | true: {
105 | backgroundColor: theme.palette.success.main,
106 | color: theme.palette.success.contrastText,
107 | },
108 | });
109 |
110 | /**
111 | * Makes "text" styles for Material UI names 'primary', 'secondary', 'warning', etc.
112 | * Also adds 'true' and 'false' classes
113 | */
114 | export const textStylesByNames = (theme: Theme) => ({
115 | // Standard MUI names
116 | default: {},
117 | primary: {
118 | color: theme.palette.primary.main,
119 | },
120 | secondary: {
121 | color: theme.palette.secondary.main,
122 | },
123 | error: {
124 | color: theme.palette.error.main,
125 | },
126 | warning: {
127 | color: theme.palette.warning.main,
128 | },
129 | info: {
130 | color: theme.palette.info.main,
131 | },
132 | success: {
133 | color: theme.palette.success.main,
134 | },
135 | // Boolean
136 | false: {
137 | color: theme.palette.error.main,
138 | },
139 | true: {
140 | color: theme.palette.success.main,
141 | },
142 | });
143 |
144 | /**
145 | * Makes "filled" + "hover" (like in Buttons) styles for Material UI names 'primary', 'secondary', 'warning', and so on
146 | * Note: Fully compatible with variant="contained" only
147 | */
148 | export const buttonStylesByNames = (theme: Theme) => ({
149 | // Standard MUI names
150 | default: {
151 | // MUI 5.x removes 'default' color from Button, we need to fix this
152 | backgroundColor: theme.palette.grey[300],
153 | color: 'rgba(0, 0, 0, 0.87)', // Value as theme.palette.text.primary in Light Mode
154 | '&:hover': {
155 | backgroundColor: theme.palette.grey[400], // It was '#d5d5d5' in MUI 4.x
156 | color: 'rgba(0, 0, 0, 0.87)', // Value as theme.palette.text.primary in Light Mode
157 | },
158 | '&:disabled': {
159 | backgroundColor: theme.palette.grey[300], // In live MUI 4.x project lite: rgba(0, 0, 0, 0.12) dark: rgba(255, 255, 255, 0.12)
160 | color: 'rgba(0, 0, 0, 0.26)', // In live MUI 4.x project lite: rgba(0, 0, 0, 0.26) dark: rgba(255, 255, 255, 0.3)
161 | },
162 | },
163 | primary: {
164 | backgroundColor: theme.palette.primary.main,
165 | color: theme.palette.primary.contrastText,
166 | '&:hover': {
167 | backgroundColor: theme.palette.primary.dark,
168 | color: theme.palette.primary.contrastText,
169 | },
170 | '&:disabled': {
171 | backgroundColor: theme.palette.primary.light,
172 | },
173 | },
174 | secondary: {
175 | backgroundColor: theme.palette.secondary.main,
176 | color: theme.palette.secondary.contrastText,
177 | '&:hover': {
178 | backgroundColor: theme.palette.secondary.dark,
179 | color: theme.palette.secondary.contrastText,
180 | },
181 | '&:disabled': {
182 | backgroundColor: theme.palette.secondary.light,
183 | },
184 | },
185 | error: {
186 | backgroundColor: theme.palette.error.main,
187 | color: theme.palette.error.contrastText,
188 | '&:hover': {
189 | backgroundColor: theme.palette.error.dark,
190 | color: theme.palette.error.contrastText,
191 | },
192 | '&:disabled': {
193 | backgroundColor: theme.palette.error.light,
194 | },
195 | },
196 | warning: {
197 | backgroundColor: theme.palette.warning.main,
198 | color: theme.palette.warning.contrastText,
199 | '&:hover': {
200 | backgroundColor: theme.palette.warning.dark,
201 | color: theme.palette.warning.contrastText,
202 | },
203 | '&:disabled': {
204 | backgroundColor: theme.palette.warning.light,
205 | },
206 | },
207 | info: {
208 | backgroundColor: theme.palette.info.main,
209 | color: theme.palette.info.contrastText,
210 | '&:hover': {
211 | backgroundColor: theme.palette.info.dark,
212 | color: theme.palette.info.contrastText,
213 | },
214 | '&:disabled': {
215 | backgroundColor: theme.palette.info.light,
216 | },
217 | },
218 | success: {
219 | backgroundColor: theme.palette.success.main,
220 | color: theme.palette.success.contrastText,
221 | '&:hover': {
222 | backgroundColor: theme.palette.success.dark,
223 | color: theme.palette.success.contrastText,
224 | },
225 | '&:disabled': {
226 | backgroundColor: theme.palette.success.light,
227 | },
228 | },
229 | // Boolean
230 | false: {
231 | backgroundColor: theme.palette.error.main,
232 | color: theme.palette.error.contrastText,
233 | '&:hover': {
234 | backgroundColor: theme.palette.error.dark,
235 | color: theme.palette.error.contrastText,
236 | },
237 | '&:disabled': {
238 | backgroundColor: theme.palette.error.light,
239 | },
240 | },
241 | true: {
242 | backgroundColor: theme.palette.success.main,
243 | color: theme.palette.success.contrastText,
244 | '&:hover': {
245 | backgroundColor: theme.palette.success.dark,
246 | color: theme.palette.success.contrastText,
247 | },
248 | '&:disabled': {
249 | backgroundColor: theme.palette.success.light,
250 | },
251 | },
252 | });
253 |
--------------------------------------------------------------------------------
/src/utils/text.ts:
--------------------------------------------------------------------------------
1 | export const CHARS_NUMERIC = '0123456789';
2 | export const CHARS_ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz';
3 | export const CHARS_ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
4 | export const CHARS_ALPHA_NUMERIC = CHARS_NUMERIC + CHARS_ALPHA_LOWER + CHARS_ALPHA_UPPER;
5 |
6 | /**
7 | * Generate a random string of a given length using a given set of characters
8 | * @param {number} length - the length of the string to generate
9 | * @param {string} [allowedChars] - the set of characters to use in the string, defaults to all alphanumeric characters in upper and lower case + numbers
10 | * @returns {string} - the generated string
11 | */
12 | export function randomText(length: number, allowedChars = CHARS_ALPHA_NUMERIC) {
13 | let result = '';
14 | const charLength = allowedChars.length;
15 | let counter = 0;
16 | while (counter < length) {
17 | result += allowedChars.charAt(Math.floor(Math.random() * charLength));
18 | counter += 1;
19 | }
20 | return result;
21 | }
22 | /**
23 | * Compare two strings including null and undefined values
24 | * @param {string} a - the first string to compare
25 | * @param {string} b - the second string to compare
26 | * @returns {boolean} - true if the strings are the same or both null or undefined, false otherwise
27 | */
28 | export function compareTexts(a: string | null | undefined, b: string | null | undefined) {
29 | if (a === undefined || a === null || a === '') {
30 | return b === undefined || b === null || b === '';
31 | }
32 | return a === b;
33 | }
34 |
35 | /**
36 | * Capitalize the first letter of a string
37 | * @param {string} s - the string to capitalize
38 | * @returns {string} - the capitalized string
39 | */
40 | export const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.substring(1);
41 |
42 | /**
43 | * Generate a random color as #RRGGBB value
44 | * @returns {string} - the generated color
45 | */
46 | export function randomColor() {
47 | const color = Math.floor(Math.random() * 16777215).toString(16);
48 | return '#' + color;
49 | }
50 |
--------------------------------------------------------------------------------
/src/utils/type.ts:
--------------------------------------------------------------------------------
1 | // Helper to read object's properties as obj['name']
2 | export type ObjectPropByName = Record;
3 |
4 | /**
5 | * Data for "Page Link" in SideBar adn other UI elements
6 | */
7 | export type LinkToPage = {
8 | icon?: string; // Icon name to use as
9 | path?: string; // URL to navigate to
10 | title?: string; // Title or primary text to display
11 | subtitle?: string; // Sub-title or secondary text to display
12 | };
13 |
--------------------------------------------------------------------------------
/src/views/About/AboutView.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardActions, CardContent, CardHeader, Divider, Grid, Typography } from '@mui/material';
2 | import { AppButton, AppLink, AppIconButton, AppView } from '../../components';
3 | import DialogsSection from './DialogsSection';
4 |
5 | /**
6 | * Renders "About" view
7 | * url: /about
8 | * @page About
9 | */
10 | const AboutView = () => {
11 | return (
12 |
13 |
14 |
15 |
16 | Detailed description of the application here...
17 |
18 |
19 | OK
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | MUI default MUI inherit{' '}
34 | MUI primary MUI secondary{' '}
35 | MUI textPrimary{' '}
36 | MUI textSecondary MUI error
37 | Internal Link
38 |
39 | Internal Link in New Tab
40 | {' '}
41 |
42 | External Link
43 |
44 | External Link in Same Tab
45 | {' '}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Default
60 | Disabled
61 | Primary
62 | Secondary
63 | Error
64 | Warning
65 | Info
66 | Success
67 | #FF8C00
68 | rgb(50, 205, 50)
69 |
70 | Inherit
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
92 |
97 |
98 | {/* */}
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | MUI Typo h1
107 | MUI Typography h2
108 | MUI Typography h3
109 | MUI Typography h4
110 | MUI Typography h5
111 | MUI Typography h6
112 |
113 | MUI Typography subtitle1
114 | MUI Typography subtitle2
115 | MUI Typography caption
116 |
117 |
118 | MUI Typography body1 - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
119 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
120 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit
121 | esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa
122 | qui officia deserunt mollit anim id est laborum.
123 |
124 |
125 |
126 | MUI Typography body2 - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
127 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
128 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit
129 | esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa
130 | qui officia deserunt mollit anim id est laborum.
131 |
132 |
133 | MUI Typography overline
134 |
135 | MUI Typography button
136 |
137 |
138 |
139 |
140 | );
141 | };
142 |
143 | export default AboutView;
144 |
--------------------------------------------------------------------------------
/src/views/About/DialogsSection.tsx:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, useState, ReactNode, useCallback } from 'react';
2 | import { Card, CardHeader, Grid, TextField } from '@mui/material';
3 | import { AppButton, AppIconButton } from '../../components';
4 | import {
5 | CommonDialog as MessageDialog,
6 | CommonDialog as ConfirmationDialog,
7 | CompositionDialog as EmailEditDialog,
8 | } from '../../components/dialogs';
9 |
10 | /**
11 | * Renders demo section for Dialogs
12 | */
13 | const DialogsSection = () => {
14 | const [modal, setModal] = useState(null);
15 | const [openEmailDialog, setOpenEmailDialog] = useState(false);
16 | const [email, setEmail] = useState('i@karpolan.com');
17 |
18 | const onDialogClose = useCallback(() => {
19 | setModal(null);
20 | }, []);
21 |
22 | const onMessageDialogConfirm = useCallback((data: unknown) => {
23 | console.info('onMessageDialogConfirm() - data:', data);
24 | setModal(null);
25 | }, []);
26 |
27 | const onMessageDialogOpen = () => {
28 | setModal(
29 |
42 | );
43 | };
44 |
45 | const onConfirmDialogConfirm = useCallback((data: unknown) => {
46 | console.info('onConfirmDialogConfirm() - data:', data);
47 | setModal(null);
48 | }, []);
49 |
50 | const onConfirmDialogOpen = () => {
51 | const dialogData = {
52 | id: 123,
53 | name: 'Sample data for Confirm Dialog',
54 | };
55 | setModal(
56 |
62 | JSX content can be easily added into the dialog via props.body
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
76 |
81 |
82 | {/* */}
83 |
84 |
85 |
86 | The props.body takes precedence over props.text. So JSX content is rendered, but the text is ignored
87 |
88 | >
89 | }
90 | text="!!! This text will not be rendered !!!"
91 | confirmButtonText="Confirm and do something"
92 | onClose={onDialogClose}
93 | onConfirm={onConfirmDialogConfirm}
94 | />
95 | );
96 | };
97 |
98 | const onEditEmailDialogClose = useCallback((data: unknown) => {
99 | setOpenEmailDialog(false);
100 | }, []);
101 |
102 | const onEmailChange = (event: ChangeEvent) => {
103 | setEmail(event.target.value);
104 | };
105 |
106 | const onEditEmailDialogOpen = () => {
107 | setOpenEmailDialog(true);
108 | };
109 |
110 | return (
111 | <>
112 | {modal}
113 | {openEmailDialog && (
114 |
120 |
121 | This is CompositionDialog with JSX in props.content and props.actions
122 |
123 | }
124 | actions={
125 | <>
126 |