feat: initial commit and template import

This commit is contained in:
Amadeu Jose Andrade Junior
2025-02-05 16:40:00 -03:00
commit 8123a94fcf
37 changed files with 12414 additions and 0 deletions

1
.env.development.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://api.example.com

1
.env.production.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://api.example.com

6
.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
node_modules/*
public
build
dist
dist-ssr

70
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,70 @@
module.exports = {
extends: [
'airbnb',
'prettier',
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'eslint-config-prettier',
],
plugins: ['react', 'react-hooks', 'prettier', 'jsx-a11y', 'import'],
settings: {
react: {
// Tells eslint-plugin-react to automatically detect the version of React to use.
version: 'detect',
},
// Tells eslint how to resolve imports
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
env: {
browser: true,
es2021: true,
jest: true,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
rules: {
'import/extensions': 'off',
'import/no-unresolved': 'off',
'react/prop-types': 'off',
'prettier/prettier': 'error',
'jsx-a11y/anchor-is-valid': 'off',
'react-hooks/exhaustive-deps': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }],
'no-unused-expressions': [
'error',
{ allowShortCircuit: true, allowTernary: true },
],
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
'linebreak-style': 'off',
'implicit-arrow-linebreak': 'off',
indent: 'off',
'object-curly-newline': 'off',
'operator-linebreak': 'off',
'no-confusing-arrow': 'off',
'function-paren-newline': 'off',
'no-mixed-operators': 'off',
'no-underscore-dangle': 'off',
'no-plusplus': 'off',
'no-param-reassign': 'off',
'no-unused-vars': 'off',
'react/react-in-jsx-scope': 'off',
},
};

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
/.cache
/.husky
.eslintignore
.gitignore
.git
LICENSE
build
dist

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true
}

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
## viterjs-template
JavaScript + React + Redux + Mui + Axios + ESLint + Prettier
![viterjs-template](https://i.ibb.co/xMMGs2Q/Screenshot-2023-07-07-105634.png)
### Getting Started
#### Clone the repo
```
npx degit emre-cil/viterjs-template my-app
```
```
cd my-app
```
#### Install Dependencies
```
pnpm install
```
#### Run
```
pnpm dev
```
#### Paths
Application using absolute paths
Example: '@/components/Counter/Counter';
if you don't want to use you can remove these lines from
> vite.config.js
```
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
},
```
> jsconfig.json
```
"paths": {
"@/*": ["./*"]
}
```
### Scripts
| Script | Description |
| ------------- | ---------------------------------- |
| pnpm dev | Runs the application. |
| pnpm build | Create builds for the application. |
| pnpm preview | Runs the Vite preview |
| pnpm lint | Display eslint errors |
| pnpm lint:fix | Fix the eslint errors |
| pnpm format | Runs prettier for all files |
| pnpm test | Run tests |
### Check List
```
```

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Dosis:wght@300;400;500;600&display=swap" rel="stylesheet" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

18
jsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ESNext",
"jsx": "react-jsx",
"allowJs": true,
"noEmit": true,
"resolveJsonModule": true,
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["node_modules", "**/node_modules/*", "dist", "**/dist/*"],
"include": ["src/**/*"]
}

10875
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

70
package.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "viterjs-template",
"version": "1.1.7",
"description": "React + Vite + Redux + MuI + axios + RRD + Prettier => Boilerplate",
"license": "MIT",
"author": "emrecil <info.emrecil@gmail.com>",
"keywords": [
"react",
"boilerplate",
"javaScript",
"starter",
"vite",
"redux",
"material-ui",
"axios",
"rrd",
"prettier"
],
"repository": {
"type": "git",
"url": "https://github.com/emre-cil/viterjs-template.git"
},
"bugs": {
"url": "https://github.com/emre-cil/viterjs-template/issues"
},
"homepage": "https://github.com/emre-cil/viterjs-template#readme",
"scripts": {
"dev": "vite --open",
"start": "vite --open",
"host": "vite --open --host",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
"format": "prettier \"src/**/*.{js,jsx,ts,tsx,css,scss}\" --write",
"format+lint": "prettier \"src/**/*.{js,jsx,ts,tsx,css,scss}\" --write && eslint src --ext .js,.jsx,.ts,.tsx --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:cover": "vitest run --coverage"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@reduxjs/toolkit": "^2.2.1",
"axios": "^1.6.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1"
},
"devDependencies": {
"@babel/core": "^7.23.9",
"@types/react": "^18.2.57",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react-swc": "^3.6.0",
"eslint": "^8.56.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.2.5",
"vite": "^5.1.4",
"vitest": "^1.3.1"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

15
src/App.jsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { CssBaseline } from '@mui/material';
import Routing from './routes/Routing';
function App() {
return (
<BrowserRouter>
<CssBaseline />
<Routing />
</BrowserRouter>
);
}
export default App;

36
src/app/api/apiSlice.js Normal file
View File

@@ -0,0 +1,36 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// import { setCredentials, logOut } from '../../features/user/userSlice';
const baseQuery = fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_URL,
prepareHeaders: (headers, { getState }) => {
const { token } = getState().auth;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
});
const baseQueryWithReauth = async (args, api, extraOptions) => {
const result = await baseQuery(args, api, extraOptions);
// ====================== TODO: Implement 401 unauthorized re-authentication ======================
// if (result?.error?.status === 401) {
// const refreshResult = await baseQuery('/refresh', api, extraOptions);
// if (refreshResult?.data) {
// const { user } = api.getState().auth;
// api.dispatch(setCredentials({ user, ...refreshResult.data }));
// result = await baseQuery(args, api, extraOptions);
// } else {
// api.dispatch(logOut());
// }
// }
return result;
};
const apiSlice = createApi({
baseQuery: baseQueryWithReauth,
endpoints: () => ({}),
});
export default apiSlice;

17
src/app/store.js Normal file
View File

@@ -0,0 +1,17 @@
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '@/features/counter/counterSlice';
import apiSlice from './api/apiSlice';
import userReducer from '@/features/user/userSlice';
const store = configureStore({
reducer: {
[apiSlice.reducerPath]: apiSlice.reducer,
counter: counterReducer,
user: userReducer,
},
middleware: (getdefaultMiddleware) =>
getdefaultMiddleware().concat(apiSlice.middleware),
devTools: true,
});
export default store;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Box } from '@mui/material';
import {
decrement,
increment,
incrementByAmount,
incrementAsync,
incrementIfOdd,
selectCount,
} from '@/features/counter/counterSlice';
import styles from './Counter.module.css';
function Counter() {
const count = useSelector(selectCount);
const dispatch = useDispatch();
const [incrementAmount, setIncrementAmount] = React.useState('2');
const incrementValue = Number(incrementAmount) || 0;
return (
<Box
className={styles.wrapper}
sx={{
py: 10,
px: 2,
mt: 3,
boxShadow: 3,
borderRadius: 5,
backgroundColor: 'grey.100',
}}
>
<div className={styles.row}>
<button
type="button"
className={styles.button}
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
<span className={styles.value}>{count}</span>
<button
type="button"
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
</div>
<div className={styles.row2}>
<input
className={styles.textbox}
type="number"
aria-label="Set increment amount"
value={incrementAmount}
onChange={(e) => setIncrementAmount(e.target.value)}
/>
<button
type="button"
className={styles.button}
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
type="button"
className={styles.asyncButton}
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
<button
type="button"
className={styles.button}
onClick={() => dispatch(incrementIfOdd(incrementValue))}
>
Add If Odd
</button>
</div>
</Box>
);
}
export default Counter;

View File

@@ -0,0 +1,97 @@
.row,
.row2 {
display: flex;
align-items: center;
justify-content: center;
}
@media screen and (max-width: 600px) {
.row2 {
flex-direction: column;
}
.row2 > button {
margin: 10px;
}
}
.row > button,
.row2 > button {
margin-left: 4px;
margin-right: 8px;
}
.row:not(:last-child) {
margin-bottom: 16px;
}
.value {
font-size: 78px;
padding-left: 16px;
padding-right: 16px;
margin-top: 2px;
color: #f9ed69;
font-family: 'Courier New', Courier, monospace;
}
.button {
appearance: none;
background: none;
font-size: 32px;
padding-left: 12px;
padding-right: 12px;
outline: none;
border: 2px solid transparent;
color: #fff;
padding-bottom: 4px;
cursor: pointer;
border-radius: 2px;
transition: all 0.15s;
border-radius: 5px;
box-shadow:
rgba(0, 0, 0, 0.12) 0px 1px 3px,
rgba(0, 0, 0, 0.24) 0px 1px 2px;
}
.textbox {
font-size: 32px;
color: #fff;
height: 3rem;
padding: 2px;
width: 64px;
text-align: center;
margin-right: 4px;
background-color: transparent;
outline: none;
border: none;
box-shadow:
rgba(0, 0, 0, 0.12) 0px 1px 3px,
rgba(0, 0, 0, 0.24) 0px 1px 2px;
border-radius: 5px;
}
.button:active {
backdrop-filter: blur(2px);
}
.asyncButton {
position: relative;
composes: button;
}
.asyncButton:after {
content: '';
background-color: rgba(112, 76, 182, 0.15);
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
opacity: 0;
transition:
width 1s linear,
opacity 0.5s ease 1s;
}
.asyncButton:active:after {
width: 0%;
opacity: 1;
transition: 0s;
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Stack, Box, Typography, IconButton } from '@mui/material';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import { useDispatch, useSelector } from 'react-redux';
import { changeMode, selectMode } from '@/features/user/userSlice';
function TemplateTester() {
const dispatch = useDispatch();
const mode = useSelector(selectMode);
const colors = [
{
type: 'primary',
colors: ['.light', '.main', '.dark'],
},
{
type: 'secondary',
colors: ['.light', '.main', '.dark'],
},
{
type: 'error',
colors: ['.light', '.main', '.dark'],
},
{
type: 'warning',
colors: ['.light', '.main', '.dark'],
},
{
type: 'info',
colors: ['.light', '.main', '.dark'],
},
{
type: 'success',
colors: ['.light', '.main', '.dark'],
},
{
type: 'grey',
colors: [
'.50',
'.100',
'.200',
'.300',
'.400',
'.500',
'.600',
'.700',
'.800',
'.900',
],
},
{
type: 'background',
colors: ['.default', '.paper', '.opposite', '.light'],
},
{
type: 'text',
colors: ['.primary', '.secondary', '.disabled'],
},
];
const typographies = [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'subtitle1',
'subtitle2',
'body1',
'body2',
'body3',
'body4',
'caption',
'button',
'overline',
];
const themeTypes = (type, func) => (
<Stack
sx={{
p: 2,
boxShadow: 3,
borderRadius: 5,
position: 'relative',
backgroundColor: 'grey.100',
}}
gap={2}
>
<Typography variant="h3">{type}</Typography> {func}
<IconButton
onClick={() => dispatch(changeMode())}
sx={{ position: 'absolute', top: 10, right: 10 }}
>
<Brightness4Icon
sx={{
transition: 'transform 0.4s',
transform: mode === 'dark' ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
/>
</IconButton>
</Stack>
);
const colorCards = colors.map((cat) => (
<Stack key={cat.type} gap={1}>
<Typography variant="h5">{cat.type}</Typography>
<Stack direction="row" flexWrap="wrap" gap={2}>
{cat.colors.map((color) => (
<Stack key={color}>
<Box
sx={{
boxShadow: 2,
width: { xs: 62, sm: 100, md: 125 },
height: { xs: 62, sm: 100, md: 125 },
backgroundColor: cat.type + color,
background: color === 'gradient' && cat.type + color,
borderRadius: 1,
p: 0.65,
'& p': {
fontSize: { xs: 10, sm: 14, md: 16 },
textShadow:
mode === 'dark' ? '0px 0px 10px #000' : '0px 0px 10px #fff',
},
}}
>
<Typography fontWeight="bold">{color}</Typography>
</Box>
</Stack>
))}
</Stack>
</Stack>
));
const typoCards = typographies.map((typo) => (
<Stack key={typo} gap={1}>
<Typography variant={typo}>{typo}</Typography>
</Stack>
));
return (
<Stack gap={5}>
{themeTypes('#Colors', colorCards)}
{themeTypes('#Typography', typoCards)}
</Stack>
);
}
export default TemplateTester;

View File

@@ -0,0 +1,7 @@
/* eslint-disable no-promise-executor-return */
// A mock function to mimic making an async request for data
export default function fetchCount(amount = 1) {
return new Promise((resolve) =>
setTimeout(() => resolve({ data: amount }), 500),
);
}

View File

@@ -0,0 +1,74 @@
/* eslint-disable no-param-reassign */
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import fetchCount from './counterAPI';
const initialState = {
value: 0,
status: 'idle',
};
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
'counter/fetchCount',
async (amount) => {
const response = await fetchCount(amount);
// The value we return becomes the `fulfilled` action payload
return response.data;
},
);
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
// The `extraReducers` field lets the slice handle actions defined elsewhere,
// including actions generated by createAsyncThunk or in other slices.
extraReducers: (builder) => {
builder
.addCase(incrementAsync.pending, (state) => {
state.status = 'loading';
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = 'idle';
state.value += action.payload;
});
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state) => state.counter.value;
// We can also write thunks by hand, which may contain both sync and async logic.
// Here's an example of conditionally dispatching actions based on current state.
export const incrementIfOdd = (amount) => (dispatch, getState) => {
const currentValue = selectCount(getState());
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount));
}
};
export default counterSlice.reducer;

View File

@@ -0,0 +1,39 @@
import apiSlice from '@/app/api/apiSlice';
export const userApiSlice = apiSlice.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation({
query: (body) => ({
url: '/login',
method: 'POST',
body,
credentials: 'include',
}),
}),
register: builder.mutation({
query: (body) => ({
url: '/register',
method: 'POST',
body,
}),
}),
logout: builder.mutation({
query: () => ({
url: '/logout',
method: 'POST',
}),
}),
refresh: builder.mutation({
query: () => ({
url: '/refresh',
method: 'POST',
withCredentials: true,
credentials: 'include',
}),
}),
}),
});
export const { useLoginMutation, useLogoutMutation, useRegisterMutation } =
userApiSlice;

View File

@@ -0,0 +1,56 @@
/* eslint-disable no-param-reassign */
/* eslint-disable no-nested-ternary */
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
token: null,
user: {
Id: import.meta.env.VITE_WEB_USER_ID,
FirstName: '',
LastName: '',
},
mode: localStorage.getItem('mode')
? localStorage.getItem('mode')
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light',
};
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setCredentials: (state, action) => {
state.token = action.payload.AccessToken;
state.user = action.payload.User;
},
logOut: (state) => {
state.user = { Id: import.meta.env.VITE_WEB_USER_ID };
state.token = null;
localStorage.removeItem('user');
},
setToken: (state, action) => {
state.token = action.payload;
},
changeMode: (state) => {
if (state.mode === 'light') {
state.mode = 'dark';
localStorage.setItem('mode', 'dark');
} else {
state.mode = 'light';
localStorage.setItem('mode', 'light');
}
},
},
});
export const { setCredentials, logOut, setToken, changeMode } =
userSlice.actions;
export const selectUser = (state) => state.user.user;
export const selectToken = (state) => state.user.token;
export const selectMode = (state) => state.user.mode;
export default userSlice.reducer;

13
src/hooks/useBoolean.js Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
function useBoolean(initialValue = false) {
const [value, setValue] = React.useState(initialValue);
const setTrue = React.useCallback(() => setValue(true), []);
const setFalse = React.useCallback(() => setValue(false), []);
const toggle = React.useCallback(() => setValue((x) => !x), []);
return [value, setTrue, setFalse, toggle];
}
export default useBoolean;

17
src/hooks/useDebounce.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;

1
src/main.css Normal file
View File

@@ -0,0 +1 @@
/* global style */

17
src/main.jsx Normal file
View File

@@ -0,0 +1,17 @@
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import React from 'react';
import store from './app/store';
import AppThemeProvider from './themes/AppThemeProvider';
import App from './App';
import './main.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}>
<AppThemeProvider>
<App />
</AppThemeProvider>
</Provider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,86 @@
/* eslint-disable import/prefer-default-export */
export const FormSX = {
p: 3,
zIndex: 1,
top: '50%',
left: '50%',
boxShadow: 6,
userSelect: 'none',
overflow: 'hidden',
position: 'absolute',
borderRadius: '10px',
transition: 'transform 0.3s',
width: { xs: '90%', sm: 400 },
transform: 'translate(-50%, -50%)',
img: {
p: '16px 32px',
},
button: {
height: '3rem',
color: 'white',
textShadow: '1px 1px 1px rgba(0, 0, 0, 0.6)',
},
a: {
fontWeight: '500',
lineHeight: '19px',
color: 'primary.main',
transition: 'color 0.3s',
textShadow: '1px 1px 1px rgba(0, 0, 0, 0.6)',
},
'@keyframes wawes': {
from: {
transform: 'rotate(0deg)',
},
to: {
transform: 'rotate(360deg)',
},
},
'&::before, &::after': {
content: '""',
zIndex: -1,
width: '600px',
height: '800px',
position: 'absolute',
borderRadius: '40% 45% 35% 40%',
},
'&::before': {
top: '-35%',
left: '75%',
animation: 'wawes 6s linear infinite',
background:
'linear-gradient(90deg, rgba(0, 101, 243, 0.2) 0%, rgba(0, 120, 255, 0.4) 100%)',
},
'&::after': {
top: '-30%',
left: '70%',
animation: 'wawes 8s linear infinite',
background:
'linear-gradient(90deg, rgba(0, 110, 255, 0.5) 0%, rgba(0, 120, 255, 0.3) 100%)',
},
'& span': {
fontSize: '0.75rem',
},
'& input': {
fontSize: '16px',
borderRadius: '5px',
},
'& label.Mui-focused ': {
pt: 0.2,
},
'& .MuiFormLabel-root': {
fontSize: '16px',
},
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: 'primary.main',
},
},
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Stack, useTheme, Typography } from '@mui/material';
import { FormSX } from './Auth.styles';
function AuthOutlet({ children, header }) {
const theme = useTheme();
return (
<form>
<Stack
gap={3}
sx={{
...FormSX,
border: `1px solid ${theme.palette.grey.border}`,
background: theme.palette.grey[50],
}}
>
{header ? (
<Typography textAlign="center" variant="h2">
{header}
</Typography>
) : (
<img
src={
theme.palette.mode === 'dark'
? 'https://picsum.photos/100/50'
: 'https://picsum.photos/200/300'
}
alt="logo"
/>
)}
{children}
</Stack>
</form>
);
}
export default AuthOutlet;

105
src/pages/Auth/Login.jsx Normal file
View File

@@ -0,0 +1,105 @@
import {
Box,
Stack,
Typography,
TextField,
InputAdornment,
Button,
Link,
IconButton,
} from '@mui/material';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { useNavigate } from 'react-router-dom';
import React from 'react';
import AuthOutlet from './AuthOutlet';
function Login() {
const email = React.useRef(null);
const password = React.useRef(null);
const navigate = useNavigate();
const [showPassword, setShowPassword] = React.useState(false);
const handleClickShowPassword = () => setShowPassword(!showPassword);
const handleMouseDownPassword = () => setShowPassword(!showPassword);
const loginHandler = async (e) => {
e.preventDefault();
const user = email.current.value.replace(/\s+/g, '');
const pwd = password.current.value.replace(/\s+/g, '');
if (user === '') {
// Please enter your email.
email.current.focus();
} else if (pwd === '') {
// 'Please enter your password.'
password.current.focus();
} else {
// do login stuff
}
};
/** Focus email input when component mounted. */
React.useEffect(() => {
email.current.focus();
}, []);
return (
<AuthOutlet>
<TextField
inputRef={email}
type="email"
label="E-mail"
variant="outlined"
autoComplete="off"
/>
<Stack gap={1}>
<TextField
inputRef={password}
type={showPassword ? 'text' : 'password'}
label="Password"
variant="outlined"
sx={{ '& .MuiInputBase-root ': { pr: '4px' } }}
autoComplete="new-password"
InputProps={{
// <-- This is where the toggle button sis added.
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
),
}}
/>
<Link
variant="body2"
textAlign="right"
onClick={() => navigate('/forgot-password')}
>
Forgot password?
</Link>
<Button variant="contained" onClick={loginHandler}>
Sign in
</Button>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Typography variant="body2" component="p">
Dont you have an account?
</Typography>
<Link
variant="body2"
sx={{ display: 'inline', ml: 1 }}
onClick={() => navigate('/register')}
>
Register
</Link>
</Box>
</AuthOutlet>
);
}
export default Login;

121
src/pages/Auth/Register.jsx Normal file
View File

@@ -0,0 +1,121 @@
import React from 'react';
import { Box, Stack, Typography, TextField, Button, Link } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import AuthOutlet from './AuthOutlet';
function Register() {
const ad = React.useRef(null);
const soyad = React.useRef(null);
const email = React.useRef(null);
const password = React.useRef(null);
const passwordConf = React.useRef(null);
const navigate = useNavigate();
const registerHandler = async (e) => {
e.preventDefault();
const FirstName = ad.current.value.replace(/\s+/g, '');
const LastName = soyad.current.value.replace(/\s+/g, '');
const Email = email.current.value.replace(/\s+/g, '');
const Password = password.current.value.replace(/\s+/g, '');
const pwdConf = passwordConf.current.value.replace(/\s+/g, '');
if (FirstName === '') {
// 'Please enter name.'
ad.current.focus();
} else if (LastName === '') {
// 'Please enter surname.'
soyad.current.focus();
} else if (Email === '') {
// 'Please enter email.'
email.current.focus();
} else if (!/^[\w-.]+@([\w-]+\.)+[\w-]{1,20}$/.test(Email)) {
// 'Please enter real email.'
email.current.focus();
} else if (Password === '') {
// 'Please enter password.'
password.current.focus();
} else if (pwdConf === '') {
// 'Please enter password again.'
passwordConf.current.focus();
} else if (Password !== pwdConf) {
// 'Passwords do not match.'
password.current.focus();
} else if (Password.length < 6) {
// 'Password must be at least 6 characters.'
password.current.focus();
} else if (
!/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{6,}$/.test(Password)
) {
// 'Password must contain at least one uppercase letter, one lowercase letter and one number.',
password.current.focus();
} else {
// do register stuff
}
};
/** Focus name input when component mounted. */
React.useEffect(() => {
ad.current.focus();
}, []);
return (
<AuthOutlet>
<Stack direction="row" gap={3} sx={{ alignItems: 'center' }}>
<TextField
inputRef={ad}
label="Name"
type="text"
variant="outlined"
autoComplete="off"
/>
<TextField
inputRef={soyad}
label="Surname"
type="text"
variant="outlined"
autoComplete="off"
/>
</Stack>
<TextField
inputRef={email}
type="email"
label="E-mail"
variant="outlined"
autoComplete="off"
/>
<TextField
inputRef={password}
type="password"
autoComplete="new-password"
label="Password"
variant="outlined"
/>
<TextField
inputRef={passwordConf}
hidden
type="password"
autoComplete="new-password"
label="Password (again)"
variant="outlined"
/>
<Button variant="contained" onClick={registerHandler}>
Sign Up
</Button>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Typography variant="body2" component="p">
Alredy have an account?
</Typography>
<Link
variant="body2"
sx={{ display: 'inline', ml: 1 }}
onClick={() => navigate('/login')}
>
Sign In
</Link>
</Box>
</AuthOutlet>
);
}
export default Register;

23
src/pages/Home/Home.jsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Typography, Stack, Container } from '@mui/material';
import Counter from '@/components/Counter/Counter';
import TemplateTester from '@/components/TemplateTester/TemplateTester';
function Home() {
return (
<Container sx={{ py: 2, position: 'relative' }}>
<Stack gap={1} my={2}>
<Typography textAlign="center" variant="h2">
Viterjs-template
</Typography>
<Typography textAlign="center" variant="subtitle1">
React + Redux + MuI + Axios + ESlint + Prettier
</Typography>
</Stack>
<TemplateTester />
<Counter />
</Container>
);
}
export default Home;

17
src/routes/Routing.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Home from '@/pages/Home/Home';
import Login from '@/pages/Auth/Login';
import Register from '@/pages/Auth/Register';
function Routing() {
return (
<Routes>
<Route path="*" element={<Home />} />
<Route path="/login/*" element={<Login />} />
<Route path="/register/*" element={<Register />} />
</Routes>
);
}
export default Routing;

0
src/setupTests.js Normal file
View File

View File

@@ -0,0 +1,203 @@
import React from 'react';
import {
ThemeProvider,
createTheme,
responsiveFontSizes,
} from '@mui/material/styles';
import { useSelector } from 'react-redux';
import { selectMode } from '@/features/user/userSlice';
function AppThemeProvider({ children }) {
const mode = useSelector(selectMode);
const theme = responsiveFontSizes(
createTheme({
palette: {
mode,
primary: {
main: '#1c9c7c',
},
secondary: {
main: '#9DF3C4',
},
Ink: {
Darkest: '#000000',
Darker: '#222222',
Dark: '#303437',
Base: '#404446',
Light: '#6C7072',
Lighter: '#72777A',
},
Sky: {
Dark: '#979C9E',
Base: '#CDCFD0',
Light: '#E3E5E5',
Lighter: '#F2F4F5',
Lightest: '#F7F9FA',
White: '#FFFFFF',
},
Red: {
Darkest: '#6B0206',
Base: '#E8282B',
Light: '#F94739',
Lighter: '#FF9898',
Lightest: '#FFE5E5',
},
Green: {
Darkest: '#0A4C0A',
Base: '#0F8B0F',
Light: '#1EB01E',
Lighter: '#7FF77F',
Lightest: '#E5FFE5',
},
background: {
default: mode === 'dark' ? '#000000' : '#FCFBFA',
opposite: mode === 'dark' ? '#FCFBFA' : '#000000',
paper: mode === 'dark' ? '#131313' : '#FCFCFC',
},
text: {
primary: mode === 'dark' ? '#FFFFFF' : '#000000',
secondary: '#999999',
disabled: '#C3C1BD',
},
grey: {
50: mode === 'dark' ? 'hsl(0, 0%, 10%)' : 'hsl(0, 5%, 95%)',
100: mode === 'dark' ? 'hsl(0, 0%, 20%)' : 'hsl(0, 0%, 90%)',
200: mode === 'dark' ? 'hsl(0, 0%, 30%)' : 'hsl(0, 0%, 80%)',
300: mode === 'dark' ? 'hsl(0, 0%, 40%)' : 'hsl(0, 0%, 70%)',
400: mode === 'dark' ? 'hsl(0, 0%, 50%)' : 'hsl(0, 0%, 60%)',
500: mode === 'dark' ? 'hsl(0, 0%, 60%)' : 'hsl(0, 0%, 50%)',
600: mode === 'dark' ? 'hsl(0, 0%, 70%)' : 'hsl(0, 0%, 40%)',
700: mode === 'dark' ? 'hsl(0, 0%, 80%)' : 'hsl(0, 0%, 30%)',
800: mode === 'dark' ? 'hsl(0, 0%, 90%)' : 'hsl(0, 0%, 20%)',
900: mode === 'dark' ? 'hsl(0, 5%, 95%)' : 'hsl(0, 0%, 10%)',
},
gradient: {
bronze: 'linear-gradient(180deg, #9C6D3E 0%, #E8C8A9 100%)',
silver: 'linear-gradient(180deg, #808080 0%, #DFDFDF 100%)',
gold: 'linear-gradient(180deg, #A3873C 0%, #E3D294 100%)',
},
},
typography: {
fontFamily: 'sans-serif',
h1: {
fontSize: '26px',
fontWeight: '600',
// lineHeight: '33px',
},
h2: {
fontSize: '22px',
fontWeight: '600',
// lineHeight: '28px',
},
h3: {
fontSize: '20px',
fontWeight: '600',
// lineHeight: '25px',
},
h4: {
fontSize: '18px',
fontWeight: '600',
// lineHeight: '23px',
},
h5: {
fontSize: '16px',
fontWeight: '500',
// lineHeight: '20px',
},
CTA1: {
fontSize: '28px',
fontWeight: '500',
// lineHeight: '35px',
},
CTA2: {
fontSize: '18px',
fontWeight: '500',
// lineHeight: '23px',
},
CTA3: {
fontSize: '16px',
fontWeight: '400',
// lineHeight: '20px',
},
Body1: {
fontFamily: 'Lato, sans-serif',
fontSize: '14px',
fontWeight: '400',
// lineHeight: '18px',
},
Body2: {
fontFamily: 'Lato, sans-serif',
fontSize: '13px',
fontWeight: '400',
// lineHeight: '16px',
},
Body3: {
fontFamily: 'Lato, sans-serif',
fontSize: '12px',
fontWeight: '400',
// lineHeight: '14px',
},
Body1Medium: {
fontFamily: 'Lato, sans-serif',
fontSize: '14px',
fontWeight: '500',
// lineHeight: '17px',
},
Body1SemiBold: {
fontFamily: 'Lato, sans-serif',
fontSize: '14px',
fontWeight: '600',
// lineHeight: '17px',
},
body3: {
fontSize: '12px',
// lineHeight: '16px',
display: 'block',
},
body4: {
fontSize: '10px',
// lineHeight: '14px',
display: 'block',
},
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
// ---CSS BODY--- \\
},
},
},
MuiLink: {
styleOverrides: {
root: {
cursor: 'pointer',
textDecoration: 'none',
lineHeight: '16px',
transition: 'all 0.1s ease-in-out',
'&:hover': {
opacity: 0.8,
},
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
aspectRatio: '1/1',
},
},
},
},
}),
);
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}
export default AppThemeProvider;

25
vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
import * as path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
sourcemap: false,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
},
test: {
css: false,
include: ['src/**/__tests__/*'],
globals: true,
environment: 'jsdom',
setupFiles: 'src/setupTests.ts',
clearMocks: true,
},
});