├── .prettierignore
├── src
├── app
│ ├── page.tsx
│ ├── favicon.ico
│ ├── auth
│ │ ├── signup
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── login
│ │ │ ├── page.tsx
│ │ │ └── LoginForm.tsx
│ ├── globals.css
│ ├── me
│ │ └── page.tsx
│ ├── dev
│ │ ├── components
│ │ │ ├── DemoAppAlerts.tsx
│ │ │ ├── DemoAppImage.tsx
│ │ │ ├── DemoAppIcon.tsx
│ │ │ ├── DemoAppButton.tsx
│ │ │ └── DemoAppIconButton.tsx
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── about
│ │ └── page.tsx
│ └── home
│ │ └── page.tsx
├── hooks
│ ├── index.ts
│ ├── event.ts
│ ├── auth.ts
│ ├── useWindowSize.ts
│ └── layout.ts
├── components
│ ├── common
│ │ ├── AppIcon
│ │ │ ├── index.tsx
│ │ │ ├── utils.ts
│ │ │ ├── icons
│ │ │ │ ├── PencilIcon.tsx
│ │ │ │ ├── CurrencyIcon.tsx
│ │ │ │ └── YellowPlanIcon.tsx
│ │ │ ├── AppIcon.tsx
│ │ │ ├── config.ts
│ │ │ └── AppIcon.test.tsx
│ │ ├── AppAlert
│ │ │ ├── index.tsx
│ │ │ ├── AppAlert.tsx
│ │ │ └── AppAlert.test.tsx
│ │ ├── AppImage
│ │ │ ├── index.tsx
│ │ │ ├── AppImage.tsx
│ │ │ └── AppImage.test.tsx
│ │ ├── AppButton
│ │ │ ├── index.tsx
│ │ │ ├── AppButton.tsx
│ │ │ └── AppButton.test.tsx
│ │ ├── AppLoading
│ │ │ ├── index.tsx
│ │ │ └── AppLoading.tsx
│ │ ├── AppIconButton
│ │ │ ├── index.tsx
│ │ │ ├── AppIconButton.tsx
│ │ │ └── AppIconButton.test.tsx
│ │ ├── AppLink
│ │ │ ├── index.tsx
│ │ │ ├── AppLinkNextNavigation.tsx
│ │ │ └── AppLink.test.tsx
│ │ ├── index.tsx
│ │ └── ErrorBoundary.tsx
│ ├── index.tsx
│ ├── UserInfo
│ │ ├── index.tsx
│ │ └── UserInfo.tsx
│ └── config.ts
├── store
│ ├── index.tsx
│ ├── config.ts
│ ├── AppReducer.ts
│ └── AppStore.tsx
├── layout
│ ├── components
│ │ ├── index.tsx
│ │ ├── TopBar.tsx
│ │ ├── SideBarNavList.tsx
│ │ ├── BottomBar.tsx
│ │ ├── SideBarNavItem.tsx
│ │ └── SideBar.tsx
│ ├── index.tsx
│ ├── config.ts
│ ├── CurrentLayout.tsx
│ ├── PrivateLayout.tsx
│ ├── PublicLayout.tsx
│ └── TopBarAndSideBarLayout.tsx
├── utils
│ ├── index.ts
│ ├── sleep.ts
│ ├── type.ts
│ ├── localStorage.ts
│ ├── sessionStorage.ts
│ ├── navigation.ts
│ ├── text.ts
│ └── environment.ts
├── theme
│ ├── index.ts
│ ├── dark.ts
│ ├── light.ts
│ ├── MuiThemeProviderForNextJs.tsx
│ ├── colors.ts
│ └── ThemeProvider.tsx
└── config.ts
├── public
├── robots.txt
├── img
│ ├── favicon
│ │ ├── 16x16.png
│ │ ├── 32x32.png
│ │ ├── 180x180.png
│ │ ├── 192x192.png
│ │ └── 512x512.png
│ └── logo.svg
└── site.webmanifest
├── .eslintrc.json
├── next-env.d.ts
├── jest.setup.ts
├── .gitignore
├── next.config.mjs
├── tsconfig.json
├── .prettierrc.js
├── jest.config.ts
├── .env.sample
├── package.json
└── README.md
/.prettierignore:
--------------------------------------------------------------------------------
1 | .next
2 | node_modules
3 | out
4 | styles
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import HomePage from './home/page';
2 |
3 | export default HomePage;
4 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /private/
3 |
4 | User-agent: *
5 | Allow: /
6 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth';
2 | export * from './event';
3 | export * from './layout';
4 |
--------------------------------------------------------------------------------
/public/img/favicon/16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/16x16.png
--------------------------------------------------------------------------------
/public/img/favicon/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/32x32.png
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "import/no-cycle": "error"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/public/img/favicon/180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/180x180.png
--------------------------------------------------------------------------------
/public/img/favicon/192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/192x192.png
--------------------------------------------------------------------------------
/public/img/favicon/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karpolan/nextjs-mui-starter-ts/HEAD/public/img/favicon/512x512.png
--------------------------------------------------------------------------------
/src/components/common/AppIcon/index.tsx:
--------------------------------------------------------------------------------
1 | import AppIcon from './AppIcon';
2 |
3 | export { AppIcon as default, AppIcon };
4 |
--------------------------------------------------------------------------------
/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './common';
2 |
3 | import UserInfo from './UserInfo';
4 |
5 | export { UserInfo };
6 |
--------------------------------------------------------------------------------
/src/components/UserInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import UserInfo from './UserInfo';
2 |
3 | export { UserInfo };
4 | export default UserInfo;
5 |
--------------------------------------------------------------------------------
/src/components/common/AppAlert/index.tsx:
--------------------------------------------------------------------------------
1 | import AppAlert from './AppAlert';
2 |
3 | export { AppAlert as default, AppAlert };
4 |
--------------------------------------------------------------------------------
/src/components/common/AppImage/index.tsx:
--------------------------------------------------------------------------------
1 | import AppImage from './AppImage';
2 |
3 | export { AppImage as default, AppImage };
4 |
--------------------------------------------------------------------------------
/src/components/common/AppButton/index.tsx:
--------------------------------------------------------------------------------
1 | import AppButton from './AppButton';
2 |
3 | export { AppButton as default, AppButton };
4 |
--------------------------------------------------------------------------------
/src/components/common/AppLoading/index.tsx:
--------------------------------------------------------------------------------
1 | import AppLoading from './AppLoading';
2 |
3 | export { AppLoading };
4 | export default AppLoading;
5 |
--------------------------------------------------------------------------------
/src/components/common/AppIconButton/index.tsx:
--------------------------------------------------------------------------------
1 | import AppIconButton from './AppIconButton';
2 |
3 | export { AppIconButton as default, AppIconButton };
4 |
--------------------------------------------------------------------------------
/src/store/index.tsx:
--------------------------------------------------------------------------------
1 | import { AppStoreProvider, useAppStore, withAppStore } from './AppStore';
2 |
3 | export { AppStoreProvider, useAppStore, withAppStore };
4 |
--------------------------------------------------------------------------------
/src/layout/components/index.tsx:
--------------------------------------------------------------------------------
1 | import BottomBar from './BottomBar';
2 | import SideBar from './SideBar';
3 | import TopBar from './TopBar';
4 |
5 | export { BottomBar, SideBar, TopBar };
6 |
--------------------------------------------------------------------------------
/src/components/common/AppLink/index.tsx:
--------------------------------------------------------------------------------
1 | import AppLink, { AppLinkForNextProps as AppLinkProps } from './AppLinkNextNavigation';
2 |
3 | export type { AppLinkProps };
4 | export { AppLink as default, AppLink };
5 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/src/layout/index.tsx:
--------------------------------------------------------------------------------
1 | import CurrentLayout from './CurrentLayout';
2 | import PrivateLayout from './PrivateLayout';
3 | import PublicLayout from './PublicLayout';
4 |
5 | export { PublicLayout, PrivateLayout };
6 | export default CurrentLayout;
7 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './environment';
2 | export * from './localStorage';
3 | export * from './navigation';
4 | export * from './sessionStorage';
5 | export * from './sleep';
6 | export * from './type';
7 | export * from './text';
8 |
--------------------------------------------------------------------------------
/src/app/auth/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import LoginPage from '../login/page';
3 |
4 | export const metadata: Metadata = {
5 | title: 'Signup - _TITLE_',
6 | description: '_DESCRIPTION_',
7 | };
8 |
9 | export default LoginPage; // Just reuse a Login page as a demo...
10 |
--------------------------------------------------------------------------------
/src/app/auth/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | /**
4 | * Redirects to default Auth page
5 | * @page Auth
6 | * @redirect /auth
7 | */
8 | const AuthPage = () => {
9 | redirect('/auth/login');
10 | // return
Auth Page
;
11 | };
12 |
13 | export default AuthPage;
14 |
--------------------------------------------------------------------------------
/src/components/common/AppIcon/utils.ts:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from 'react';
2 |
3 | /**
4 | * Props to use with custom SVG icons, similar to AppIcon's Props
5 | */
6 | export interface IconProps extends SVGAttributes {
7 | color?: string;
8 | icon?: string;
9 | size?: string | number;
10 | title?: string;
11 | }
12 |
--------------------------------------------------------------------------------
/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import AppThemeProvider from './ThemeProvider';
2 | import DARK_THEME from './dark';
3 | import LIGHT_THEME from './light';
4 |
5 | export {
6 | LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default
7 | DARK_THEME,
8 | LIGHT_THEME,
9 | AppThemeProvider as ThemeProvider,
10 | };
11 |
--------------------------------------------------------------------------------
/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/app/globals.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | padding: 0;
4 | margin: 0;
5 | }
6 |
7 | html,
8 | body {
9 | max-width: 100vw;
10 | overflow-x: hidden;
11 | /* required for sticky elements: HeaderMobile, and so on */
12 | max-height: 100vh;
13 | }
14 |
15 | a {
16 | color: inherit;
17 | text-decoration: none;
18 | }
19 |
--------------------------------------------------------------------------------
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | // Optional: configure or set up a testing framework before each test.
2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.*`
3 |
4 | // Used for __tests__/testing-library.js
5 | // Learn more: https://github.com/testing-library/jest-dom
6 | import '@testing-library/jest-dom';
7 |
8 | // To get 'next/router' working with tests
9 | jest.mock('next/router', () => require('next-router-mock'));
10 |
--------------------------------------------------------------------------------
/src/app/auth/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata, NextPage } from 'next';
2 | import LoginForm from './LoginForm';
3 |
4 | /**
5 | * User Login page
6 | * @page Login
7 | */
8 | const LoginPage: NextPage = () => {
9 | return (
10 | <>
11 |
12 | >
13 | );
14 | };
15 |
16 | export const metadata: Metadata = {
17 | title: 'Login - _TITLE_',
18 | description: '_DESCRIPTION_',
19 | };
20 |
21 | export default LoginPage;
22 |
--------------------------------------------------------------------------------
/src/components/common/index.tsx:
--------------------------------------------------------------------------------
1 | import AppAlert from './AppAlert';
2 | import AppButton from './AppButton';
3 | import AppIcon from './AppIcon';
4 | import AppIconButton from './AppIconButton';
5 | import AppImage from './AppImage';
6 | import AppLink from './AppLink';
7 | import AppLoading from './AppLoading';
8 | import ErrorBoundary from './ErrorBoundary';
9 |
10 | export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppImage, AppLink, AppLoading };
11 |
--------------------------------------------------------------------------------
/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/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/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/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/app/me/page.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from '@mui/material';
2 | import { NextPage } from 'next';
3 | import { AppAlert, UserInfo } from '../../components';
4 |
5 | /**
6 | * Renders User Profile Page
7 | * @page Me
8 | */
9 | const MeAkaProfilePage: NextPage = () => {
10 | return (
11 |
12 | This page is under construction
13 |
14 |
15 | );
16 | };
17 |
18 | export default MeAkaProfilePage;
19 |
--------------------------------------------------------------------------------
/src/theme/MuiThemeProviderForNextJs.tsx:
--------------------------------------------------------------------------------
1 | import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter';
2 | import { FunctionComponent, PropsWithChildren } from 'react';
3 |
4 | /**
5 | * Platform-specific ThemeProvider for Next.js
6 | * @component MuiThemeProviderForNextJs
7 | */
8 | const MuiThemeProviderForNextJs: FunctionComponent = ({ children }) => {
9 | return {children};
10 | };
11 |
12 | export default MuiThemeProviderForNextJs;
13 |
--------------------------------------------------------------------------------
/src/store/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Data structure of the AppStore state
3 | */
4 | export interface AppStoreState {
5 | darkMode: boolean;
6 | isAuthenticated: boolean;
7 | currentUser?: object | undefined;
8 | }
9 |
10 | /**
11 | * Initial values for the AppStore state
12 | */
13 | export const APP_STORE_INITIAL_STATE: AppStoreState = {
14 | darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore
15 | isAuthenticated: false, // Overridden in AppStore by checking auth token
16 | };
17 |
--------------------------------------------------------------------------------
/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/CurrentLayout.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React, { FunctionComponent, PropsWithChildren } from 'react';
3 | import { useIsAuthenticated } from '@/hooks';
4 | import PrivateLayout from './PrivateLayout';
5 | import PublicLayout from './PublicLayout';
6 |
7 | /**
8 | * Returns the current Layout component depending on different circumstances.
9 | * @layout CurrentLayout
10 | */
11 | const CurrentLayout: FunctionComponent = (props) => {
12 | return useIsAuthenticated() ? : ;
13 | };
14 |
15 | export default CurrentLayout;
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env
30 | .env.local
31 | .env.development.local
32 | .env.test.local
33 | .env.production.local
34 |
35 | # vercel
36 | .vercel
37 |
38 | # typescript
39 | *.tsbuildinfo
40 | next-env.d.ts
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 |
3 | const nextConfig = {
4 | output: 'export', // Use this if you want to create "static generated website" (SSG), result in "/out" folder
5 | trailingSlash: true,
6 | images: { unoptimized: true },
7 |
8 | env: {
9 | // TODO: You can add custom env variables here, also check .env.xxx file
10 | AUTHOR: 'KARPOLAN',
11 | // npm_package_name: process.env.npm_package_name,
12 | // npm_package_version: process.env.npm_package_version,
13 | },
14 |
15 | reactStrictMode: true,
16 | // reactStrictMode: false,
17 | };
18 |
19 | export default nextConfig;
20 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import { envRequired, getCurrentEnvironment } from '@/utils/environment';
2 |
3 | export const IS_DEBUG = process.env.NEXT_PUBLIC_DEBUG === 'true'; // Enables logging, etc.
4 |
5 | export const IS_PRODUCTION = getCurrentEnvironment() === 'production'; // Enables analytics, etc.
6 |
7 | // export const PUBLIC_URL = envRequired(process.env.NEXT_PUBLIC_PUBLIC_URL); // Variant 1: .env variable is required
8 | export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env variable is optional
9 |
10 | IS_DEBUG &&
11 | console.log('@/config', {
12 | IS_DEBUG,
13 | IS_PRODUCTION,
14 | PUBLIC_URL,
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/common/AppAlert/AppAlert.tsx:
--------------------------------------------------------------------------------
1 | import MuiAlert, { AlertProps as MuiAlertProps } from '@mui/material/Alert';
2 | import { FunctionComponent } from 'react';
3 | import { APP_ALERT_SEVERITY, APP_ALERT_VARIANT } from '../../config';
4 |
5 | /**
6 | * Application styled Alert component
7 | * @component AppAlert
8 | */
9 | const AppAlert: FunctionComponent = ({
10 | severity = APP_ALERT_SEVERITY,
11 | variant = APP_ALERT_VARIANT,
12 | onClose,
13 | ...restOfProps
14 | }) => {
15 | return ;
16 | };
17 |
18 | export default AppAlert;
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | },
23 | "forceConsistentCasingInFileNames": true
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 120, // max 120 chars in line, code is easy to read
3 | useTabs: false, // use spaces instead of tabs
4 | tabWidth: 2, // "visual width" of of the "tab"
5 | trailingComma: 'es5', // add trailing commas in objects, arrays, etc.
6 | semi: true, // add ; when needed
7 | singleQuote: true, // '' for stings instead of ""
8 | bracketSpacing: true, // import { some } ... instead of import {some} ...
9 | arrowParens: 'always', // braces even for single param in arrow functions (a) => { }
10 | jsxSingleQuote: false, // "" for react props, like in html
11 | jsxBracketSameLine: false, // pretty JSX
12 | endOfLine: 'lf', // 'lf' for linux, 'crlf' for windows, we need to use 'lf' for git
13 | };
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "_TITLE_",
3 | "short_name": "_TITLE_",
4 | "description": "_DESCRIPTION_",
5 | "start_url": ".",
6 | "theme_color": "#000000",
7 | "background_color": "#ffffff",
8 | "display": "standalone",
9 | "orientation": "portrait",
10 | "icons": [
11 | {
12 | "src": "favicon.ico?v=1.0",
13 | "sizes": "48x48 32x32 16x16",
14 | "type": "image/x-icon"
15 | },
16 | { "src": "img/favicon/16x16.png?v=1.0", "sizes": "16x16", "type": "image/png" },
17 | { "src": "img/favicon/32x32.png?v=1.0", "sizes": "32x32", "type": "image/png" },
18 | { "src": "img/favicon/180x180.png?v=1.0", "sizes": "180x180", "type": "image/png" },
19 | { "src": "img/favicon/192x192.png?v=1.0", "sizes": "192x192", "type": "image/png" },
20 | { "src": "img/favicon/512x512.png?v=1.0", "sizes": "512x512", "type": "image/png" }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/dev/components/DemoAppAlerts.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader } from '@mui/material';
2 | import { AppAlert } from '@/components';
3 |
4 | /**
5 | * Renders "Demo Section" for AppButton component
6 | * @component DemoAppButton
7 | */
8 | const DemoAppButton = () => {
9 | return (
10 |
11 |
12 |
13 | AppAlert - Info
14 |
15 | AppAlert - Info, variant "outlined"
16 |
17 | AppAlert - Error (default)
18 |
19 | AppAlert - Info, variant "standard"
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default DemoAppButton;
27 |
--------------------------------------------------------------------------------
/src/components/common/AppImage/AppImage.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from 'react';
2 | import NextImage, { ImageProps } from 'next/image';
3 |
4 | interface AppImageProps extends Omit {
5 | alt?: string; // Make property optional as it was before NextJs v13
6 | }
7 |
8 | /**
9 | * Application wrapper around NextJS image with some default props
10 | * @component AppImage
11 | */
12 | const AppImage: FunctionComponent = ({
13 | title, // Note: value has be destructed before usage as default value for other property
14 | alt = title ?? 'Image',
15 | height = 256,
16 | width = 256,
17 | ...restOfProps
18 | }) => {
19 | // Uses custom loader + unoptimized="true" to avoid NextImage warning https://nextjs.org/docs/api-reference/next/image#unoptimized
20 | return ;
21 | };
22 |
23 | export default AppImage;
24 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'jest';
2 | import nextJest from 'next/jest.js';
3 |
4 | // const nextJest = require('next/jest');
5 |
6 | const createJestConfig = nextJest({
7 | // Provide the path to your Next.js app to load next.config.* and .env files in your test environment
8 | dir: './',
9 | });
10 |
11 | // Add any custom config to be passed to Jest
12 | const customJestConfig: Config = {
13 | coverageProvider: 'v8',
14 | setupFilesAfterEnv: ['/jest.setup.ts'],
15 | moduleNameMapper: {
16 | // Handle module aliases
17 | '^@/(.*)$': '/$1',
18 | },
19 | // testEnvironment: 'jest-environment-jsdom',
20 | testEnvironment: 'jsdom',
21 | // transform: {
22 | // '.+\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform',
23 | // },
24 | };
25 |
26 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
27 | module.exports = createJestConfig(customJestConfig);
28 |
--------------------------------------------------------------------------------
/public/img/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/hooks/auth.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { sessionStorageGet, sessionStorageDelete } from '@/utils/sessionStorage';
3 | import { useAppStore } from '../store';
4 |
5 | /**
6 | * Hook to detect is current user authenticated or not
7 | * @returns {boolean} true if user is authenticated, false otherwise
8 | */
9 | export function useIsAuthenticated() {
10 | const [state] = useAppStore();
11 | let result = state.isAuthenticated;
12 |
13 | // TODO: AUTH: replace next line with access token verification
14 | result = Boolean(sessionStorageGet('access_token', ''));
15 |
16 | return result;
17 | }
18 |
19 | /**
20 | * Returns event handler to Logout current user
21 | * @returns {function} calling this event logs out current user
22 | */
23 | export function useEventLogout() {
24 | const [, dispatch] = useAppStore();
25 |
26 | return useCallback(() => {
27 | // TODO: AUTH: replace next line with access token saving
28 | sessionStorageDelete('access_token');
29 |
30 | dispatch({ type: 'LOG_OUT' });
31 | }, [dispatch]);
32 | }
33 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/layout/components/TopBar.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, ReactNode } from 'react';
2 | import { AppBar, Toolbar, Typography } from '@mui/material';
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/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, PropsWithChildren } from 'react';
2 | import { Metadata, Viewport } from 'next';
3 | import { SimplePaletteColorOptions } from '@mui/material';
4 | import { AppStoreProvider } from '@/store';
5 | import defaultTheme, { ThemeProvider } from '@/theme';
6 | import CurrentLayout from '@/layout';
7 | import './globals.css';
8 |
9 | const THEME_COLOR = (defaultTheme.palette?.primary as SimplePaletteColorOptions)?.main || '#FFFFFF';
10 |
11 | export const viewport: Viewport = {
12 | themeColor: THEME_COLOR,
13 | };
14 |
15 | export const metadata: Metadata = {
16 | title: '_TITLE_',
17 | description: '_DESCRIPTION_',
18 | manifest: '/site.webmanifest',
19 | // TODO: Add Open Graph metadata
20 | };
21 |
22 | const RootLayout: FunctionComponent = ({ children }) => {
23 | return (
24 |
25 |
26 |
27 |
28 | {children}
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default RootLayout;
37 |
--------------------------------------------------------------------------------
/src/app/dev/page.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage } from 'next';
2 | import { redirect } from 'next/navigation';
3 | import { Stack, Typography } from '@mui/material';
4 | import { IS_DEBUG } from '@/config';
5 | import DemoAppAlert from './components/DemoAppAlerts';
6 | import DemoAppButton from './components/DemoAppButton';
7 | import DemoAppIcon from './components/DemoAppIcon';
8 | import DemoAppIconButton from './components/DemoAppIconButton';
9 | import DemoAppImage from './components/DemoAppImage';
10 |
11 | /**
12 | * Renders Development tools when env.NEXT_PUBLIC_DEBUG is true
13 | * @page Dev
14 | */
15 | const DevPage: NextPage = () => {
16 | if (!IS_DEBUG) {
17 | redirect('/');
18 | }
19 |
20 | return (
21 |
22 |
23 | DevTools page
24 | This page is not visible on production.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default DevPage;
39 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | # Environment similar to NODE_ENV.
2 | # Analytics and public resources are enabled only in "production"
3 | # How to use: set value to "production" to get fully functional application.
4 | NEXT_PUBLIC_ENV = development
5 | # NEXT_PUBLIC_ENV = preview
6 | # NEXT_PUBLIC_ENV = production
7 |
8 | # Enables additional debug features, no additional debug information if the variable is not set
9 | # How to use: set value to "true" to get more debugging information, but don't do it on production.
10 | NEXT_PUBLIC_DEBUG = true
11 |
12 | # Public URL of the application/website.
13 | # How to use: Do not set any value until you need custom domain for your application.
14 | # NEXT_PUBLIC_PUBLIC_URL = https://xxx.com
15 | # NEXT_PUBLIC_PUBLIC_URL = https://xxx.web.app
16 | NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
17 |
18 |
19 | # API/Backend basic URL
20 | NEXT_PUBLIC_API_URL = http://localhost:5000
21 | # NEXT_PUBLIC_API_URL = https://dev-api.domain.com
22 | # NEXT_PUBLIC_API_URL = https://api.domain.com
23 |
24 | # EmailJS configuration for contact form
25 | NEXT_PUBLIC_EMAILJS_KEY = user-xxxx
26 |
27 | # Analytics and Advertising
28 | NEXT_PUBLIC_GA_ID = G-...
29 | NEXT_PUBLIC_AMPLITUDE_API_KEY = 69abf7...
30 | NEXT_PUBLIC_ADSENSE_CLIENT_ID = pub-12345...
--------------------------------------------------------------------------------
/src/layout/components/SideBarNavList.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, MouseEventHandler } from 'react';
2 | import List from '@mui/material/List';
3 | import { LinkToPage } from '@/utils';
4 | import SideBarNavItem from './SideBarNavItem';
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/app/dev/components/DemoAppImage.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, Stack } from '@mui/material';
2 | import { AppImage } from '@/components';
3 |
4 | /**
5 | * Renders "Demo Section" for AppImage component
6 | * @component DemoAppImage
7 | */
8 | const DemoAppImage = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | {/* */}
16 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default DemoAppImage;
32 |
--------------------------------------------------------------------------------
/src/components/common/AppLoading/AppLoading.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from 'react';
2 | import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material';
3 | import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config';
4 |
5 | interface Props extends StackProps {
6 | color?: CircularProgressProps['color'];
7 | size?: number | string;
8 | type?: 'circular' | 'linear';
9 | value?: number;
10 | }
11 |
12 | /**
13 | * Renders MI circular progress centered inside Stack
14 | * @component AppLoading
15 | * @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem'
16 | */
17 | const AppLoading: FunctionComponent = ({
18 | color = APP_LOADING_COLOR,
19 | size = APP_LOADING_SIZE,
20 | type = APP_LOADING_TYPE,
21 | value,
22 | ...restOfProps
23 | }) => {
24 | const alignItems = type === 'linear' ? undefined : 'center';
25 | return (
26 |
27 | {type === 'linear' ? (
28 |
29 | ) : (
30 |
31 | )}
32 |
33 | );
34 | };
35 |
36 | export default AppLoading;
37 |
--------------------------------------------------------------------------------
/src/layout/components/BottomBar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { FunctionComponent, useCallback } from 'react';
3 | import { useRouter } from 'next/navigation';
4 | import { BottomNavigation, BottomNavigationAction } from '@mui/material';
5 | import { LinkToPage } from '@/utils';
6 | import { AppIcon } from '@/components';
7 |
8 | interface Props {
9 | items: Array;
10 | }
11 |
12 | /**
13 | * Renders horizontal Navigation Bar using MUI BottomNavigation component
14 | * @component BottomBar
15 | */
16 | const BottomBar: FunctionComponent = ({ items }) => {
17 | const router = useRouter();
18 |
19 | const onNavigationChange = useCallback(
20 | (_event: unknown, newValue: string) => {
21 | router.push(newValue);
22 | },
23 | [router]
24 | );
25 |
26 | return (
27 |
32 | {items.map(({ title, path, icon }) => (
33 | } />
34 | ))}
35 |
36 | );
37 | };
38 |
39 | export default BottomBar;
40 |
--------------------------------------------------------------------------------
/src/hooks/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useState } from 'react';
2 | import { IS_SERVER } from '@/utils';
3 |
4 | const MOBILE_WINDOWS_SIZE = { width: 720, height: 1280 };
5 | const DESKTOP_WINDOWS_SIZE = { width: 1920, height: 1080 };
6 | const DEFAULT_WINDOWS_SIZE = MOBILE_WINDOWS_SIZE ?? DESKTOP_WINDOWS_SIZE; // Mobile-First by default
7 |
8 | type WindowSize = {
9 | width: number;
10 | height: number;
11 | };
12 |
13 | /**
14 | * Hook to monitor Window (actually Browser) Size using "resize" event listener
15 | * @returns {WindowSize} current window size as {width, height} object
16 | */
17 | const useWindowSize = (): WindowSize => {
18 | const [windowSize, setWindowSize] = useState(DEFAULT_WINDOWS_SIZE);
19 |
20 | useLayoutEffect(() => {
21 | function handleResize() {
22 | setWindowSize({
23 | width: window.innerWidth,
24 | height: window.innerHeight,
25 | });
26 | }
27 |
28 | window.addEventListener('resize', handleResize);
29 | handleResize(); // Get initial/current window size
30 |
31 | return () => window.removeEventListener('resize', handleResize);
32 | }, []);
33 |
34 | return windowSize;
35 | };
36 |
37 | /**
38 | * The hook will really work in Browser only, so or Server Side Rendering (SSR) we just return DEFAULT_WINDOWS_SIZE
39 | */
40 | export default IS_SERVER ? () => DEFAULT_WINDOWS_SIZE : useWindowSize;
41 |
--------------------------------------------------------------------------------
/src/layout/components/SideBarNavItem.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { FunctionComponent, MouseEventHandler } from 'react';
3 | import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
4 | import { AppIcon, AppLink } from '@/components';
5 | import { LinkToPage } from '@/utils';
6 | import { usePathname } from 'next/navigation';
7 |
8 | interface Props extends LinkToPage {
9 | openInNewTab?: boolean;
10 | selected?: boolean;
11 | onClick?: MouseEventHandler;
12 | }
13 |
14 | /**
15 | * Renders Navigation Item for SideBar, detects current url and sets selected state if needed
16 | * @component SideBarNavItem
17 | */
18 | const SideBarNavItem: FunctionComponent = ({
19 | openInNewTab,
20 | icon,
21 | path,
22 | selected: propSelected = false,
23 | subtitle,
24 | title,
25 | onClick,
26 | }) => {
27 | const pathname = usePathname();
28 | const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;
29 |
30 | return (
31 |
39 | {icon && }
40 |
41 |
42 | );
43 | };
44 |
45 | export default SideBarNavItem;
46 |
--------------------------------------------------------------------------------
/src/components/common/AppIcon/icons/PencilIcon.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from 'react';
2 | import { IconProps } from '../utils';
3 |
4 | const PencilIcon: FunctionComponent = (props) => {
5 | return (
6 |
26 | );
27 | };
28 |
29 | export default PencilIcon;
30 |
--------------------------------------------------------------------------------
/src/store/AppReducer.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from 'react';
2 | import { localStorageSet } from '../utils/localStorage';
3 | import { AppStoreState } from './config';
4 |
5 | /**
6 | * Reducer for global AppStore using "Redux styled" actions
7 | * @function AppReducer
8 | * @param {object} state - current/default state
9 | * @param {string} action.type - unique name of the action
10 | * @param {string} action.action - alternate to action.type property, unique name of the action
11 | * @param {*} [action.payload] - optional data object or the function to get data object
12 | */
13 | const AppReducer: Reducer = (state, action) => {
14 | // console.log('AppReducer() - action:', action);
15 | switch (action.type || action.action) {
16 | case 'CURRENT_USER':
17 | return {
18 | ...state,
19 | currentUser: action?.currentUser || action?.payload,
20 | };
21 | case 'SIGN_UP':
22 | case 'LOG_IN':
23 | return {
24 | ...state,
25 | isAuthenticated: true,
26 | };
27 | case 'LOG_OUT':
28 | return {
29 | ...state,
30 | isAuthenticated: false,
31 | currentUser: undefined, // Also reset previous user data
32 | };
33 | case 'DARK_MODE': {
34 | const darkMode = action?.darkMode ?? action?.payload;
35 | localStorageSet('darkMode', darkMode);
36 | return {
37 | ...state,
38 | darkMode,
39 | };
40 | }
41 | default:
42 | return state;
43 | }
44 | };
45 |
46 | export default AppReducer;
47 |
--------------------------------------------------------------------------------
/src/components/common/AppIcon/icons/CurrencyIcon.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from 'react';
2 | import { IconProps } from '../utils';
3 |
4 | const CurrencyIcon: FunctionComponent = (props) => {
5 | return (
6 |
26 | );
27 | };
28 |
29 | export default CurrencyIcon;
30 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfo.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, Stack, Typography } from '@mui/material';
2 | import { AppLink } from '../common';
3 |
4 | interface UserInfoProps {
5 | className?: string;
6 | showAvatar?: boolean;
7 | user?: any;
8 | }
9 |
10 | /**
11 | * Renders User info with Avatar
12 | * @component UserInfo
13 | * @param {boolean} [showAvatar] - user's avatar picture is shown when true
14 | * @param {object} [user] - logged user data {name, email, avatar...}
15 | */
16 | const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
17 | const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
18 | const srcAvatar = user?.avatar ? user?.avatar : undefined;
19 | const userPhoneOrEmail = user?.phone || (user?.email as string);
20 |
21 | return (
22 |
23 | {showAvatar ? (
24 |
25 |
34 |
35 | ) : null}
36 |
37 | {fullName || 'Current User'}
38 |
39 | {userPhoneOrEmail || 'Loading...'}
40 |
41 | );
42 | };
43 |
44 | export default UserInfo;
45 |
--------------------------------------------------------------------------------
/src/app/about/page.tsx:
--------------------------------------------------------------------------------
1 | import { Stack, Typography } from '@mui/material';
2 | import { NextPage } from 'next';
3 | import { AppButton, AppLink } from '@/components';
4 |
5 | /**
6 | * Renders About Application page
7 | * @page About
8 | */
9 | const AboutPage: NextPage = () => {
10 | return (
11 |
12 |
13 | About application
14 |
15 | This application is a mix of{' '}
16 | Create Next App and{' '}
17 | MUI with set of reusable components and utilities to build
18 | professional NextJS application faster. The source code is
19 | available on GitHub.
20 |
21 |
22 |
23 | Open GitHub
24 |
25 |
26 |
27 |
28 | Reusable components
29 |
30 | Demo of reusable components is available on DevTools page
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default AboutPage;
38 |
--------------------------------------------------------------------------------
/src/app/auth/login/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Stack } from '@mui/material';
3 | import { useRouter } from 'next/navigation';
4 | import { AppButton, AppLink } from '@/components';
5 | import { useAppStore } from '@/store';
6 | import { useEventLogout } from '@/hooks';
7 | import { sessionStorageSet } from '@/utils';
8 |
9 | /**
10 | * Renders login form for user to authenticate
11 | * @component LoginForm
12 | */
13 | const LoginForm = () => {
14 | const router = useRouter();
15 | const [, dispatch] = useAppStore();
16 | const onLogout = useEventLogout();
17 |
18 | const onLogin = () => {
19 | // TODO: AUTH: Sample of access token store, replace next line in real application
20 | sessionStorageSet('access_token', 'TODO:_save-real-access-token-here');
21 |
22 | dispatch({ type: 'LOG_IN' });
23 | router.replace('/'); // Redirect to home page without ability to go back
24 | };
25 |
26 | return (
27 |
28 | Put form controls or add social login buttons here...
29 |
30 |
31 |
32 | Emulate User Login
33 |
34 |
35 | Logout User
36 |
37 |
38 |
39 |
40 | The source code is available at{' '}
41 |
GitHub
42 |
43 |
44 | );
45 | };
46 |
47 | export default LoginForm;
48 |
--------------------------------------------------------------------------------
/src/layout/PrivateLayout.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, PropsWithChildren } from 'react';
2 | import { IS_DEBUG } from '@/config';
3 | import { LinkToPage } from '@/utils';
4 | import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
5 |
6 | const TITLE_PRIVATE = '_TITLE_'; // Title for pages after authentication
7 |
8 | /**
9 | * SideBar navigation items with links for Private Layout
10 | */
11 | const SIDE_BAR_ITEMS: Array = [
12 | {
13 | title: 'Home',
14 | path: '/',
15 | icon: 'home',
16 | },
17 | {
18 | title: 'My Profile',
19 | path: '/me',
20 | icon: 'account',
21 | },
22 | {
23 | title: '404',
24 | path: '/wrong-url',
25 | icon: 'error',
26 | },
27 | {
28 | title: 'About',
29 | path: '/about',
30 | icon: 'info',
31 | },
32 | ];
33 |
34 | // Add debug links
35 | IS_DEBUG &&
36 | SIDE_BAR_ITEMS.push({
37 | title: '[Debug Tools]',
38 | path: '/dev',
39 | icon: 'settings',
40 | });
41 |
42 | /**
43 | * Renders "Private Layout" composition
44 | * @layout PrivateLayout
45 | */
46 | const PrivateLayout: FunctionComponent = ({ children }) => {
47 | const title = TITLE_PRIVATE;
48 | document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
49 |
50 | return (
51 |
52 | {children}
53 | {/* Copyright © */}
54 |
55 | );
56 | };
57 |
58 | export default PrivateLayout;
59 |
--------------------------------------------------------------------------------
/src/app/dev/components/DemoAppIcon.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState } from 'react';
3 | import copyToClipboard from 'copy-to-clipboard';
4 | import { Box, Card, CardContent, CardHeader, Snackbar } from '@mui/material';
5 | import { AppIconButton } from '@/components';
6 | import { ICONS } from '@/components/common/AppIcon/config';
7 |
8 | /**
9 | * Renders "Demo Section" for AppIcon component
10 | * @component DemoAppIcon
11 | */
12 | const DemoAppIcon = () => {
13 | const [snackbarOpen, setSnackbarOpen] = useState(false);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | {Object.keys(ICONS).map((icon) => (
21 | {
26 | copyToClipboard(``);
27 | setSnackbarOpen(true); // Show snackbar
28 | setTimeout(() => setSnackbarOpen(false), 3000); // Hide snackbar after small delay
29 | }}
30 | />
31 | ))}
32 |
33 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default DemoAppIcon;
47 |
--------------------------------------------------------------------------------
/src/components/common/AppIcon/AppIcon.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
2 | import { APP_ICON_SIZE } from '../../config';
3 | import { IconName, ICONS } from './config';
4 |
5 | /**
6 | * Props of the AppIcon component, also can be used for SVG icons
7 | */
8 | export interface Props extends SVGAttributes {
9 | color?: string;
10 | icon?: IconName | string;
11 | size?: string | number;
12 | title?: string;
13 | }
14 |
15 | /**
16 | * Renders SVG icon by given Icon name
17 | * @component AppIcon
18 | * @param {string} [color] - color of the icon as a CSS color value
19 | * @param {string} [icon] - name of the Icon to render
20 | * @param {string} [title] - title/hint to show when the cursor hovers the icon
21 | * @param {string | number} [size] - size of the icon, default is ICON_SIZE
22 | */
23 | const AppIcon: FunctionComponent = ({
24 | color,
25 | icon = 'default',
26 | size = APP_ICON_SIZE,
27 | style,
28 | ...restOfProps
29 | }) => {
30 | const iconName = (icon || 'default').trim().toLowerCase() as IconName;
31 |
32 | let ComponentToRender: ComponentType = ICONS[iconName];
33 | if (!ComponentToRender) {
34 | console.warn(`AppIcon: icon "${iconName}" is not found!`);
35 | ComponentToRender = ICONS.default; // ICONS['default'];
36 | }
37 |
38 | const propsToRender = {
39 | height: size,
40 | color,
41 | fill: color && 'currentColor',
42 | size,
43 | style: { ...style, color },
44 | width: size,
45 | ...restOfProps,
46 | };
47 |
48 | return ;
49 | };
50 |
51 | export default AppIcon;
52 |
--------------------------------------------------------------------------------
/src/theme/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { FunctionComponent, PropsWithChildren, useEffect, useMemo, useState } from 'react';
3 | import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles';
4 |
5 | import { useAppStore } from '../store';
6 | import DARK_THEME from './dark';
7 | import LIGHT_THEME from './light';
8 | import MuiThemeProviderForNextJs from './MuiThemeProviderForNextJs';
9 | import CssBaseline from '@mui/material/CssBaseline';
10 |
11 | function getThemeByDarkMode(darkMode: boolean) {
12 | return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME);
13 | }
14 |
15 | /**
16 | * Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App
17 | * The Light or Dark themes applied depending on global .darkMode state
18 | * @component AppThemeProvider
19 | */
20 | const AppThemeProvider: FunctionComponent = ({ children }) => {
21 | const [state] = useAppStore();
22 | const [loading, setLoading] = useState(true);
23 |
24 | const currentTheme = useMemo(
25 | () => getThemeByDarkMode(state.darkMode),
26 | [state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes
27 | );
28 |
29 | useEffect(() => setLoading(false), []); // Set .loading to false when the component is mounted
30 |
31 | if (loading) return null; // Don't render anything until the component is mounted
32 |
33 | return (
34 |
35 |
36 |
37 | {children}
38 |
39 |
40 | );
41 | };
42 |
43 | export default AppThemeProvider;
44 |
--------------------------------------------------------------------------------
/src/app/home/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata, NextPage } from 'next';
2 | import { Stack, Typography } from '@mui/material';
3 | import { AppLink } from '@/components';
4 | import DemoAppAlert from '../dev/components/DemoAppAlerts';
5 | import DemoAppButton from '../dev/components/DemoAppButton';
6 | import DemoAppIcon from '../dev/components/DemoAppIcon';
7 | import DemoAppIconButton from '../dev/components/DemoAppIconButton';
8 | import DemoAppImage from '../dev/components/DemoAppImage';
9 |
10 | export const metadata: Metadata = {
11 | title: '_TITLE_',
12 | description: '_DESCRIPTION_',
13 | };
14 |
15 | /**
16 | * Main page of the Application
17 | * @page Home
18 | */
19 | const Home: NextPage = () => {
20 | return (
21 |
22 |
23 | About application
24 |
25 | This application is a mix of{' '}
26 | Create Next App and{' '}
27 | MUI with set of reusable components and utilities to build
28 | professional NextJS application faster. The source code is
29 | available on GitHub.
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default Home;
45 |
--------------------------------------------------------------------------------
/src/components/common/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Component, ErrorInfo, ReactNode } from 'react';
3 |
4 | interface Props {
5 | children: ReactNode;
6 | name: string;
7 | }
8 |
9 | interface State {
10 | hasError: boolean;
11 | error?: Error;
12 | errorInfo?: ErrorInfo;
13 | }
14 |
15 | /**
16 | * Error boundary wrapper to save Application parts from falling
17 | * @component ErrorBoundary
18 | * @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default
19 | */
20 | class ErrorBoundary extends Component {
21 | static defaultProps = {
22 | name: 'Error Boundary',
23 | };
24 |
25 | constructor(props: Props) {
26 | super(props);
27 | this.state = { hasError: false };
28 | }
29 |
30 | static getDerivedStateFromError(error: Error) {
31 | // The next render will show the Error UI
32 | return { hasError: true };
33 | }
34 |
35 | componentDidCatch(error: Error, errorInfo: ErrorInfo) {
36 | // Save information to help render Error UI
37 | this.setState({ error, errorInfo });
38 | // TODO: Add log error messages to an error reporting service here
39 | }
40 |
41 | render() {
42 | if (this.state.hasError) {
43 | // Error UI rendering
44 | return (
45 |
46 |
{this.props.name} - Something went wrong
47 |
48 | {this.state?.error?.toString()}
49 |
50 | {this.state?.errorInfo?.componentStack}
51 |
52 |
53 | );
54 | }
55 |
56 | // Normal UI rendering
57 | return this.props.children;
58 | }
59 | }
60 |
61 | export default ErrorBoundary;
62 |
--------------------------------------------------------------------------------
/src/utils/localStorage.ts:
--------------------------------------------------------------------------------
1 | import { IS_SERVER } from './environment';
2 |
3 | /**
4 | * Smartly reads value from localStorage
5 | */
6 | export function localStorageGet(name: string, defaultValue: any = ''): string {
7 | if (IS_SERVER) {
8 | return defaultValue; // We don't have access to localStorage on the server
9 | }
10 |
11 | const valueFromStore = localStorage.getItem(name);
12 | if (valueFromStore === null) return defaultValue; // No value in store, return default one
13 |
14 | try {
15 | const jsonParsed = JSON.parse(valueFromStore);
16 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
17 | return jsonParsed; // We successfully parse JS value from the store
18 | }
19 | } catch (error) {}
20 |
21 | return valueFromStore; // Return string value as it is
22 | }
23 |
24 | /**
25 | * Smartly writes value into localStorage
26 | */
27 | export function localStorageSet(name: string, value: any) {
28 | if (IS_SERVER) {
29 | return; // Do nothing on server side
30 | }
31 | if (typeof value === 'undefined') {
32 | return; // Do not store undefined values
33 | }
34 | let valueAsString: string;
35 | if (typeof value === 'object') {
36 | valueAsString = JSON.stringify(value);
37 | } else {
38 | valueAsString = String(value);
39 | }
40 |
41 | localStorage.setItem(name, valueAsString);
42 | }
43 |
44 | /**
45 | * Deletes value by name from localStorage, if specified name is empty entire localStorage is cleared.
46 | */
47 | export function localStorageDelete(name: string) {
48 | if (IS_SERVER) {
49 | return; // Do nothing on server side
50 | }
51 | if (name) {
52 | localStorage.removeItem(name);
53 | } else {
54 | localStorage.clear();
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/utils/sessionStorage.ts:
--------------------------------------------------------------------------------
1 | import { IS_SERVER } from './environment';
2 |
3 | /**
4 | * Smartly reads value from sessionStorage
5 | */
6 | export function sessionStorageGet(name: string, defaultValue: any = ''): string {
7 | if (IS_SERVER) {
8 | return defaultValue; // We don't have access to sessionStorage on the server
9 | }
10 |
11 | const valueFromStore = sessionStorage.getItem(name);
12 | if (valueFromStore === null) return defaultValue; // No value in store, return default one
13 |
14 | try {
15 | const jsonParsed = JSON.parse(valueFromStore);
16 | if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
17 | return jsonParsed; // We successfully parse JS value from the store
18 | }
19 | } catch (error) {}
20 |
21 | return valueFromStore; // Return string value as it is
22 | }
23 |
24 | /**
25 | * Smartly writes value into sessionStorage
26 | */
27 | export function sessionStorageSet(name: string, value: any) {
28 | if (IS_SERVER) {
29 | return; // Do nothing on server side
30 | }
31 | if (typeof value === 'undefined') {
32 | return; // Do not store undefined values
33 | }
34 | let valueAsString: string;
35 | if (typeof value === 'object') {
36 | valueAsString = JSON.stringify(value);
37 | } else {
38 | valueAsString = String(value);
39 | }
40 |
41 | sessionStorage.setItem(name, valueAsString);
42 | }
43 |
44 | /**
45 | * Deletes value by name from sessionStorage, if specified name is empty entire sessionStorage is cleared.
46 | */
47 | export function sessionStorageDelete(name: string) {
48 | if (IS_SERVER) {
49 | return; // Do nothing on server side
50 | }
51 | if (name) {
52 | sessionStorage.removeItem(name);
53 | } else {
54 | sessionStorage.clear();
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-mui-starter-ts",
3 | "version": "0.2.9",
4 | "description": "_DESCRIPTION_",
5 | "author": {
6 | "name": "Anton Karpenko",
7 | "email": "i@karpolan.com",
8 | "url": "https://karpolan.com"
9 | },
10 | "private": true,
11 | "keywords": [
12 | "nextjs",
13 | "react",
14 | "typescript",
15 | "mui",
16 | "material",
17 | "material ui"
18 | ],
19 | "repository": {
20 | "type": "git",
21 | "url": "https://github.com/karpolan/nextjs-mui-starter-ts.git"
22 | },
23 | "scripts": {
24 | "dev": "next dev",
25 | "build": "next build",
26 | "format": "prettier ./ --write",
27 | "lint": "next lint",
28 | "start": "next start",
29 | "test": "jest --watch",
30 | "test:ci": "jest --ci",
31 | "type": "tsc"
32 | },
33 | "dependencies": {
34 | "@emotion/cache": "latest",
35 | "@emotion/react": "latest",
36 | "@emotion/server": "latest",
37 | "@emotion/styled": "latest",
38 | "@mui/icons-material": "latest",
39 | "@mui/material": "latest",
40 | "@mui/material-nextjs": "latest",
41 | "clsx": "latest",
42 | "copy-to-clipboard": "latest",
43 | "next": "latest",
44 | "react": "latest",
45 | "react-dom": "latest"
46 | },
47 | "devDependencies": {
48 | "@testing-library/jest-dom": "latest",
49 | "@testing-library/react": "latest",
50 | "@testing-library/user-event": "latest",
51 | "@types/jest": "latest",
52 | "@types/node": "latest",
53 | "@types/react": "latest",
54 | "@types/react-dom": "latest",
55 | "eslint": "latest",
56 | "eslint-config-next": "latest",
57 | "jest": "latest",
58 | "jest-environment-jsdom": "latest",
59 | "next-router-mock": "latest",
60 | "prettier": "latest",
61 | "ts-node": "latest",
62 | "typescript": "^5"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/utils/navigation.ts:
--------------------------------------------------------------------------------
1 | import { IS_BROWSER } from './environment';
2 |
3 | export const EXTERNAL_LINK_PROPS = {
4 | target: '_blank',
5 | rel: 'noopener noreferrer',
6 | };
7 |
8 | /**
9 | * Disables "Back" button for current page
10 | * Usage: Call function in useEffect( ,[]) or directly
11 | */
12 | export function disableBackNavigation() {
13 | window.history.pushState(null, '', window.location.href);
14 | window.onpopstate = function () {
15 | window.history.go(1);
16 | };
17 | }
18 |
19 | /**
20 | * Navigates to the specified URL with options
21 | */
22 | export function navigateTo(url: string, replaceInsteadOfPush = false, optionalTitle = '') {
23 | if (replaceInsteadOfPush) {
24 | window.history.replaceState(null, optionalTitle, url);
25 | } else {
26 | window.history.pushState(null, optionalTitle, url);
27 | }
28 | }
29 |
30 | /**
31 | * For smooth scrolling to the specified element with optional offset
32 | * @param {object} destinationElement - DOM element to scroll to
33 | * @param {number} [verticalOffset] - optional vertical offset
34 | * @param {object} [scrollingElement] - optional scrolling element, defaults to .window
35 | * @param {string} [behavior] - optional scroll behavior, defaults to 'smooth'
36 | */
37 | export function scrollIntoViewAdjusted(
38 | destinationElement: Element | HTMLElement | null,
39 | verticalOffset = 0,
40 | scrollingElement?: Element | HTMLElement | null,
41 | behavior: 'auto' | 'instant' | 'smooth' | undefined = 'smooth'
42 | ) {
43 | if (!IS_BROWSER || !destinationElement) {
44 | return;
45 | }
46 |
47 | const rect = destinationElement.getBoundingClientRect();
48 | if (!rect || typeof rect.top === 'undefined') {
49 | return;
50 | }
51 |
52 | const top = rect.top - verticalOffset;
53 | const elementToScroll = scrollingElement ?? window;
54 | elementToScroll.scrollBy({ top, behavior });
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/common/AppAlert/AppAlert.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import AppAlert from './AppAlert';
3 | import { capitalize, randomText } from '@/utils';
4 | import { AlertProps } from '@mui/material';
5 |
6 | const ComponentToTest = AppAlert;
7 |
8 | /**
9 | * Tests for component
10 | */
11 | describe(' component', () => {
12 | it('renders itself', () => {
13 | const testId = randomText(8);
14 | render();
15 | const alert = screen.getByTestId(testId);
16 | expect(alert).toBeDefined();
17 | expect(alert).toHaveAttribute('role', 'alert');
18 | expect(alert).toHaveClass('MuiAlert-root');
19 | });
20 |
21 | it('supports .severity property', () => {
22 | const SEVERITIES = ['error', 'info', 'success', 'warning'];
23 | for (const severity of SEVERITIES) {
24 | const testId = randomText(8);
25 | const severity = 'success';
26 | render(
27 |
32 | );
33 | const alert = screen.getByTestId(testId);
34 | expect(alert).toBeDefined();
35 | expect(alert).toHaveClass(`MuiAlert-filled${capitalize(severity)}`);
36 | }
37 | });
38 |
39 | it('supports .variant property', () => {
40 | const VARIANTS = ['filled', 'outlined', 'standard'];
41 | for (const variant of VARIANTS) {
42 | const testId = randomText(8);
43 | render(
44 |
49 | );
50 | const alert = screen.getByTestId(testId);
51 | expect(alert).toBeDefined();
52 | expect(alert).toHaveClass(`MuiAlert-${variant}Warning`);
53 | }
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Starter project for Next.js with App Router + Material UI using TypeScript
2 |
3 | Mix of [Create Next App](https://nextjs.org/docs/pages/api-reference/create-next-app) and [MUI](https://mui.com) with set of reusable components and utilities to build professional NextJS application faster.
4 |
5 | - [Source Code](https://github.com/karpolan/nextjs-mui-starter-ts)
6 | - [Online Demo](https://nextjs-mui-starter-ts.vercel.app)
7 |
8 | _Warning: if your are planning to use **Pages Router** (not **App Router**) then use [this template](https://github.com/karpolan/nextjs-with-pages-mui-starter-ts)_
9 |
10 | ## How to use
11 |
12 | 1. Clone or download the repo from: https://github.com/karpolan/nextjs-mui-starter-ts
13 | 2. Copy `.env.sample` file into `.env` file
14 | 3. Replace `_TITLE_` and `_DESCRIPTION_` in all files with own texts
15 | 4. Check and resolve all `// TODO: ` directives
16 | 5. Add your own code :)
17 |
18 | ## Available Scripts
19 |
20 | In the project directory, you can run:
21 |
22 | ### `npm run dev` or `yarn dev`
23 |
24 | Runs the app in the development mode.
25 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
26 |
27 | The page will reload if you make edits.
28 | You will also see any lint errors in the console.
29 |
30 | ### `npm run lint` or `yarn lint`
31 |
32 | Checks the code for errors and missing things
33 |
34 | ### `npm run format` or `yarn format`
35 |
36 | Formats the code according to `./prettierrc.js` config
37 |
38 | ### `npm test` or `yarn test`
39 |
40 | Launches the test runner in the interactive watch mode.
41 |
42 | ### `npm run build` or `yarn build`
43 |
44 | Builds the app for production or local development to the `.next` folder.
45 |
46 | ## Deploy on Vercel
47 |
48 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
49 |
50 | Check out our [Next.js deployment documentation]https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
51 |
--------------------------------------------------------------------------------
/src/components/common/AppImage/AppImage.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { randomText } from '@/utils';
3 | import AppImage from './AppImage';
4 |
5 | const ComponentToTest = AppImage;
6 |
7 | /**
8 | * Tests for component
9 | */
10 | describe(' component', () => {
11 | const src = 'https:/domain.com/image.jpg';
12 |
13 | it('renders itself', () => {
14 | const testId = randomText(8);
15 | render();
16 | const image = screen.getByTestId(testId);
17 | expect(image).toBeDefined();
18 | expect(image).toHaveAttribute('src', src);
19 | expect(image).toHaveAttribute('alt', 'Image'); // Default prop value
20 | expect(image).toHaveAttribute('height', '256'); // Default prop value
21 | expect(image).toHaveAttribute('width', '256'); // Default prop value
22 | });
23 |
24 | it('supports .width and .height props', () => {
25 | const testId = randomText(8);
26 | const height = 345;
27 | const width = 123;
28 | render();
29 | const image = screen.getByTestId(testId);
30 | expect(image).toBeDefined();
31 | expect(image).toHaveAttribute('height', String(height));
32 | expect(image).toHaveAttribute('width', String(width));
33 | });
34 |
35 | it('supports .title property', () => {
36 | const testId = randomText(8);
37 | const title = randomText(16);
38 | render();
39 | const image = screen.getByTestId(testId);
40 | expect(image).toBeDefined();
41 | expect(image).toHaveAttribute('title', title);
42 | expect(image).toHaveAttribute('alt', title); // When title is provided, it is used as alt
43 | });
44 |
45 | it('supports .alt property even when .title is provided', () => {
46 | const testId = randomText(8);
47 | const title = randomText(16);
48 | const alt = randomText(32);
49 | render();
50 | const image = screen.getByTestId(testId);
51 | expect(image).toBeDefined();
52 | expect(image).toHaveAttribute('alt', alt);
53 | expect(image).toHaveAttribute('title', title);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/layout/PublicLayout.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, PropsWithChildren } from 'react';
2 | import { Stack } from '@mui/material';
3 | import { IS_DEBUG } from '@/config';
4 | import { LinkToPage } from '@/utils';
5 | import { useIsMobile } from '@/hooks';
6 | import { BottomBar } from './components';
7 | import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
8 | import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config';
9 |
10 | // TODO: change to your app name or other word
11 | const TITLE_PUBLIC = 'Unauthorized - _TITLE_'; // Title for pages without/before authentication
12 |
13 | /**
14 | * SideBar navigation items with links for Public Layout
15 | */
16 | const SIDE_BAR_ITEMS: Array = [
17 | {
18 | title: 'Log In',
19 | path: '/auth/login',
20 | icon: 'login',
21 | },
22 | {
23 | title: 'Sign Up',
24 | path: '/auth/signup',
25 | icon: 'signup',
26 | },
27 | {
28 | title: 'About',
29 | path: '/about',
30 | icon: 'info',
31 | },
32 | ];
33 |
34 | // Add debug links
35 | IS_DEBUG &&
36 | SIDE_BAR_ITEMS.push({
37 | title: '[Debug Tools]',
38 | path: '/dev',
39 | icon: 'settings',
40 | });
41 |
42 | /**
43 | * BottomBar navigation items with links for Public Layout
44 | */
45 | const BOTTOM_BAR_ITEMS: Array = [
46 | {
47 | title: 'Log In',
48 | path: '/auth/login',
49 | icon: 'login',
50 | },
51 | {
52 | title: 'Sign Up',
53 | path: '/auth/signup',
54 | icon: 'signup',
55 | },
56 | {
57 | title: 'About',
58 | path: '/about',
59 | icon: 'info',
60 | },
61 | ];
62 |
63 | /**
64 | * Renders "Public Layout" composition
65 | * @layout PublicLayout
66 | */
67 | const PublicLayout: FunctionComponent = ({ children }) => {
68 | const onMobile = useIsMobile();
69 | const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
70 |
71 | const title = TITLE_PUBLIC;
72 | document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
73 |
74 | return (
75 |
76 | {children}
77 | {bottomBarVisible && }
78 |
79 | );
80 | };
81 |
82 | export default PublicLayout;
83 |
--------------------------------------------------------------------------------
/src/components/common/AppIcon/config.ts:
--------------------------------------------------------------------------------
1 | // SVG assets
2 | import PencilIcon from './icons/PencilIcon';
3 | // MUI Icons
4 | import DefaultIcon from '@mui/icons-material/MoreHoriz';
5 | import SettingsIcon from '@mui/icons-material/Settings';
6 | import VisibilityIcon from '@mui/icons-material/Visibility';
7 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
8 | import MenuIcon from '@mui/icons-material/Menu';
9 | import CloseIcon from '@mui/icons-material/Close';
10 | import DayNightIcon from '@mui/icons-material/Brightness4';
11 | import NightIcon from '@mui/icons-material/Brightness3';
12 | import DayIcon from '@mui/icons-material/Brightness5';
13 | import SearchIcon from '@mui/icons-material/Search';
14 | import InfoIcon from '@mui/icons-material/Info';
15 | import HomeIcon from '@mui/icons-material/Home';
16 | import AccountCircle from '@mui/icons-material/AccountCircle';
17 | import PersonAddIcon from '@mui/icons-material/PersonAdd';
18 | import PersonIcon from '@mui/icons-material/Person';
19 | import ExitToAppIcon from '@mui/icons-material/ExitToApp';
20 | import NotificationsIcon from '@mui/icons-material/NotificationsOutlined';
21 | import DangerousIcon from '@mui/icons-material/Dangerous';
22 |
23 | /**
24 | * List of all available Icon names
25 | */
26 | export type IconName = keyof typeof ICONS;
27 |
28 | /**
29 | * How to use:
30 | * 1. Import all required React, MUI or other SVG icons into this file.
31 | * 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
32 | * 3. Use icons everywhere in the App by their names in component
33 | * Important: properties of ICONS object MUST be lowercase!
34 | * Note: You can use camelCase or UPPERCASE in the component
35 | */
36 | export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record */ = {
37 | default: DefaultIcon,
38 | logo: PencilIcon,
39 | close: CloseIcon,
40 | menu: MenuIcon,
41 | settings: SettingsIcon,
42 | visibilityon: VisibilityIcon,
43 | visibilityoff: VisibilityOffIcon,
44 | daynight: DayNightIcon,
45 | night: NightIcon,
46 | day: DayIcon,
47 | search: SearchIcon,
48 | info: InfoIcon,
49 | home: HomeIcon,
50 | account: AccountCircle,
51 | signup: PersonAddIcon,
52 | login: PersonIcon,
53 | logout: ExitToAppIcon,
54 | notifications: NotificationsIcon,
55 | error: DangerousIcon,
56 | };
57 |
--------------------------------------------------------------------------------
/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 | /**
9 | * Returns the value of the environment variable with the given name, raises an error if it is required and not set.
10 | * Note: My not work with Next.js on client-side code.
11 | * @param {string} name - The name of the environment variable to get: e.g. XXX_YYY_PUBLIC_URL
12 | * @param {boolean} [isRequired] - Whether the environment variable is required or not.
13 | * @param {string} [defaultValue] - The default value to return if the environment variable is not set.
14 | * @returns {string} The value of the environment variable with the given name.
15 | */
16 | export function envGet(
17 | name: string,
18 | isRequired = false,
19 | defaultValue: string | undefined = undefined
20 | ): string | undefined {
21 | let variable = process.env[name]; // Classic way
22 | // let variable = import.meta.env[name]; // Vite way
23 |
24 | if (typeof variable === 'undefined') {
25 | if (isRequired) {
26 | throw new Error(`Missing process.env.${name} variable`);
27 | }
28 | variable = defaultValue;
29 | }
30 | return variable;
31 | }
32 |
33 | /**
34 | * Verifies existence of environment variables, raises an error if it is required and not set.
35 | * @example const MY_VARIABLE = requireEnv(process.env.MY_VARIABLE);
36 | * @param {string} [passProcessDotEnvDotValueNameHere] - Pass a value of process.env.MY_VARIABLE here, not just a name!
37 | * @returns {string} The value of incoming parameter.
38 | * @throws Error "Missing .env variable!"
39 | */
40 | export function envRequired(passProcessDotEnvDotValueNameHere: string | undefined): string {
41 | if (typeof passProcessDotEnvDotValueNameHere === 'undefined') {
42 | throw new Error('Missing .env variable!');
43 | }
44 | return passProcessDotEnvDotValueNameHere;
45 | }
46 |
47 | export function getCurrentVersion(): string {
48 | return process.env.npm_package_version ?? process.env.NEXT_PUBLIC_VERSION ?? 'unknown';
49 | }
50 |
51 | export function getCurrentEnvironment(): string {
52 | return process.env.NEXT_PUBLIC_ENV ?? process.env?.NODE_ENV ?? 'development';
53 | }
54 |
--------------------------------------------------------------------------------
/src/store/AppStore.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import {
3 | createContext,
4 | useReducer,
5 | useContext,
6 | FunctionComponent,
7 | PropsWithChildren,
8 | Dispatch,
9 | ComponentType,
10 | } from 'react';
11 | // import useMediaQuery from '@mui/material/useMediaQuery';
12 | import AppReducer from './AppReducer';
13 | import { localStorageGet } from '../utils/localStorage';
14 | import { IS_SERVER } from '../utils/environment';
15 | import { APP_STORE_INITIAL_STATE, AppStoreState } from './config';
16 |
17 | /**
18 | * Instance of React Context for global AppStore
19 | */
20 | export type AppContextReturningType = [AppStoreState, Dispatch];
21 | const AppContext = createContext([APP_STORE_INITIAL_STATE, () => null]);
22 |
23 | /**
24 | * Main global Store as HOC with React Context API
25 | * @component AppStoreProvider
26 | * import {AppStoreProvider} from './store'
27 | * ...
28 | *
29 | *
30 | *
31 | */
32 | const AppStoreProvider: FunctionComponent = ({ children }) => {
33 | // const prefersDarkMode = IS_SERVER ? false : useMediaQuery('(prefers-color-scheme: dark)'); // Note: Conditional hook is bad idea :(
34 | const prefersDarkMode = IS_SERVER ? false : window.matchMedia('(prefers-color-scheme: dark)').matches;
35 | const previousDarkMode = IS_SERVER ? false : Boolean(localStorageGet('darkMode', false));
36 | // const tokenExists = Boolean(loadToken());
37 |
38 | const initialState: AppStoreState = {
39 | ...APP_STORE_INITIAL_STATE,
40 | darkMode: previousDarkMode || prefersDarkMode,
41 | // isAuthenticated: tokenExists,
42 | };
43 | const value: AppContextReturningType = useReducer(AppReducer, initialState);
44 |
45 | return {children};
46 | };
47 |
48 | /**
49 | * Hook to use the AppStore in functional components
50 | * @hook useAppStore
51 | * import {useAppStore} from './store'
52 | * ...
53 | * const [state, dispatch] = useAppStore();
54 | * OR
55 | * const [state] = useAppStore();
56 | */
57 | const useAppStore = (): AppContextReturningType => useContext(AppContext);
58 |
59 | /**
60 | * HOC to inject the ApStore to class component, also works for functional components
61 | * @hok withAppStore
62 | * import {withAppStore} from './store'
63 | * ...
64 | * class MyComponent
65 | *
66 | * render () {
67 | * const [state, dispatch] = this.props.appStore;
68 | * ...
69 | * }
70 | * ...
71 | * export default withAppStore(MyComponent)
72 | */
73 | interface WithAppStoreProps {
74 | appStore: AppContextReturningType;
75 | }
76 | const withAppStore = (Component: ComponentType): FunctionComponent =>
77 | function ComponentWithAppStore(props) {
78 | return ;
79 | };
80 |
81 | export { AppStoreProvider, useAppStore, withAppStore };
82 |
--------------------------------------------------------------------------------
/src/components/common/AppIcon/AppIcon.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import AppIcon from './AppIcon';
3 | import { APP_ICON_SIZE } from '../../config';
4 | import { randomColor, randomText } from '@/utils';
5 | import { ICONS } from './config';
6 |
7 | const ComponentToTest = AppIcon;
8 |
9 | /**
10 | * Tests for component
11 | */
12 | describe(' component', () => {
13 | it('renders itself', () => {
14 | const testId = randomText(8);
15 | render();
16 | const svg = screen.getByTestId(testId);
17 | expect(svg).toBeDefined();
18 | expect(svg).toHaveAttribute('data-icon', 'default');
19 | expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
20 | expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
21 | expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
22 | });
23 |
24 | it('supports .color property', () => {
25 | const testId = randomText(8);
26 | const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
27 | render();
28 | const svg = screen.getByTestId(testId);
29 | expect(svg).toHaveAttribute('data-icon', 'default');
30 | // expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from