├── .env.example
├── .editorconfig
├── .prettierignore
├── src
├── utils
│ ├── index.ts
│ └── string.ts
├── ts
│ └── types
│ │ ├── index.ts
│ │ └── common.ts
├── data
│ ├── index.ts
│ └── constant
│ │ ├── path.ts
│ │ ├── color.ts
│ │ ├── type-navs.ts
│ │ └── navs.tsx
├── features
│ └── todo
│ │ ├── index.ts
│ │ ├── services
│ │ ├── types.ts
│ │ └── todo.api.ts
│ │ └── hooks
│ │ └── use-todo-query.ts
├── components
│ ├── index.ts
│ └── common
│ │ ├── toaster
│ │ └── toaster-config.tsx
│ │ └── button
│ │ └── button-theme.tsx
├── vite-env.d.ts
├── hooks
│ ├── toast
│ │ └── use-toast.ts
│ ├── index.ts
│ ├── use-active-menu.ts
│ ├── use-modal-store.ts
│ └── theme-store
│ │ └── use-theme-store.ts
├── pages
│ ├── home.tsx
│ ├── users
│ │ └── index.tsx
│ ├── index.ts
│ ├── not-found.tsx
│ └── todos
│ │ └── index.tsx
├── routes
│ ├── index.tsx
│ └── render-router.tsx
├── layout
│ ├── footer
│ │ └── index.tsx
│ ├── error-boundary
│ │ └── fallbackRender.tsx
│ ├── index.tsx
│ └── header
│ │ └── index.tsx
├── main.tsx
├── provider
│ ├── query-provider.tsx
│ └── theme-config-provider.tsx
├── apis
│ └── axios-client.ts
├── index.css
└── assets
│ └── react.svg
├── .env
├── .prettierrc
├── .huskyrc
├── .husky
└── pre-commit
├── postcss.config.js
├── public
├── assets
│ └── imgs
│ │ └── banner.png
└── vite.svg
├── .eslintignore
├── tsconfig.node.json
├── postinstall.sh
├── .gitignore
├── index.html
├── vite.config.ts
├── .github
└── FUNDING.yml
├── tsconfig.json
├── tailwind.config.js
├── .eslintrc.cjs
├── package.json
└── README.md
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_API_URL=
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_size = 2
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './string';
2 |
--------------------------------------------------------------------------------
/src/ts/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './common';
2 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | VITE_API_URL=https://public-api-crud-todo-app.vercel.app/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "auto",
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/.huskyrc:
--------------------------------------------------------------------------------
1 | export NVM_DIR="$HOME/.nvm"
2 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
--------------------------------------------------------------------------------
/src/data/index.ts:
--------------------------------------------------------------------------------
1 | export * from './constant/path';
2 | export * from './constant/color';
3 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/src/features/todo/index.ts:
--------------------------------------------------------------------------------
1 | export * from './services/types';
2 |
3 | export * from './hooks/use-todo-query';
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './common/button/button-theme';
2 | export * from './common/toaster/toaster-config';
3 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | VITE_API_URL: string;
5 | }
6 |
--------------------------------------------------------------------------------
/public/assets/imgs/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sonht113/react-boilerplate-for-starter/HEAD/public/assets/imgs/banner.png
--------------------------------------------------------------------------------
/src/utils/string.ts:
--------------------------------------------------------------------------------
1 | export function capitalizeFirstLetter(str: string) {
2 | return str.charAt(0).toUpperCase() + str.slice(1);
3 | }
4 |
--------------------------------------------------------------------------------
/src/hooks/toast/use-toast.ts:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-hot-toast';
2 |
3 | function useToast() {
4 | return { toast };
5 | }
6 |
7 | export default useToast;
8 |
--------------------------------------------------------------------------------
/src/pages/home.tsx:
--------------------------------------------------------------------------------
1 | const Home = () => {
2 | return (
3 |
4 | Home
5 |
6 | );
7 | };
8 |
9 | export default Home;
10 |
--------------------------------------------------------------------------------
/src/data/constant/path.ts:
--------------------------------------------------------------------------------
1 | export const LOGIN_PATH = '/login';
2 | export const HOME_PATH = '/home';
3 | export const USER_PATH = '/users';
4 | export const TODO_PATH = '/todo';
5 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | build
2 | dist
3 | node_modules
4 | *.test.js
5 | vite.config.ts
6 | postcss.config.js
7 | tailwind.config.js
8 | i18n.ts
9 | public/firebase-messaging-sw.js
10 | src/assets
--------------------------------------------------------------------------------
/src/pages/users/index.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | const Users: FC = () => {
4 | return (
5 |
6 | Users
7 |
8 | );
9 | };
10 |
11 | export default Users;
12 |
--------------------------------------------------------------------------------
/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | import { lazy } from 'react';
2 |
3 | const Home = lazy(() => import('@/pages/home'));
4 |
5 | const Users = lazy(() => import('@/pages/users'));
6 |
7 | const Todos = lazy(() => import('@/pages/todos'));
8 |
9 | export { Home, Users, Todos };
10 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/data/constant/color.ts:
--------------------------------------------------------------------------------
1 | const COLOR = {
2 | ACTIVE: '#34A853',
3 | LOCKED: '#F31111',
4 | PAUSE: '#FA8C16',
5 | DISABLED: '#96969B',
6 | LIGHT_PRIMARY: '#C67F03',
7 | DARK_PRIMARY: '#3b78f9',
8 | LOGIN_BG: '#162C5B',
9 | };
10 |
11 | export default COLOR;
12 |
--------------------------------------------------------------------------------
/postinstall.sh:
--------------------------------------------------------------------------------
1 | HUSKYFOLDER=.husky
2 | FILE=.husky/pre-commit
3 | FILE2=.husky/_
4 | if [ ! -d "$HUSKYFOLDER" ] || [ ! -f "$FILE" ] || [ ! -d "$FILE2" ]; then
5 | git init & mkdir .husky & npx husky install & rm -rf .husky/pre-commit & npx husky add .husky/pre-commit 'pnpm lint-staged'
6 | fi
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import useThemeStore from './theme-store/use-theme-store';
2 | import useToast from './toast/use-toast';
3 | import { useActiveMenu } from './use-active-menu';
4 | import useModalStore from './use-modal-store';
5 |
6 | export { useThemeStore, useModalStore, useToast, useActiveMenu };
7 |
--------------------------------------------------------------------------------
/src/hooks/use-active-menu.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'react-router-dom';
2 |
3 | export const useActiveMenu = () => {
4 | const router = useLocation();
5 | const path = router.pathname;
6 |
7 | const checkActive = (link: string) => {
8 | return path === link || path.includes(link);
9 | };
10 |
11 | return { checkActive };
12 | };
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/features/todo/services/types.ts:
--------------------------------------------------------------------------------
1 | export type TodoData = {
2 | _id: string;
3 | todoName: string;
4 | isComplete: boolean;
5 | createdAt: string;
6 | updatedAt: string;
7 | };
8 |
9 | export type ResponseData = {
10 | code: number;
11 | data: TodoData[];
12 | };
13 |
14 | export type TodoDataMutation = Partial<
15 | Pick
16 | >;
17 |
--------------------------------------------------------------------------------
/src/hooks/use-modal-store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | type ModalStore = {
4 | isOpen: boolean;
5 | open: () => void;
6 | close: () => void;
7 | };
8 |
9 | const useModalStore = create()((set) => ({
10 | isOpen: false,
11 | open: () => set({ isOpen: true }),
12 | close: () => set({ isOpen: false }),
13 | }));
14 |
15 | export default useModalStore;
16 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react';
2 |
3 | import { BrowserRouter } from 'react-router-dom';
4 |
5 | import RenderRouter from './render-router';
6 |
7 | const Routes = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default Routes;
18 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | ReactJS Boilerplate By TrongSon
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/data/constant/type-navs.ts:
--------------------------------------------------------------------------------
1 | import { ReactElement } from 'react';
2 |
3 | type NavsChild = {
4 | key: string;
5 | label?: string | ReactElement;
6 | element?: ReactElement;
7 | };
8 |
9 | export type TypeNavs = NavsChild & {
10 | children?: TypeNavs[];
11 | };
12 |
13 | type RouteChild = {
14 | path: string;
15 | element: ReactElement;
16 | };
17 |
18 | export type TypeRoutes = RouteChild & {
19 | children?: RouteChild[];
20 | };
21 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import path from 'path';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | resolve: {
8 | alias: {
9 | '@': path.join(__dirname, 'src'),
10 | },
11 | },
12 | plugins: [
13 | react({
14 | jsxImportSource: '@emotion/react',
15 | babel: {
16 | plugins: ['@emotion/babel-plugin'],
17 | },
18 | }),
19 | ],
20 | });
21 |
--------------------------------------------------------------------------------
/src/layout/footer/index.tsx:
--------------------------------------------------------------------------------
1 | const FooterComponent = () => {
2 | return (
3 |
11 | )
12 | }
13 |
14 | export default FooterComponent;
--------------------------------------------------------------------------------
/src/components/common/toaster/toaster-config.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 |
3 | import { Toaster } from 'react-hot-toast';
4 |
5 | type Props = {
6 | position?:
7 | | 'top-left'
8 | | 'top-center'
9 | | 'top-right'
10 | | 'bottom-left'
11 | | 'bottom-center'
12 | | 'bottom-right';
13 | reverseOrder?: boolean;
14 | };
15 |
16 | export const ToasterConfig: FC = ({
17 | position = 'top-center',
18 | reverseOrder = false,
19 | }) => {
20 | return ;
21 | };
22 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ReactDOM from 'react-dom/client';
4 |
5 | import './index.css';
6 | import QueryProvider from './provider/query-provider.tsx';
7 | import LayoutConfigProvider from './provider/theme-config-provider.tsx';
8 | import Routes from './routes/index.tsx';
9 | import { ToasterConfig } from '@/components';
10 |
11 | ReactDOM.createRoot(document.getElementById('root')!).render(
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | ,
20 | );
21 |
--------------------------------------------------------------------------------
/src/layout/error-boundary/fallbackRender.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import { FallbackProps } from 'react-error-boundary';
4 |
5 | const fallbackRender: (props: FallbackProps) => ReactNode = ({
6 | error,
7 | resetErrorBoundary,
8 | }: {
9 | error: Record<'message', string>;
10 | resetErrorBoundary: FallbackProps['resetErrorBoundary'];
11 | }) => {
12 | return (
13 |
14 |
Something went wrong:
15 |
{error.message}
16 |
17 |
18 | );
19 | };
20 |
21 | export default fallbackRender;
22 |
--------------------------------------------------------------------------------
/src/routes/render-router.tsx:
--------------------------------------------------------------------------------
1 | import { FC, lazy } from 'react';
2 |
3 | import { Navigate, useRoutes } from 'react-router-dom';
4 |
5 | import { routeList } from '@/data/constant/navs';
6 | import LayoutComponent from '@/layout';
7 |
8 | const NotFound = lazy(() => import('@/pages/not-found'));
9 |
10 | const routes = [
11 | {
12 | path: '/',
13 | element: ,
14 | children: [
15 | {
16 | path: '',
17 | element: ,
18 | },
19 | ...routeList,
20 | {
21 | path: '*',
22 | element: ,
23 | },
24 | ],
25 | },
26 | ];
27 |
28 | const RenderRouter: FC = () => {
29 | const element = useRoutes(routes);
30 |
31 | return element;
32 | };
33 |
34 | export default RenderRouter;
35 |
--------------------------------------------------------------------------------
/src/ts/types/common.ts:
--------------------------------------------------------------------------------
1 | import { QueryKey, UseQueryOptions } from '@tanstack/react-query';
2 |
3 | export type PageParams = {
4 | page?: number;
5 | limit?: number;
6 | };
7 |
8 | export type QueryOptions = Omit<
9 | UseQueryOptions,
10 | | 'queryKey'
11 | | 'queryFn'
12 | | 'refetchInterval'
13 | | 'refetchOnMount'
14 | | 'refetchOnReconnect'
15 | | 'refetchOnWindowFocus'
16 | | 'useErrorBoundary'
17 | >;
18 |
19 | export type ValueOf = T[keyof T];
20 |
21 | // K is the union keyof T whose type is required,
22 | // the remaining keys of T have the same type
23 | export type RequiredKeys = Required<
24 | Pick>
25 | > &
26 | Omit>;
27 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [sonht113]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: [sonht113]
14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
15 |
--------------------------------------------------------------------------------
/src/features/todo/services/todo.api.ts:
--------------------------------------------------------------------------------
1 | import { ResponseData, TodoData, TodoDataMutation } from './types';
2 | import axiosClient from '@/apis/axios-client';
3 |
4 | const baseUrl = 'todos';
5 |
6 | const todoApi = {
7 | getList: (): Promise => axiosClient.get(baseUrl),
8 | getDetail: (id: string): Promise<{ code: number; data: TodoData }> =>
9 | axiosClient.get(`${baseUrl}/${id}`),
10 | add: (body: TodoDataMutation): Promise<{ code: number; data: TodoData }> =>
11 | axiosClient.post(baseUrl, body),
12 | update: (body: {
13 | id: string;
14 | data: TodoDataMutation;
15 | }): Promise<{ code: number; data: TodoData }> =>
16 | axiosClient.put(baseUrl + `/${body.id}`, body.data),
17 | delete: (id: string): Promise<{ code: number; message: string }> =>
18 | axiosClient.delete(baseUrl + `/${id}`),
19 | };
20 |
21 | export default todoApi;
22 |
--------------------------------------------------------------------------------
/src/hooks/theme-store/use-theme-store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | type ThemeStore = {
4 | theme: 'dark' | 'light';
5 | setTheme: (_: { theme: ThemeStore['theme'] }) => void;
6 | };
7 |
8 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
9 | ? 'dark'
10 | : 'light';
11 | const userTheme = localStorage.getItem('theme') as ThemeStore['theme'];
12 |
13 | const useThemeStore = create()((set) => ({
14 | theme: userTheme || 'dark' || systemTheme,
15 | setTheme: ({ theme }: { theme: ThemeStore['theme'] }) => {
16 | const body = document.body;
17 |
18 | if (theme === 'dark') {
19 | if (!body.hasAttribute('class')) {
20 | body.setAttribute('class', 'dark');
21 | }
22 | } else {
23 | if (body.hasAttribute('class')) {
24 | body.removeAttribute('class');
25 | }
26 | }
27 |
28 | set({
29 | theme,
30 | });
31 | },
32 | }));
33 |
34 | export default useThemeStore;
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "types": ["vite/client"],
5 | "useDefineForClassFields": true,
6 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
7 | "allowJs": false,
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 | "esModuleInterop": false,
11 |
12 | /* Bundler mode */
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "allowSyntheticDefaultImports": true,
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "noEmit": true,
19 | "jsx": "react-jsx",
20 |
21 | /* Linting */
22 | "strict": true,
23 | "forceConsistentCasingInFileNames": true,
24 | "noUnusedLocals": true,
25 | "noUnusedParameters": true,
26 | "noFallthroughCasesInSwitch": true,
27 |
28 | "baseUrl": "./",
29 | "paths": {
30 | "@/*": ["src/*"]
31 | }
32 | },
33 | "include": ["src", "tsconfig.json"],
34 | "references": [{ "path": "./tsconfig.node.json" }]
35 | }
36 |
--------------------------------------------------------------------------------
/src/layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react';
2 |
3 | import { ErrorBoundary } from 'react-error-boundary';
4 | import { Outlet } from 'react-router-dom';
5 |
6 | import fallbackRender from './error-boundary/fallbackRender';
7 | import FooterComponent from './footer';
8 | import HeaderComponent from './header';
9 |
10 | const LayoutComponent = () => {
11 | return (
12 |
13 |
14 |
15 |
16 |
19 | Loading...
20 |
21 | }
22 | >
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default LayoutComponent;
33 |
--------------------------------------------------------------------------------
/src/pages/not-found.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
3 | /* eslint-disable @typescript-eslint/no-unsafe-call */
4 | import styled from 'styled-components';
5 |
6 | const NotFound = () => {
7 | return (
8 |
9 |
10 | 4
11 |
12 | 😢
13 |
14 | 4
15 |
16 | Page not found.
17 |
18 | );
19 | };
20 |
21 | export default NotFound;
22 |
23 | const Wrapper = styled.div`
24 | height: 100vh;
25 | display: flex;
26 | align-items: center;
27 | justify-content: center;
28 | flex-direction: column;
29 | min-height: 320px;
30 | `;
31 |
32 | const Title = styled.p`
33 | margin-top: -8vh;
34 | font-weight: bold;
35 | font-size: 3.375rem;
36 |
37 | span {
38 | font-size: 3.125rem;
39 | }
40 | `;
41 |
42 | export const P = styled.p`
43 | font-size: 1rem;
44 | line-height: 1.5;
45 | margin: 0.625rem 0 1.5rem 0;
46 | `;
47 |
--------------------------------------------------------------------------------
/src/provider/query-provider.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode, useState } from 'react';
2 |
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4 | import { AxiosError } from 'axios';
5 |
6 | import { useToast } from '@/hooks';
7 |
8 | type Props = {
9 | children: ReactNode;
10 | };
11 |
12 | const QueryProvider: FC = ({ children }) => {
13 | const { toast } = useToast();
14 | const [queryClient] = useState(
15 | () =>
16 | new QueryClient({
17 | defaultOptions: {
18 | queries: {
19 | refetchOnWindowFocus: false,
20 | retry: false,
21 | onError: (error: unknown) => {
22 | void toast.error(
23 | `Something went wrong: ${
24 | (
25 | error as AxiosError<{
26 | message: string;
27 | }>
28 | )?.response?.data.message || 'unknown'
29 | }`,
30 | );
31 | },
32 | },
33 | },
34 | }),
35 | );
36 |
37 | return (
38 | {children}
39 | );
40 | };
41 |
42 | export default QueryProvider;
43 |
--------------------------------------------------------------------------------
/src/layout/header/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 |
3 | import { ButtonTheme } from '@/components';
4 | import { navList } from '@/data/constant/navs';
5 | import { useActiveMenu } from '@/hooks';
6 |
7 | const HeaderComponent = () => {
8 | const { checkActive } = useActiveMenu();
9 |
10 | return (
11 |
12 |
13 |
14 |
15 | {navList.map((item) => (
16 |
17 |
24 | {item.label}
25 |
26 |
27 | ))}
28 |
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default HeaderComponent;
37 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/apis/axios-client.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
2 | /* eslint-disable @typescript-eslint/no-unsafe-return */
3 | import axios, { AxiosError } from 'axios';
4 |
5 | import { LOGIN_PATH } from '@/data';
6 |
7 | const axiosClient = axios.create({
8 | baseURL: import.meta.env.VITE_API_URL,
9 | headers: {
10 | 'Content-Type': 'application/json',
11 | },
12 | });
13 | // Interceptors
14 | // Add a request interceptor
15 | axiosClient.interceptors.request.use(
16 | function (config) {
17 | // Do something before request is sent
18 | const token = localStorage.getItem('token');
19 | if (token) {
20 | config.headers['Authorization'] = 'Bearer ' + token;
21 | }
22 |
23 | return config;
24 | },
25 | function (error) {
26 | // Do something with request error
27 | return Promise.reject(error);
28 | },
29 | );
30 |
31 | // Add a response interceptor
32 | axiosClient.interceptors.response.use(
33 | function (response) {
34 | // Any status code that lie within the range of 2xx cause this function to trigger
35 | // Do something with response data
36 | return response.data;
37 | },
38 | function (error: AxiosError) {
39 | // Any status codes that falls outside the range of 2xx cause this function to trigger
40 | // Do something with response error
41 | if (error.response?.status === 401) {
42 | // clear token ...
43 | localStorage.removeItem('token');
44 | window.location.replace(LOGIN_PATH);
45 | }
46 |
47 | return Promise.reject(error);
48 | },
49 | );
50 |
51 | export default axiosClient;
52 |
--------------------------------------------------------------------------------
/src/provider/theme-config-provider.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement, useCallback, useEffect } from 'react';
2 |
3 | import CssBaseline from '@mui/material/CssBaseline';
4 | import { ThemeProvider, createTheme } from '@mui/material/styles';
5 |
6 | import { useThemeStore } from '@/hooks';
7 |
8 | type Props = {
9 | children: ReactElement;
10 | };
11 |
12 | function LayoutConfigProvider({ children }: Props) {
13 | const theme = useThemeStore((state) => state.theme);
14 | const setTheme = useThemeStore((state) => state.setTheme);
15 |
16 | const setThemeState = useCallback(
17 | (dark = true) => {
18 | setTheme({
19 | theme: dark ? 'dark' : 'light',
20 | });
21 | },
22 | [setTheme],
23 | );
24 |
25 | const matchMode = useCallback(
26 | (e: MediaQueryListEvent) => {
27 | setThemeState(e.matches);
28 | },
29 | [setThemeState],
30 | );
31 |
32 | useEffect(() => {
33 | const root = window.document.documentElement;
34 |
35 | root.classList.remove('light', 'dark');
36 | setThemeState(theme === 'dark');
37 |
38 | // watch system theme change
39 | if (!localStorage.getItem('theme')) {
40 | const mql = window.matchMedia('(prefers-color-scheme: dark)');
41 |
42 | mql.addEventListener('change', matchMode);
43 | }
44 |
45 | root.classList.add(theme);
46 | }, [matchMode, setThemeState, theme]);
47 |
48 | return (
49 |
56 | {' '}
57 |
58 | {children}
59 |
60 | );
61 | }
62 |
63 | export default LayoutConfigProvider;
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 346.8 77.2% 49.8%;
14 | --primary-foreground: 355.7 100% 97.3%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 346.8 77.2% 49.8%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 20 14.3% 4.1%;
31 | --foreground: 0 0% 95%;
32 | --card: 24 9.8% 10%;
33 | --card-foreground: 0 0% 95%;
34 | --popover: 0 0% 9%;
35 | --popover-foreground: 0 0% 95%;
36 | --primary: 346.8 77.2% 49.8%;
37 | --primary-foreground: 355.7 100% 97.3%;
38 | --secondary: 240 3.7% 15.9%;
39 | --secondary-foreground: 0 0% 98%;
40 | --muted: 0 0% 15%;
41 | --muted-foreground: 240 5% 64.9%;
42 | --accent: 12 6.5% 15.1%;
43 | --accent-foreground: 0 0% 98%;
44 | --destructive: 0 62.8% 30.6%;
45 | --destructive-foreground: 0 85.7% 97.3%;
46 | --border: 240 3.7% 15.9%;
47 | --input: 240 3.7% 15.9%;
48 | --ring: 346.8 77.2% 49.8%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/features/todo/hooks/use-todo-query.ts:
--------------------------------------------------------------------------------
1 | import { createQueryKeys } from '@lukemorales/query-key-factory';
2 | import { useMutation, useQuery } from '@tanstack/react-query';
3 | import { toast } from 'react-hot-toast';
4 |
5 | import todoApi from '../services/todo.api';
6 | import { ResponseData, TodoData } from '../services/types';
7 | import { QueryOptions } from '@/ts/types';
8 |
9 | const todos = createQueryKeys('todos', {
10 | list: () => ({
11 | queryKey: ['todos'],
12 | queryFn: () => todoApi.getList(),
13 | }),
14 | detail: (id: string) => ({
15 | queryKey: [id],
16 | queryFn: () => todoApi.getDetail(id),
17 | }),
18 | });
19 |
20 | export const useTodoListQuery = (
21 | options: QueryOptions = {},
22 | ) => {
23 | return useQuery({
24 | ...todos.list(),
25 | keepPreviousData: true,
26 | ...options,
27 | });
28 | };
29 |
30 | export const useTodoDetailQuery = (
31 | id: string,
32 | options: QueryOptions = {},
33 | ) => {
34 | return useQuery({
35 | ...todos.detail(id),
36 | ...options,
37 | });
38 | };
39 |
40 | export const useAddTodoMutation = () => {
41 | return useMutation({
42 | mutationFn: todoApi.add,
43 | onSuccess: () => {
44 | void toast.success('Create new Todo successfully');
45 | },
46 | onError: () => {
47 | void toast.error('Create new Todo failed');
48 | },
49 | });
50 | };
51 |
52 | export const useUpdateTodoMutation = () => {
53 | return useMutation({
54 | mutationFn: todoApi.update,
55 | onSuccess: () => {
56 | void toast.success('Update Todo successfully');
57 | },
58 | onError: () => {
59 | void toast.error('Update Todo failed');
60 | },
61 | });
62 | };
63 |
64 | export const useDeleteTodoMutation = () => {
65 | return useMutation({
66 | mutationFn: todoApi.delete,
67 | onSuccess: () => {
68 | void toast.success('Delete Todo successfully');
69 | },
70 | onError: () => {
71 | void toast.error('Delete Todo failed');
72 | },
73 | });
74 | };
75 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ['class'],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: '2rem',
14 | screens: {
15 | '2xl': '1400px',
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: 'hsl(var(--border))',
21 | input: 'hsl(var(--input))',
22 | ring: 'hsl(var(--ring))',
23 | background: 'hsl(var(--background))',
24 | foreground: 'hsl(var(--foreground))',
25 | primary: {
26 | DEFAULT: 'hsl(var(--primary))',
27 | foreground: 'hsl(var(--primary-foreground))',
28 | },
29 | secondary: {
30 | DEFAULT: 'hsl(var(--secondary))',
31 | foreground: 'hsl(var(--secondary-foreground))',
32 | },
33 | destructive: {
34 | DEFAULT: 'hsl(var(--destructive))',
35 | foreground: 'hsl(var(--destructive-foreground))',
36 | },
37 | muted: {
38 | DEFAULT: 'hsl(var(--muted))',
39 | foreground: 'hsl(var(--muted-foreground))',
40 | },
41 | accent: {
42 | DEFAULT: 'hsl(var(--accent))',
43 | foreground: 'hsl(var(--accent-foreground))',
44 | },
45 | popover: {
46 | DEFAULT: 'hsl(var(--popover))',
47 | foreground: 'hsl(var(--popover-foreground))',
48 | },
49 | card: {
50 | DEFAULT: 'hsl(var(--card))',
51 | foreground: 'hsl(var(--card-foreground))',
52 | },
53 | },
54 | borderRadius: {
55 | lg: 'var(--radius)',
56 | md: 'calc(var(--radius) - 2px)',
57 | sm: 'calc(var(--radius) - 4px)',
58 | },
59 | keyframes: {
60 | 'accordion-down': {
61 | from: { height: 0 },
62 | to: { height: 'var(--radix-accordion-content-height)' },
63 | },
64 | 'accordion-up': {
65 | from: { height: 'var(--radix-accordion-content-height)' },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | 'accordion-down': 'accordion-down 0.2s ease-out',
71 | 'accordion-up': 'accordion-up 0.2s ease-out',
72 | },
73 | },
74 | },
75 | plugins: [require('tailwindcss-animate')],
76 | };
77 |
--------------------------------------------------------------------------------
/src/data/constant/navs.tsx:
--------------------------------------------------------------------------------
1 | import { cloneDeep } from 'lodash';
2 | import { Link } from 'react-router-dom';
3 |
4 | import { HOME_PATH, USER_PATH, TODO_PATH } from './path';
5 | import { TypeNavs, TypeRoutes } from './type-navs';
6 | import { Home, Users, Todos } from '@/pages';
7 | import { capitalizeFirstLetter } from '@/utils';
8 |
9 | const navs: TypeNavs[] = [
10 | {
11 | key: HOME_PATH,
12 | label: 'home',
13 | element: ,
14 | },
15 | {
16 | key: USER_PATH,
17 | label: 'user',
18 | element: ,
19 | },
20 | {
21 | key: TODO_PATH,
22 | label: 'todo',
23 | element: ,
24 | },
25 | ];
26 |
27 | const getRoutes = (arr: TypeRoutes[], nav: TypeNavs, basePath = '') => {
28 | if (nav.children) {
29 | for (const n of nav.children) {
30 | getRoutes(arr, n, basePath + nav.key);
31 | }
32 | }
33 | if (!nav.element) return;
34 |
35 | arr.push({
36 | path: basePath + nav.key,
37 | // element: nav.element && (
38 | // {nav.element}
39 | // ),
40 | element: nav.element,
41 | });
42 |
43 | return arr;
44 | };
45 |
46 | const addLink = (nav: TypeNavs, path: string) => {
47 | return nav.children ? (
48 | capitalizeFirstLetter(nav.label as string)
49 | ) : (
50 | {capitalizeFirstLetter(nav.label as string)}
51 | );
52 | };
53 |
54 | const getShowNavigation = (
55 | nav: TypeNavs,
56 | basePath = '',
57 | ): TypeNavs | undefined => {
58 | if (!nav.label) return;
59 | if (nav.children) {
60 | const arr: TypeNavs[] = [];
61 | for (const n of nav.children) {
62 | const formatN = getShowNavigation(n, basePath + nav.key);
63 | if (formatN) arr.push(formatN);
64 | }
65 |
66 | nav.children = arr.length > 0 ? arr : undefined;
67 | }
68 |
69 | return {
70 | key: basePath + nav.key,
71 | label: addLink(nav, basePath + nav.key),
72 | children: nav.children,
73 | element: nav.element,
74 | };
75 | };
76 |
77 | const menuList: TypeNavs[] = [];
78 | const routeList: TypeRoutes[] = [];
79 | const navList: TypeNavs[] = navs.map((nav) => ({
80 | key: nav.key,
81 | label: nav.label,
82 | }));
83 |
84 | for (const nav of navs) {
85 | const nav1 = cloneDeep(nav);
86 | const n = getShowNavigation(nav1);
87 | n && menuList.push(n);
88 |
89 | const nav2 = cloneDeep(nav);
90 | getRoutes(routeList, nav2);
91 | }
92 |
93 | export { routeList, menuList, navList };
94 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | node: true,
4 | browser: true,
5 | es6: true,
6 | },
7 | ignorePatterns: ['./tsconfig.json'],
8 | extends: [
9 | 'eslint:recommended',
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:jsx-a11y/recommended',
12 | 'plugin:react/recommended',
13 | 'plugin:prettier/recommended',
14 | 'plugin:testing-library/react',
15 | 'plugin:react-hooks/recommended',
16 | 'plugin:prettier/recommended',
17 | 'plugin:@typescript-eslint/recommended-requiring-type-checking',
18 | ],
19 | overrides: [
20 | {
21 | env: {
22 | node: true,
23 | },
24 | files: ['.eslintrc.{js,cjs}'],
25 | parserOptions: {
26 | sourceType: 'script',
27 | },
28 | },
29 | ],
30 | parser: '@typescript-eslint/parser',
31 | parserOptions: {
32 | ecmaVersion: 'latest',
33 | sourceType: 'module',
34 | project: ['./tsconfig.json', './.eslintrc.cjs'],
35 | tsconfigRootDir: __dirname,
36 | },
37 | plugins: ['@typescript-eslint', 'import', 'simple-import-sort'],
38 | rules: {
39 | 'no-console': 2,
40 | 'react-hooks/rules-of-hooks': 2,
41 | 'react-hooks/exhaustive-deps': 2,
42 | 'react/no-array-index-key': 2,
43 | 'react/react-in-jsx-scope': 'off',
44 | 'react/prop-types': 'off',
45 | '@typescript-eslint/no-non-null-assertion': 0,
46 | 'no-unused-vars': 'off',
47 | '@typescript-eslint/no-misused-promises': 'off',
48 | '@typescript-eslint/no-unused-vars': [
49 | 2,
50 | {
51 | argsIgnorePattern: '^_',
52 | varsIgnorePattern: '^_',
53 | ignoreRestSiblings: true,
54 | },
55 | ],
56 | 'prettier/prettier': ['off', { singleQuote: true }],
57 | 'no-restricted-imports': [
58 | 2,
59 | {
60 | patterns: [
61 | '@/features/*/*',
62 | '@/components/*',
63 | '@/hooks/*',
64 | '@/utils/*',
65 | '@/ts/*/*',
66 | ],
67 | },
68 | ],
69 | 'import/order': [
70 | 'error',
71 | {
72 | groups: ['builtin', 'external', 'internal'],
73 | pathGroups: [
74 | {
75 | pattern: 'react',
76 | group: 'external',
77 | position: 'before',
78 | },
79 | ],
80 | pathGroupsExcludedImportTypes: ['react'],
81 | 'newlines-between': 'always',
82 | alphabetize: {
83 | order: 'asc',
84 | caseInsensitive: true,
85 | },
86 | },
87 | ],
88 | 'no-implied-eval': 'off',
89 | 'require-await': 'off',
90 | },
91 | settings: {
92 | react: {
93 | version: 'detect',
94 | },
95 | },
96 | };
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-template-for-starter",
3 | "author": "NuiCoder",
4 | "private": true,
5 | "version": "0.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "vite",
9 | "preinstall": "npx only-allow pnpm",
10 | "build": "tsc && vite build",
11 | "lint": "eslint . --ext js,ts,tsx",
12 | "format": "prettier --write **/*.{js,ts,tsx} && eslint . --ext js,ts,tsx --fix",
13 | "preview": "vite preview",
14 | "postinstall": "bash postinstall.sh",
15 | "prepare": "husky install"
16 | },
17 | "husky": {
18 | "hooks": {
19 | "pre-commint": "lint-staged"
20 | }
21 | },
22 | "lint-staged": {
23 | "src/**/*": [
24 | "eslint --ext ./src --fix"
25 | ],
26 | "./src/**": [
27 | "prettier --write ."
28 | ]
29 | },
30 | "resolutions": {
31 | "styled-components": "^5"
32 | },
33 | "dependencies": {
34 | "@emotion/babel-plugin": "^11.11.0",
35 | "@emotion/react": "^11.11.1",
36 | "@emotion/styled": "^11.11.0",
37 | "@hookform/resolvers": "^3.3.1",
38 | "@lukemorales/query-key-factory": "^1.3.2",
39 | "@mui/icons-material": "^5.14.18",
40 | "@mui/material": "^5.14.17",
41 | "@tanstack/react-query": "^4.35.3",
42 | "@types/styled-components": "^5.1.27",
43 | "axios": "^1.5.0",
44 | "class-variance-authority": "^0.7.0",
45 | "clsx": "^2.0.0",
46 | "lodash": "^4.17.21",
47 | "lucide-react": "^0.279.0",
48 | "react": "^18.2.0",
49 | "react-dom": "^18.2.0",
50 | "react-error-boundary": "^4.0.11",
51 | "react-hook-form": "^7.47.0",
52 | "react-hot-toast": "^2.4.1",
53 | "react-icons": "^4.11.0",
54 | "react-router-dom": "^6.16.0",
55 | "styled-components": "^6.0.8",
56 | "tailwind-merge": "^1.14.0",
57 | "tailwindcss-animate": "^1.0.7",
58 | "zod": "^3.22.2",
59 | "zustand": "^4.4.1"
60 | },
61 | "devDependencies": {
62 | "@optimize-lodash/rollup-plugin": "^4.0.4",
63 | "@types/lodash": "^4.14.198",
64 | "@types/node": "^20.6.2",
65 | "@types/react": "^18.2.15",
66 | "@types/react-dom": "^18.2.7",
67 | "@typescript-eslint/eslint-plugin": "^6.7.2",
68 | "@typescript-eslint/parser": "^6.7.2",
69 | "@vitejs/plugin-react": "^4.0.3",
70 | "autoprefixer": "^10.4.15",
71 | "eslint": "^8.45.0",
72 | "eslint-config-prettier": "^9.0.0",
73 | "eslint-plugin-import": "^2.28.1",
74 | "eslint-plugin-jsx-a11y": "^6.7.1",
75 | "eslint-plugin-prettier": "^5.0.0",
76 | "eslint-plugin-react": "^7.33.2",
77 | "eslint-plugin-react-hooks": "^4.6.0",
78 | "eslint-plugin-react-refresh": "^0.4.3",
79 | "eslint-plugin-simple-import-sort": "^10.0.0",
80 | "eslint-plugin-testing-library": "^6.0.1",
81 | "husky": "^8.0.3",
82 | "lint-staged": "^14.0.1",
83 | "postcss": "^8.4.30",
84 | "prettier": "^3.0.3",
85 | "tailwindcss": "^3.3.3",
86 | "typescript": "^5.0.2",
87 | "vite": "^4.4.5"
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/common/button/button-theme.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles';
2 | import Switch, { SwitchProps } from '@mui/material/Switch';
3 |
4 | import { useThemeStore } from '@/hooks';
5 |
6 | const MaterialUISwitch = styled((props: SwitchProps) => )(
7 | ({ theme }) => ({
8 | width: 62,
9 | height: 34,
10 | padding: 7,
11 | '& .MuiSwitch-switchBase': {
12 | margin: 1,
13 | padding: 0,
14 | transform: 'translateX(6px)',
15 | '&.Mui-checked': {
16 | color: '#fff',
17 | transform: 'translateX(22px)',
18 | '& .MuiSwitch-thumb:before': {
19 | backgroundImage: `url('data:image/svg+xml;utf8,')`,
22 | },
23 | '& + .MuiSwitch-track': {
24 | opacity: 1,
25 | backgroundColor:
26 | theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
27 | },
28 | },
29 | },
30 | '& .MuiSwitch-thumb': {
31 | backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c',
32 | width: 32,
33 | height: 32,
34 | '&:before': {
35 | content: "''",
36 | position: 'absolute',
37 | width: '100%',
38 | height: '100%',
39 | left: 0,
40 | top: 0,
41 | backgroundRepeat: 'no-repeat',
42 | backgroundPosition: 'center',
43 | backgroundImage: `url('data:image/svg+xml;utf8,')`,
46 | },
47 | },
48 | '& .MuiSwitch-track': {
49 | opacity: 1,
50 | backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
51 | borderRadius: 20 / 2,
52 | },
53 | }),
54 | );
55 |
56 | export function ButtonTheme() {
57 | const setTheme = useThemeStore((state) => state.setTheme);
58 | return (
59 |
63 | e.target.checked
64 | ? setTheme({ theme: 'dark' })
65 | : setTheme({ theme: 'light' })
66 | }
67 | />
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Boilerplate and Starter for React JS 18+, Material-UI, Tailwind CSS v3.3 and Typescript
2 |
3 |
4 |
5 |
6 |
7 | 🚀🚀🚀 Boilerplate and Starter for React.js, Material-UI, Tailwind CSS and TypeScript ⚡️ Made with developer experience first: React.js, TypeScript, Axios, ESLint, Prettier, Husky, Lint-Staged, VSCode, PostCSS, Tailwind CSS.
8 |
9 | Clone this project and use it to create your own [React.js](https://nextjs.org) project.
10 |
11 | ### Features
12 |
13 | - ⚡ [React.js](https://react.dev/)
14 | - ⚡ [Material-UI](https://mui.com/)
15 | - 🔥 Type checking [TypeScript](https://www.typescriptlang.org)
16 | - 💎 Integrate with [Tailwind CSS](https://tailwindcss.com)
17 | - ✅ Strict Mode for TypeScript and React 18
18 | - 📏 Linter with [ESLint](https://eslint.org) (default NextJS, NextJS Core Web Vitals, Tailwind CSS and Airbnb configuration)
19 | - 💖 Code Formatter with [Prettier](https://prettier.io)
20 | - 🦊 Husky for Git Hooks
21 | - 🚫 Lint-staged for running linters on Git staged files
22 | - 🗂 VSCode configuration: Debug, Settings, Tasks and extension for PostCSS, ESLint, Prettier, TypeScript, Jest
23 |
24 | ### Requirements
25 |
26 | - Node.js 16+ and pnpm
27 |
28 | ### Getting started
29 |
30 | Run the following command on your local environment:
31 |
32 | ```shell
33 | git clone --depth=1 https://github.com/sonht113/react-boilerplate-for-starter.git
34 | cd my-project-name
35 | pnpm install
36 | ```
37 |
38 | Then, you can run locally in development mode with live reload:
39 |
40 | ```shell
41 | pnpm run dev
42 | ```
43 |
44 | Open http://localhost:5173 with your favorite browser to see your project.
45 |
46 | ```shell
47 | .
48 | ├── README.md # README file
49 | ├── .github # GitHub folder
50 | ├── .husky # Husky configuration
51 | ├── public # Public assets folder
52 | ├── src
53 | │ ├── apis # Common apis folder
54 | │ ├── components # Component folder
55 | │ ├── data # Data constants JS Pages
56 | │ └── features # Features folder
57 | │ ├── hooks # Hooks customs folder
58 | │ ├── layout # Layout Pages
59 | │ └── pages # React JS Pages
60 | │ ├── provider # Provider folder
61 | │ └── routes # Routes folder
62 | │ ├── ts # Type and Enum folder
63 | │ ├── utils # Utility functions
64 | ├── tailwind.config.js # Tailwind CSS configuration
65 | └── tsconfig.json # TypeScript configuration
66 | ```
67 |
68 | ### Customization
69 |
70 | - `src/index.css`: your CSS file using Tailwind CSS
71 | - `src/main.tsx`: default theme
72 |
73 | You have access to the whole code source if you need further customization. The provided code is only example for you to start your project. The sky is the limit 🚀.
74 |
75 | ---
76 |
77 | Made with ♥ by [TrongSon](https://www.facebook.com/profile.php?id=100032736788526&locale=vi_VN)
78 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/todos/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useMemo } from 'react';
2 |
3 | import AddIcon from '@mui/icons-material/Add';
4 | import {
5 | Backdrop,
6 | Box,
7 | Button,
8 | Card,
9 | CardContent,
10 | CardHeader,
11 | Checkbox,
12 | CircularProgress,
13 | FormControl,
14 | FormControlLabel,
15 | FormHelperText,
16 | IconButton,
17 | Modal,
18 | TextField,
19 | Typography,
20 | } from '@mui/material';
21 | import { Controller, useForm } from 'react-hook-form';
22 | import { FiTrash2 } from 'react-icons/fi';
23 |
24 | import {
25 | TodoDataMutation,
26 | useAddTodoMutation,
27 | useDeleteTodoMutation,
28 | useTodoListQuery,
29 | useUpdateTodoMutation,
30 | } from '@/features/todo';
31 | import { useModalStore } from '@/hooks';
32 |
33 | type Input = {
34 | todoName: string;
35 | };
36 |
37 | const Todos: FC = () => {
38 | const {
39 | control,
40 | handleSubmit,
41 | reset,
42 | formState: { errors },
43 | } = useForm();
44 |
45 | const isOpen = useModalStore((state) => state.isOpen);
46 | const open = useModalStore((state) => state.open);
47 | const close = useModalStore((state) => state.close);
48 |
49 | const { data: todos, refetch, isLoading: loadingFetch } = useTodoListQuery();
50 | const { mutate: addTodo, isLoading: loadingCreate } = useAddTodoMutation();
51 | const { mutate: updateTodo, isLoading: loadingUpdate } =
52 | useUpdateTodoMutation();
53 | const { mutate: deleteTodo, isLoading: loadingDelete } =
54 | useDeleteTodoMutation();
55 |
56 | const todoList = useMemo(() => {
57 | if(todos && todos.data) {
58 | return todos?.data.sort((a, b) => {
59 | if (a.isComplete && !b.isComplete) {
60 | return 1; // Move 'a' to the end
61 | } else if (!a.isComplete && b.isComplete) {
62 | return -1; // Keep 'a' before 'b'
63 | } else {
64 | return 0; // Maintain the order of other elements
65 | }
66 | });
67 | }
68 | }, [todos]);
69 |
70 | const handleCreateTodo = (body: TodoDataMutation) => {
71 | return addTodo(body, {
72 | onSuccess: () => {
73 | void refetch();
74 | void reset({ todoName: '' });
75 | void close();
76 | },
77 | });
78 | };
79 |
80 | const handleUpdateTodo = (body: { id: string; data: TodoDataMutation }) => {
81 | return updateTodo(body, {
82 | onSuccess: () => {
83 | void refetch();
84 | },
85 | });
86 | };
87 |
88 | const handleDeleteTodo = (id: string) => {
89 | return deleteTodo(id, {
90 | onSuccess: () => {
91 | void refetch();
92 | },
93 | });
94 | };
95 |
96 | return (
97 |
98 | theme.zIndex.drawer + 1 }}
100 | open={loadingDelete || loadingUpdate}
101 | >
102 |
103 |
104 |
111 | <>
112 | theme.zIndex.drawer + 1 }}
114 | open={loadingCreate}
115 | >
116 |
117 |
118 |
123 | (
127 |
128 |
134 | {errors.todoName && (
135 |
136 | {errors.todoName.message}
137 |
138 | )}
139 |
140 | )}
141 | name="todoName"
142 | />
143 |
144 |
147 |
148 |
149 | >
150 |
151 |
152 |
156 |
157 |
158 | }
159 | />
160 |
161 | {loadingFetch && (
162 |
163 |
164 |
165 | )}
166 | {
167 | !loadingFetch &&!todoList && (
168 |
169 | No data
170 |
171 | )
172 | }
173 | {todoList &&
174 | todoList.map((todo) => (
175 |
176 |
183 | {todo.todoName}
184 |
185 | }
186 | control={
187 |
191 | handleUpdateTodo({
192 | id: todo._id,
193 | data: { isComplete: true },
194 | })
195 | }
196 | />
197 | }
198 | />
199 | handleDeleteTodo(todo._id)}>
200 |
201 |
202 |
203 | ))}
204 |
205 |
206 |
207 | );
208 | };
209 |
210 | export default Todos;
211 |
--------------------------------------------------------------------------------