| @@ -0,0 +1,31 @@ | |||
| { | |||
| "extends": ["react-app", "airbnb", "prettier"], | |||
| "settings": { | |||
| "import/resolver": { | |||
| "node": { | |||
| "paths": ["src"] | |||
| } | |||
| } | |||
| }, | |||
| "plugins": ["react", "react-hooks", "security"], | |||
| "rules": { | |||
| "react/jsx-filename-extension": "off", | |||
| "react/jsx-props-no-spreading": "off", | |||
| "react/button-has-type": "off", | |||
| "react/require-default-props": "off", | |||
| "import/no-extraneous-dependencies": "off", | |||
| "import/prefer-default-export": "off", | |||
| "consistent-return": "off", | |||
| "no-shadow": "off", | |||
| "no-use-before-define": "off", | |||
| "no-template-curly-in-string": "off", | |||
| "react-hooks/exhaustive-deps": "warn", | |||
| "prettier/prettier": [ | |||
| "error", | |||
| { | |||
| "endOfLine": "auto" | |||
| } | |||
| ] | |||
| } | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| { | |||
| "env": { | |||
| "browser": true, | |||
| "es2021": true | |||
| }, | |||
| "extends": ["eslint:recommended", "plugin:react/recommended"], | |||
| "settings": { | |||
| "import/resolver": { | |||
| "node": { | |||
| "paths": ["src"] | |||
| } | |||
| } | |||
| }, | |||
| "parserOptions": { | |||
| "ecmaFeatures": { | |||
| "jsx": true | |||
| }, | |||
| "ecmaVersion": 12, | |||
| "sourceType": "module" | |||
| }, | |||
| "plugins": ["react"], | |||
| "rules": {} | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||
| # dependencies | |||
| /node_modules | |||
| /.pnp | |||
| .pnp.js | |||
| # testing | |||
| /coverage | |||
| # production | |||
| /build | |||
| # misc | |||
| .DS_Store | |||
| .env.local | |||
| .env.development.local | |||
| .env.test.local | |||
| .env.production.local | |||
| npm-debug.log* | |||
| yarn-debug.log* | |||
| yarn-error.log* | |||
| .env | |||
| @@ -0,0 +1,36 @@ | |||
| FROM node:16-alpine | |||
| WORKDIR /app | |||
| COPY package*.json ./ | |||
| COPY src ./ | |||
| COPY public ./ | |||
| RUN yarn install | |||
| # Bundle app source | |||
| COPY . . | |||
| EXPOSE 3000 | |||
| CMD ["yarn", "start"] | |||
| ######################################################## | |||
| ################## BUILD VERSION ####################### | |||
| # Use a Node 16 base image | |||
| #FROM node:16-alpine | |||
| # Set the working directory to /app inside the container | |||
| #WORKDIR /app | |||
| # Copy app files | |||
| #COPY . . | |||
| # ==== BUILD ===== | |||
| # Install dependencies | |||
| #RUN yarn | |||
| # Build the app | |||
| #RUN yarn run build | |||
| # ==== RUN ======= | |||
| # Set the env to "production" | |||
| #ENV NODE_ENV production | |||
| # Expose the port on which the app will be running (3000 is the default that `serve` uses) | |||
| #EXPOSE 3000 | |||
| # Start the app | |||
| #CMD [ "npx", "serve", "build" ] | |||
| @@ -0,0 +1,70 @@ | |||
| # Getting Started with Create React App | |||
| This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). | |||
| ## Available Scripts | |||
| In the project directory, you can run: | |||
| ### `npm start` | |||
| Runs the app in the development mode.\ | |||
| Open [http://localhost:3000](http://localhost:3000) to view it in your browser. | |||
| The page will reload when you make changes.\ | |||
| You may also see any lint errors in the console. | |||
| ### `npm test` | |||
| Launches the test runner in the interactive watch mode.\ | |||
| See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. | |||
| ### `npm run build` | |||
| Builds the app for production to the `build` folder.\ | |||
| It correctly bundles React in production mode and optimizes the build for the best performance. | |||
| The build is minified and the filenames include the hashes.\ | |||
| Your app is ready to be deployed! | |||
| See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. | |||
| ### `npm run eject` | |||
| **Note: this is a one-way operation. Once you `eject`, you can't go back!** | |||
| If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. | |||
| Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. | |||
| You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. | |||
| ## Learn More | |||
| You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). | |||
| To learn React, check out the [React documentation](https://reactjs.org/). | |||
| ### Code Splitting | |||
| This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) | |||
| ### Analyzing the Bundle Size | |||
| This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) | |||
| ### Making a Progressive Web App | |||
| This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) | |||
| ### Advanced Configuration | |||
| This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) | |||
| ### Deployment | |||
| This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) | |||
| ### `npm run build` fails to minify | |||
| This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) | |||
| @@ -0,0 +1,15 @@ | |||
| const faker = require('faker'); | |||
| module.exports = () => { | |||
| const items = []; | |||
| for (let id = 1; id <= 500; id++) { | |||
| items.push({ | |||
| id: id, | |||
| name: `${faker.commerce.productAdjective()} ${faker.commerce.productMaterial()} ${faker.commerce.product()}`, | |||
| color: faker.commerce.color(), | |||
| price: `$${faker.commerce.price()}`, | |||
| company: faker.company.companyName(), | |||
| }); | |||
| } | |||
| return { items }; | |||
| }; | |||
| @@ -0,0 +1,7 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "baseUrl": "src" | |||
| }, | |||
| "include": ["src"] | |||
| } | |||
| @@ -0,0 +1,69 @@ | |||
| { | |||
| "name": "template", | |||
| "version": "0.1.0", | |||
| "private": true, | |||
| "dependencies": { | |||
| "@emotion/react": "^11.10.6", | |||
| "@emotion/styled": "^11.10.6", | |||
| "@mui/icons-material": "^5.11.16", | |||
| "@mui/material": "^5.12.1", | |||
| "@mui/x-data-grid": "^6.2.1", | |||
| "@reduxjs/toolkit": "^1.9.5", | |||
| "@testing-library/jest-dom": "^5.16.5", | |||
| "@testing-library/react": "^13.4.0", | |||
| "@testing-library/user-event": "^13.5.0", | |||
| "date-fns": "^2.29.3", | |||
| "faker": "^5.5.3", | |||
| "formik": "^2.2.9", | |||
| "i18next": "^22.4.15", | |||
| "json-server": "^0.17.3", | |||
| "react": "^18.2.0", | |||
| "react-dom": "^18.2.0", | |||
| "react-helmet-async": "^1.3.0", | |||
| "react-i18next": "^12.2.0", | |||
| "react-jwt": "^1.1.8", | |||
| "react-redux": "^8.0.5", | |||
| "react-router-dom": "^6.10.0", | |||
| "react-scripts": "5.0.1", | |||
| "react-toastify": "^9.1.2", | |||
| "redux-persist": "^6.0.0", | |||
| "web-vitals": "^2.1.4", | |||
| "yup": "^1.1.1" | |||
| }, | |||
| "scripts": { | |||
| "start": "react-scripts start", | |||
| "build": "react-scripts build", | |||
| "test": "react-scripts test", | |||
| "eject": "react-scripts eject", | |||
| "json-serve": "json-server ./db/db.js --port=4000" | |||
| }, | |||
| "eslintConfig": { | |||
| "extends": [ | |||
| "react-app", | |||
| "react-app/jest" | |||
| ] | |||
| }, | |||
| "browserslist": { | |||
| "production": [ | |||
| ">0.2%", | |||
| "not dead", | |||
| "not op_mini all" | |||
| ], | |||
| "development": [ | |||
| "last 1 chrome version", | |||
| "last 1 firefox version", | |||
| "last 1 safari version" | |||
| ] | |||
| }, | |||
| "devDependencies": { | |||
| "dotenv": "^16.0.3", | |||
| "eslint": "^8.38.0", | |||
| "eslint-config-airbnb": "^19.0.4", | |||
| "eslint-config-prettier": "^8.8.0", | |||
| "eslint-plugin-import": "^2.27.5", | |||
| "eslint-plugin-jsx-a11y": "^6.7.1", | |||
| "eslint-plugin-react": "^7.32.2", | |||
| "eslint-plugin-react-hooks": "^4.6.0", | |||
| "prettier": "^2.8.7" | |||
| } | |||
| } | |||
| @@ -0,0 +1,43 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="utf-8" /> | |||
| <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |||
| <meta name="theme-color" content="#000000" /> | |||
| <meta | |||
| name="description" | |||
| content="Web site created using create-react-app" | |||
| /> | |||
| <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | |||
| <!-- | |||
| manifest.json provides metadata used when your web app is installed on a | |||
| user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ | |||
| --> | |||
| <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> | |||
| <!-- | |||
| Notice the use of %PUBLIC_URL% in the tags above. | |||
| It will be replaced with the URL of the `public` folder during the build. | |||
| Only files inside the `public` folder can be referenced from the HTML. | |||
| Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will | |||
| work correctly both with client-side routing and a non-root public URL. | |||
| Learn how to configure a non-root public URL by running `npm run build`. | |||
| --> | |||
| <title>React App</title> | |||
| </head> | |||
| <body> | |||
| <noscript>You need to enable JavaScript to run this app.</noscript> | |||
| <div id="root"></div> | |||
| <!-- | |||
| This HTML file is a template. | |||
| If you open it directly in the browser, you will see an empty page. | |||
| You can add webfonts, meta tags, or analytics to this file. | |||
| The build step will place the bundled scripts into the <body> tag. | |||
| To begin the development, run `npm start` or `yarn start`. | |||
| To create a production bundle, use `npm run build` or `yarn build`. | |||
| --> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,25 @@ | |||
| { | |||
| "short_name": "React App", | |||
| "name": "Create React App Sample", | |||
| "icons": [ | |||
| { | |||
| "src": "favicon.ico", | |||
| "sizes": "64x64 32x32 24x24 16x16", | |||
| "type": "image/x-icon" | |||
| }, | |||
| { | |||
| "src": "logo192.png", | |||
| "type": "image/png", | |||
| "sizes": "192x192" | |||
| }, | |||
| { | |||
| "src": "logo512.png", | |||
| "type": "image/png", | |||
| "sizes": "512x512" | |||
| } | |||
| ], | |||
| "start_url": ".", | |||
| "display": "standalone", | |||
| "theme_color": "#000000", | |||
| "background_color": "#ffffff" | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| # https://www.robotstxt.org/robotstxt.html | |||
| User-agent: * | |||
| Disallow: | |||
| @@ -0,0 +1,38 @@ | |||
| .App { | |||
| text-align: center; | |||
| } | |||
| .App-logo { | |||
| height: 40vmin; | |||
| pointer-events: none; | |||
| } | |||
| @media (prefers-reduced-motion: no-preference) { | |||
| .App-logo { | |||
| animation: App-logo-spin infinite 20s linear; | |||
| } | |||
| } | |||
| .App-header { | |||
| background-color: #282c34; | |||
| min-height: 100vh; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| font-size: calc(10px + 2vmin); | |||
| color: white; | |||
| } | |||
| .App-link { | |||
| color: #61dafb; | |||
| } | |||
| @keyframes App-logo-spin { | |||
| from { | |||
| transform: rotate(0deg); | |||
| } | |||
| to { | |||
| transform: rotate(360deg); | |||
| } | |||
| } | |||
| @@ -0,0 +1,53 @@ | |||
| import React, { useEffect } from "react"; | |||
| import { useNavigate } from "react-router-dom"; | |||
| import { Helmet } from "react-helmet-async"; | |||
| import i18next from "i18next"; | |||
| import AppRoutes from "./AppRoutes"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ToastContainer } from "react-toastify"; | |||
| import "react-toastify/dist/ReactToastify.css"; | |||
| import { StyledEngineProvider } from "@mui/material"; | |||
| import { authScopeStringGetHelper } from "util/authScopeHelpers"; | |||
| import { LANGUAGE } from "constants/localStorage"; | |||
| import { HOME_PAGE } from "constants/pages"; | |||
| import { useSelector } from "react-redux"; | |||
| import { selectCurrentToken } from "features/auth/authSlice"; | |||
| const App = () => { | |||
| const { i18n } = useTranslation(); | |||
| const navigate = useNavigate(); | |||
| const auth = useSelector(selectCurrentToken); | |||
| useEffect(() => { | |||
| const lang = authScopeStringGetHelper(LANGUAGE); | |||
| if (lang) { | |||
| i18n.changeLanguage(lang); | |||
| } | |||
| }, []); | |||
| useEffect(() => { | |||
| if (auth !== null) { | |||
| navigate(HOME_PAGE, { replace: true }); | |||
| } | |||
| }, []); | |||
| return ( | |||
| <> | |||
| <Helmet> | |||
| <title>{i18next.t("app.title")}</title> | |||
| </Helmet> | |||
| <StyledEngineProvider injectFirst> | |||
| <ToastContainer bodyClassName="ToastBody" /> | |||
| <AppRoutes /> | |||
| </StyledEngineProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default App; | |||
| @@ -0,0 +1,8 @@ | |||
| import { render, screen } from '@testing-library/react'; | |||
| import App from './App'; | |||
| test('renders learn react link', () => { | |||
| render(<App />); | |||
| const linkElement = screen.getByText(/learn react/i); | |||
| expect(linkElement).toBeInTheDocument(); | |||
| }); | |||
| @@ -0,0 +1,22 @@ | |||
| import React from "react"; | |||
| import { Route, Routes } from "react-router-dom"; | |||
| import LoginPage from "./pages/LoginPage/LoginPage"; | |||
| import RegisterPage from "pages/RegisterPage/RegisterPage"; | |||
| import HomePage from "pages/HomePage/HomePage"; | |||
| import RequireAuth from "components/RequireAuth/RequireAuth"; | |||
| import AuthCallback from "pages/AuthCallbackPage/AuthCallbackPage"; | |||
| const AppRoutes = () => ( | |||
| <Routes> | |||
| <Route path="/" element={<LoginPage />} /> | |||
| <Route path="login" element={<LoginPage />} /> | |||
| <Route exact path="register" element={<RegisterPage />} /> | |||
| <Route path="/api/auth/:provider/callback" element={<AuthCallback />} /> | |||
| <Route element={<RequireAuth />}> | |||
| <Route path="home" element={<HomePage />} /> | |||
| </Route> | |||
| </Routes> | |||
| ); | |||
| export default AppRoutes; | |||
| @@ -0,0 +1,26 @@ | |||
| import React from 'react'; | |||
| import PropTypes from 'prop-types'; | |||
| import { Backdrop, CircularProgress } from '@mui/material'; | |||
| import { alpha } from '@mui/system'; | |||
| const BackdropComponent = ({ position = 'fixed', isLoading }) => ( | |||
| <Backdrop | |||
| sx={{ | |||
| // 'fixed' takes whole page, 'absolute' takes whole space of the parent element which needs to have 'relative' position | |||
| position, | |||
| backgroundColor: ({ palette }) => | |||
| alpha(palette.background.default, palette.action.disabledOpacity), | |||
| zIndex: ({ zIndex }) => zIndex.drawer + 1, | |||
| }} | |||
| open={isLoading} | |||
| > | |||
| <CircularProgress /> | |||
| </Backdrop> | |||
| ); | |||
| BackdropComponent.propTypes = { | |||
| position: PropTypes.oneOf(['fixed', 'absolute']), | |||
| isLoading: PropTypes.bool, | |||
| }; | |||
| export default BackdropComponent; | |||
| @@ -0,0 +1,30 @@ | |||
| import React from "react"; | |||
| import { Paper, Typography } from "@mui/material"; | |||
| import { DataGrid } from "@mui/x-data-grid"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const rows = [ | |||
| { id: 1, col1: "Example", col2: "Row", col3: "1" }, | |||
| { id: 2, col1: "Row", col2: "Example", col3: "2" }, | |||
| { id: 3, col1: "3", col2: "Row", col3: "Example" }, | |||
| ]; | |||
| const columns = [ | |||
| { field: "col1", headerName: "Column 1", flex: 1 }, | |||
| { field: "col2", headerName: "Column 2", flex: 1 }, | |||
| { field: "col3", headerName: "Column 2", flex: 1 }, | |||
| ]; | |||
| const DataGridExample = () => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <Paper sx={{ p: 2 }} elevation={5}> | |||
| <Typography variant="h4" gutterBottom align="center"> | |||
| {t("common.dataGridExample")} | |||
| </Typography> | |||
| <DataGrid autoHeight rows={rows} columns={columns} /> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default DataGridExample; | |||
| @@ -0,0 +1,57 @@ | |||
| import React from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { | |||
| Dialog, | |||
| DialogContent, | |||
| DialogTitle, | |||
| DialogActions, | |||
| Button, | |||
| useMediaQuery, | |||
| useTheme, | |||
| } from "@mui/material"; | |||
| const DialogComponent = ({ | |||
| title, | |||
| content, | |||
| onClose, | |||
| open, | |||
| maxWidth, | |||
| fullWidth, | |||
| responsive, | |||
| }) => { | |||
| const theme = useTheme(); | |||
| const fullScreen = useMediaQuery(theme.breakpoints.down("md")); | |||
| const handleClose = () => { | |||
| onClose(); | |||
| }; | |||
| return ( | |||
| <Dialog | |||
| maxWidth={maxWidth} | |||
| fullWidth={fullWidth} | |||
| fullScreen={responsive && fullScreen} | |||
| onClose={handleClose} | |||
| open={open} | |||
| > | |||
| <DialogTitle>{title}</DialogTitle> | |||
| {content && <DialogContent>{content}</DialogContent>} | |||
| <DialogActions> | |||
| <Button onClick={handleClose}>OK</Button> | |||
| <Button onClick={handleClose}>Cancel</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| ); | |||
| }; | |||
| DialogComponent.propTypes = { | |||
| title: PropTypes.string, | |||
| open: PropTypes.bool, | |||
| content: PropTypes.any, | |||
| onClose: PropTypes.func.isRequired, | |||
| maxWidth: PropTypes.oneOf(["xs", "sm", "md", "lg", "xl"]), | |||
| fullWidth: PropTypes.bool, | |||
| responsive: PropTypes.bool, | |||
| }; | |||
| export default DialogComponent; | |||
| @@ -0,0 +1,28 @@ | |||
| import React from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { Drawer } from "@mui/material"; | |||
| const DrawerComponent = ({ open, toggleOpen, content, anchor = "right" }) => ( | |||
| <Drawer | |||
| sx={{ | |||
| minWidth: 250, | |||
| "& .MuiDrawer-paper": { | |||
| minWidth: 250, | |||
| }, | |||
| }} | |||
| anchor={anchor} | |||
| open={open} | |||
| onClose={toggleOpen} | |||
| > | |||
| {content ? content : null} | |||
| </Drawer> | |||
| ); | |||
| DrawerComponent.propTypes = { | |||
| open: PropTypes.bool, | |||
| toggleOpen: PropTypes.func, | |||
| content: PropTypes.any, | |||
| anchor: PropTypes.oneOf(["top", "right", "left", "bottom"]), | |||
| }; | |||
| export default DrawerComponent; | |||
| @@ -0,0 +1,15 @@ | |||
| import React from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { Typography } from "@mui/material"; | |||
| const ErrorMessageComponent = ({ error }) => ( | |||
| <Typography variant="body1" color="error" my={2}> | |||
| {error} | |||
| </Typography> | |||
| ); | |||
| ErrorMessageComponent.propTypes = { | |||
| error: PropTypes.string.isRequired, | |||
| }; | |||
| export default ErrorMessageComponent; | |||
| @@ -0,0 +1,44 @@ | |||
| import React, { useState } from "react"; | |||
| import { Button, Menu, MenuItem } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { authScopeSetHelper } from "util/authScopeHelpers"; | |||
| import { LANGUAGE } from "constants/localStorage"; | |||
| const MenuListComponent = () => { | |||
| const { t, i18n } = useTranslation(); | |||
| const [anchorEl, setAnchorEl] = useState(null); | |||
| const open = Boolean(anchorEl); | |||
| const handleClick = (event) => { | |||
| setAnchorEl(event.currentTarget); | |||
| }; | |||
| const handleClose = () => { | |||
| setAnchorEl(null); | |||
| }; | |||
| const handleLanguageChange = (language) => { | |||
| i18n.changeLanguage(language); | |||
| authScopeSetHelper(LANGUAGE, language) | |||
| setAnchorEl(null); | |||
| }; | |||
| return ( | |||
| <div> | |||
| <Button onClick={handleClick}>{t("common.language")}</Button> | |||
| <Menu | |||
| id="menu-list" | |||
| anchorEl={anchorEl} | |||
| open={open} | |||
| onClose={handleClose} | |||
| > | |||
| <MenuItem onClick={() => handleLanguageChange("en")}> | |||
| {t("common.english")} | |||
| </MenuItem> | |||
| <MenuItem onClick={() => handleLanguageChange("sr")}> | |||
| {t("common.serbian")} | |||
| </MenuItem> | |||
| </Menu> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default MenuListComponent; | |||
| @@ -0,0 +1,64 @@ | |||
| import React, { useState } from "react"; | |||
| import { Button, Divider, Paper, Typography } from "@mui/material"; | |||
| import DialogComponent from "../Dialog/DialogComponent"; | |||
| import DrawerComponent from "../Drawer/DrawerComponent"; | |||
| import PopoverComponent from "../Popover/PopoverComponent"; | |||
| const Modals = () => { | |||
| const [dialogOpen, setDialogOpen] = useState(false); | |||
| const [drawerOpen, setDrawerOpen] = useState(false); | |||
| const [popoverOpen, setPopoverOpen] = useState(false); | |||
| const [anchorEl, setAnchorEl] = useState(null); | |||
| return ( | |||
| <Paper | |||
| sx={{ | |||
| p: 2, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }} | |||
| elevation={5} | |||
| > | |||
| <Typography variant="h4" gutterBottom align="center"> | |||
| Modals Example | |||
| </Typography> | |||
| <Divider /> | |||
| <Button onClick={() => setDialogOpen(true)}>Open Dialog</Button> | |||
| <Button onClick={() => setDrawerOpen(true)}>Open Drawer</Button> | |||
| <Button | |||
| onClick={(e) => { | |||
| setPopoverOpen(true); | |||
| setAnchorEl(e.currentTarget); | |||
| }} | |||
| > | |||
| Open Popover | |||
| </Button> | |||
| <DialogComponent | |||
| title="Dialog Title" | |||
| content={<Typography>Dialog Content</Typography>} | |||
| open={dialogOpen} | |||
| onClose={() => setDialogOpen(false)} | |||
| maxWidth="md" | |||
| fullWidth | |||
| responsive | |||
| /> | |||
| <DrawerComponent | |||
| anchor="left" | |||
| content={<Typography sx={{ p: 2 }}>Drawer Content</Typography>} | |||
| open={drawerOpen} | |||
| toggleOpen={() => setDrawerOpen(!drawerOpen)} | |||
| /> | |||
| <PopoverComponent | |||
| anchorEl={anchorEl} | |||
| open={popoverOpen} | |||
| onClose={() => { | |||
| setPopoverOpen(false); | |||
| setAnchorEl(null); | |||
| }} | |||
| content={<Typography sx={{ p: 2 }}>Popover Content</Typography>} | |||
| /> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default Modals; | |||
| @@ -0,0 +1,171 @@ | |||
| import React, { useState, useMemo, useContext } from "react"; | |||
| import { | |||
| AppBar, | |||
| Badge, | |||
| Box, | |||
| IconButton, | |||
| Toolbar, | |||
| Typography, | |||
| List, | |||
| ListItem, | |||
| ListItemButton, | |||
| ListItemIcon, | |||
| ListItemText, | |||
| useMediaQuery, | |||
| } from "@mui/material"; | |||
| import { useTheme } from "@mui/system"; | |||
| import MenuOutlinedIcon from "@mui/icons-material/MenuOutlined"; | |||
| import ShoppingBasketIcon from "@mui/icons-material/ShoppingBasket"; | |||
| import LogoutIcon from "@mui/icons-material/Logout"; | |||
| import Brightness4Icon from "@mui/icons-material/Brightness4"; | |||
| import Brightness7Icon from "@mui/icons-material/Brightness7"; | |||
| import MenuList from "../MenuList/MenuListComponent"; | |||
| import Drawer from "../Drawer/DrawerComponent"; | |||
| import { ColorModeContext } from "context/ColorModeContext"; | |||
| import { useDispatch } from "react-redux"; | |||
| import { logOut } from "features/auth/authSlice"; | |||
| const NavbarComponent = () => { | |||
| const dispatch = useDispatch(); | |||
| const [openDrawer, setOpenDrawer] = useState(false); | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.down("sm")); | |||
| const toggleColorMode = useContext(ColorModeContext); | |||
| const handleToggleDrawer = () => { | |||
| setOpenDrawer(!openDrawer); | |||
| }; | |||
| const handleLogout = () => { | |||
| dispatch(logOut()); | |||
| }; | |||
| const drawerContent = useMemo( | |||
| () => ( | |||
| <List> | |||
| <ListItemButton divider onClick={handleToggleDrawer}> | |||
| <ListItemIcon> | |||
| <ListItemText>Link 1</ListItemText> | |||
| </ListItemIcon> | |||
| </ListItemButton> | |||
| <ListItem divider onClick={handleToggleDrawer}> | |||
| <ListItemIcon> | |||
| <ListItemText>Link 2</ListItemText> | |||
| </ListItemIcon> | |||
| </ListItem> | |||
| <ListItem divider onClick={handleToggleDrawer}> | |||
| <ListItemText>Link 3</ListItemText> | |||
| </ListItem> | |||
| <ListItem divider> | |||
| <IconButton onClick={toggleColorMode}> | |||
| <ListItemText>Toggle {theme.palette.mode} mode</ListItemText> | |||
| {theme.palette.mode === "dark" ? ( | |||
| <Brightness7Icon /> | |||
| ) : ( | |||
| <Brightness4Icon /> | |||
| )} | |||
| </IconButton> | |||
| </ListItem> | |||
| </List> | |||
| ), | |||
| [handleToggleDrawer] | |||
| ); | |||
| return ( | |||
| <AppBar | |||
| elevation={2} | |||
| sx={{ backgroundColor: "background.default", position: "relative" }} | |||
| > | |||
| <Toolbar> | |||
| <Box | |||
| component="div" | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "center", | |||
| width: "100%", | |||
| }} | |||
| > | |||
| {matches ? ( | |||
| <Drawer | |||
| open={openDrawer} | |||
| toggleOpen={handleToggleDrawer} | |||
| content={drawerContent} | |||
| /> | |||
| ) : ( | |||
| <Box sx={{ display: "flex" }}> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ | |||
| marginRight: 3, | |||
| cursor: "pointer", | |||
| color: "text.primary", | |||
| }} | |||
| > | |||
| Link 1 | |||
| </Typography> | |||
| <Typography | |||
| variant="body1" | |||
| sx={{ | |||
| marginRight: 3, | |||
| cursor: "pointer", | |||
| color: "text.primary", | |||
| }} | |||
| > | |||
| Link 2 | |||
| </Typography> | |||
| <Typography | |||
| variant="subtitle1" | |||
| sx={{ | |||
| marginRight: 3, | |||
| cursor: "pointer", | |||
| color: "text.primary", | |||
| }} | |||
| > | |||
| Link 3 | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| <Box> | |||
| <MenuList /> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "center", | |||
| alignItems: "center", | |||
| }} | |||
| > | |||
| {matches ? ( | |||
| <Box> | |||
| <IconButton onClick={handleToggleDrawer}> | |||
| <MenuOutlinedIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| ) : ( | |||
| <Box> | |||
| <IconButton> | |||
| <Badge badgeContent={3} color="primary"> | |||
| <ShoppingBasketIcon color="action" /> | |||
| </Badge> | |||
| </IconButton> | |||
| <IconButton sx={{ ml: 1 }} onClick={toggleColorMode}> | |||
| {theme.palette.mode === "dark" ? ( | |||
| <Brightness7Icon /> | |||
| ) : ( | |||
| <Brightness4Icon /> | |||
| )} | |||
| </IconButton> | |||
| <IconButton onClick={handleLogout}> | |||
| <LogoutIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| </Toolbar> | |||
| </AppBar> | |||
| ); | |||
| }; | |||
| export default NavbarComponent; | |||
| @@ -0,0 +1,180 @@ | |||
| import React, { useEffect, useState } from "react"; | |||
| import { | |||
| Paper, | |||
| Box, | |||
| Grid, | |||
| Typography, | |||
| Divider, | |||
| TablePagination, | |||
| TextField, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| } from "@mui/material"; | |||
| import { useDispatch, useSelector, batch } from "react-redux"; | |||
| import useDebounce from "hooks/useDebounceHook"; | |||
| import { | |||
| itemsSelector, | |||
| pageSelector, | |||
| itemsPerPageSelector, | |||
| countSelector, | |||
| sortSelector, | |||
| } from "features/randomData/randomDataSlice"; | |||
| import { | |||
| loadRandomData, | |||
| updatePage, | |||
| updateItemsPerPage, | |||
| updateFilter, | |||
| updateSort, | |||
| } from "features/randomData/randomDataSlice"; | |||
| const PagingSortingFilteringExample = () => { | |||
| const [filterText, setFilterText] = useState(""); | |||
| const dispatch = useDispatch(); | |||
| // const { t } = useTranslation(); | |||
| const items = useSelector(itemsSelector); | |||
| const currentPage = useSelector(pageSelector); | |||
| const itemsPerPage = useSelector(itemsPerPageSelector); | |||
| const totalCount = useSelector(countSelector); | |||
| const sort = useSelector(sortSelector); | |||
| // Use debounce to prevent too many rerenders | |||
| const debouncedFilterText = useDebounce(filterText, 500); | |||
| useEffect(() => { | |||
| dispatch(loadRandomData(30)); | |||
| dispatch(updateSort(sort)); | |||
| }, []); | |||
| useEffect(() => { | |||
| batch(() => { | |||
| dispatch(updateFilter(filterText)); | |||
| currentPage > 0 && dispatch(updatePage(0)); | |||
| }); | |||
| }, [debouncedFilterText]); | |||
| const handleFilterTextChange = (event) => { | |||
| const filterText = event.target.value; | |||
| setFilterText(filterText); | |||
| }; | |||
| const handleSortChange = (event) => { | |||
| const sort = event.target.value; | |||
| dispatch(updateSort(sort)); | |||
| }; | |||
| const handlePageChange = (event, newPage) => { | |||
| dispatch(updatePage(newPage)); | |||
| }; | |||
| const handleItemsPerPageChange = (event) => { | |||
| const itemsPerPage = parseInt(event.target.value); | |||
| batch(() => { | |||
| dispatch(updateItemsPerPage(itemsPerPage)); | |||
| dispatch(updatePage(0)); | |||
| }); | |||
| }; | |||
| return ( | |||
| <Paper | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| justifyContent: "start", | |||
| py: 2, | |||
| minHeight: 500, | |||
| }} | |||
| elevation={5} | |||
| > | |||
| <Typography sx={{ my: 4 }} variant="h4" gutterBottom align="center"> | |||
| Pagination, Filtering and Sorting Example Client Side | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| mx: 2, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| width: "100%", | |||
| }} | |||
| > | |||
| {/* TODO Separate into SelectComponent */} | |||
| <FormControl sx={{ flexGrow: 1 }}> | |||
| <InputLabel id="sort-label">Sort</InputLabel> | |||
| <Select | |||
| label="Sort" | |||
| labelId="sort-label" | |||
| id="sort-select-helper" | |||
| value={sort} | |||
| onChange={handleSortChange} | |||
| > | |||
| <MenuItem value="name-asc">Name - A-Z</MenuItem> | |||
| <MenuItem value="name-desc">Name - Z-A</MenuItem> | |||
| <MenuItem value="price-asc">Price - Lowest to Highest</MenuItem> | |||
| <MenuItem value="price-desc">Price - Highest to Lowest</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <TextField | |||
| sx={{ flexGrow: 1 }} | |||
| variant="outlined" | |||
| label="Filter" | |||
| placeholder="Filter" | |||
| value={filterText} | |||
| onChange={handleFilterTextChange} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <Grid container> | |||
| {items && | |||
| items.length > 0 && | |||
| items | |||
| .slice( | |||
| currentPage * itemsPerPage, | |||
| currentPage * itemsPerPage + itemsPerPage | |||
| ) | |||
| .map((product, index) => ( | |||
| // ! DON'T USE index for key, this is for example only | |||
| <Grid item sx={{ p: 2 }} xs={12} sm={6} md={4} lg={3} key={index}> | |||
| {/* TODO separate into component */} | |||
| <Paper sx={{ p: 3, height: "100%" }} elevation={3}> | |||
| <Typography sx={{ fontWeight: 600 }}>Name: </Typography> | |||
| <Typography display="inline"> {product.name}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Designer: </Typography> | |||
| <Typography display="inline"> {product.designer}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Type: </Typography> | |||
| <Typography display="inline"> {product.type}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Price: </Typography> | |||
| <Typography display="inline"> ${product.price}</Typography> | |||
| </Paper> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| <Box sx={{ width: "100%" }}> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCount} | |||
| page={currentPage} | |||
| onPageChange={handlePageChange} | |||
| rowsPerPage={itemsPerPage} | |||
| onRowsPerPageChange={handleItemsPerPageChange} | |||
| rowsPerPageOptions={[12, 24, 48, 96]} | |||
| labelRowsPerPage="Items per page" | |||
| showFirstButton | |||
| showLastButton | |||
| /> | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default PagingSortingFilteringExample; | |||
| @@ -0,0 +1,157 @@ | |||
| import React, { useEffect, useState } from "react"; | |||
| import { | |||
| Paper, | |||
| Box, | |||
| Grid, | |||
| Typography, | |||
| Divider, | |||
| TablePagination, | |||
| TextField, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| } from "@mui/material"; | |||
| import Backdrop from "../Backdrop/BackdropComponent"; | |||
| import useDebounce from "hooks/useDebounceHook"; | |||
| import { useRandomData } from "context/RandomDataContext"; | |||
| const PagingSortingFilteringExampleServerSide = () => { | |||
| const [filterText, setFilterText] = useState(""); | |||
| const { state, data } = useRandomData(); | |||
| const { items, loading, totalCount, currentPage, itemsPerPage, sort } = data; | |||
| const { setPage, setItemsPerPage, setSort, setFilter } = state; | |||
| // Use debounce to prevent too many rerenders | |||
| const debouncedFilterText = useDebounce(filterText, 500); | |||
| useEffect(() => { | |||
| setFilter(filterText); | |||
| }, [debouncedFilterText]); | |||
| const handleFilterTextChange = (event) => { | |||
| const filterText = event.target.value; | |||
| setFilterText(filterText); | |||
| }; | |||
| const handleSortChange = (event) => { | |||
| const sort = event.target.value; | |||
| setSort(sort); | |||
| }; | |||
| const handlePageChange = (event, newPage) => { | |||
| setPage(newPage); | |||
| }; | |||
| const handleItemsPerPageChange = (event) => { | |||
| const itemsPerPage = parseInt(event.target.value); | |||
| setItemsPerPage(itemsPerPage); | |||
| setPage(0); | |||
| }; | |||
| return ( | |||
| <Paper | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| justifyContent: "start", | |||
| py: 2, | |||
| minHeight: 500, | |||
| position: "relative", | |||
| }} | |||
| elevation={5} | |||
| > | |||
| {loading && <Backdrop isLoading={loading} position="absolute" />} | |||
| <Typography sx={{ my: 4 }} variant="h4" gutterBottom align="center"> | |||
| Pagination, Filtering and Sorting Example Server Side | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| mx: 2, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| width: "100%", | |||
| }} | |||
| > | |||
| <FormControl sx={{ flexGrow: 1 }}> | |||
| <InputLabel id="sort-label">Sort</InputLabel> | |||
| <Select | |||
| label="Sort" | |||
| labelId="sort-label" | |||
| id="sort-select-helper" | |||
| value={sort || ""} | |||
| onChange={handleSortChange} | |||
| > | |||
| <MenuItem value="">None</MenuItem> | |||
| <MenuItem value="name-asc">Name - A-Z</MenuItem> | |||
| <MenuItem value="name-desc">Name - Z-A</MenuItem> | |||
| <MenuItem value="price-asc">Price - Lowest to Highest</MenuItem> | |||
| <MenuItem value="price-desc">Price - Highest to Lowest</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <TextField | |||
| sx={{ flexGrow: 1 }} | |||
| variant="outlined" | |||
| label="Filter" | |||
| placeholder="Filter" | |||
| value={filterText} | |||
| onChange={handleFilterTextChange} | |||
| /> | |||
| </Box> | |||
| <Grid container sx={{ position: "relative" }}> | |||
| {items && | |||
| items.length > 0 && | |||
| items.map((item) => ( | |||
| <Grid | |||
| item | |||
| sx={{ p: 2 }} | |||
| xs={12} | |||
| sm={6} | |||
| md={4} | |||
| lg={3} | |||
| key={item.id} | |||
| > | |||
| {/* TODO separate into component */} | |||
| <Paper sx={{ p: 3, height: "100%" }} elevation={3}> | |||
| <Typography sx={{ fontWeight: 600 }}>Name: </Typography> | |||
| <Typography display="inline"> {item.name}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Company: </Typography> | |||
| <Typography display="inline"> {item.company}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Color: </Typography> | |||
| <Typography display="inline"> {item.color}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Price: </Typography> | |||
| <Typography display="inline"> {item.price}</Typography> | |||
| </Paper> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| <Box sx={{ width: "100%" }}> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCount} | |||
| page={currentPage} | |||
| onPageChange={handlePageChange} | |||
| rowsPerPage={itemsPerPage} | |||
| onRowsPerPageChange={handleItemsPerPageChange} | |||
| rowsPerPageOptions={[12, 24, 48, 96]} | |||
| labelRowsPerPage="Items per page" | |||
| showFirstButton | |||
| showLastButton | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default PagingSortingFilteringExampleServerSide; | |||
| @@ -0,0 +1,35 @@ | |||
| import React from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { Box, Popover } from "@mui/material"; | |||
| const PopoverComponent = ({ open, anchorEl, onClose, content }) => { | |||
| const handleClose = () => { | |||
| onClose(); | |||
| }; | |||
| return ( | |||
| <Box component="div"> | |||
| <Popover | |||
| sx={{ p: 5 }} | |||
| open={open} | |||
| anchorEl={anchorEl} | |||
| onClose={handleClose} | |||
| anchorOrigin={{ | |||
| vertical: "bottom", | |||
| horizontal: "left", | |||
| }} | |||
| > | |||
| {content} | |||
| </Popover> | |||
| </Box> | |||
| ); | |||
| }; | |||
| PopoverComponent.propTypes = { | |||
| anchorEl: PropTypes.object, | |||
| open: PropTypes.bool, | |||
| onClose: PropTypes.func.isRequired, | |||
| content: PropTypes.any, | |||
| }; | |||
| export default PopoverComponent; | |||
| @@ -0,0 +1,17 @@ | |||
| import React from "react"; | |||
| import { useLocation, Navigate, Outlet } from "react-router-dom"; | |||
| import { useSelector } from "react-redux"; | |||
| import { selectCurrentToken } from "features/auth/authSlice"; | |||
| const RequireAuth = () => { | |||
| const token = useSelector(selectCurrentToken); | |||
| const location = useLocation(); | |||
| return token ? ( | |||
| <Outlet /> | |||
| ) : ( | |||
| <Navigate to={"/login"} state={{ from: location }} replace /> | |||
| ); | |||
| }; | |||
| export default RequireAuth; | |||
| @@ -0,0 +1,4 @@ | |||
| export const JWT_TOKEN = "JwtToken"; | |||
| export const JWT_REFRESH_TOKEN = "JwtRefreshToken"; | |||
| export const REFRESH_TOKEN_CONST = "RefreshToken"; | |||
| export const LANGUAGE = "Language"; | |||
| @@ -0,0 +1,8 @@ | |||
| export const BASE_PAGE = '/'; | |||
| export const LOGIN_PAGE = '/login'; | |||
| export const REGISTER_PAGE = '/register'; | |||
| export const FORGOT_PASSWORD_PAGE = '/forgot-password'; | |||
| export const HOME_PAGE = '/home'; | |||
| export const ERROR_PAGE = '/error-page'; | |||
| export const NOT_FOUND_PAGE = '/not-found'; | |||
| export const AUTH_CALLBACK_PAGE = '/api/auth/:provider/callback' | |||
| @@ -0,0 +1,21 @@ | |||
| import React, { createContext } from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { ThemeProvider } from "@mui/material/styles"; | |||
| import useToggleColorMode from "hooks/useToggleColorMode"; | |||
| export const ColorModeContext = createContext(); | |||
| const ColorModeProvider = ({ children }) => { | |||
| const [toggleColorMode, theme] = useToggleColorMode(); | |||
| return ( | |||
| <ColorModeContext.Provider value={toggleColorMode}> | |||
| <ThemeProvider theme={theme}>{children}</ThemeProvider> | |||
| </ColorModeContext.Provider> | |||
| ); | |||
| }; | |||
| ColorModeProvider.propTypes = { | |||
| children: PropTypes.node, | |||
| }; | |||
| export default ColorModeProvider; | |||
| @@ -0,0 +1,76 @@ | |||
| import React, { createContext, useContext, useState } from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import usePagingHook from "hooks/usePagingHook"; | |||
| const apiCall = async (page, itemsPerPage, sort, sortDirection, filter) => { | |||
| const res = await fetch( | |||
| "http://localhost:4000/items?" + | |||
| new URLSearchParams({ | |||
| _page: page, | |||
| _limit: itemsPerPage, | |||
| // Conditionally add to params object if keys exist | |||
| ...(sort && { _sort: sort }), | |||
| ...(sortDirection && { _order: sortDirection }), | |||
| ...(filter && { q: filter }), | |||
| }), | |||
| { | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| } | |||
| ); | |||
| const totalCount = res.headers.get('x-total-count') | |||
| const data = await res.json() | |||
| return {totalCount, data} | |||
| }; | |||
| const Context = createContext(); | |||
| export const useRandomData = () => useContext(Context); | |||
| const RandomDataProvider = ({ children }) => { | |||
| const setPage = (page) => { | |||
| setState({ ...state, page }); | |||
| }; | |||
| const setItemsPerPage = (itemsPerPage) => { | |||
| setState({ ...state, itemsPerPage }); | |||
| }; | |||
| const setSort = (sort) => { | |||
| setState({ ...state, sort }); | |||
| }; | |||
| const setFilter = (filter) => { | |||
| setState({ ...state, filter }); | |||
| }; | |||
| const [state, setState] = useState({ | |||
| page: 0, | |||
| setPage, | |||
| itemsPerPage: 12, | |||
| setItemsPerPage, | |||
| sort: "", | |||
| setSort, | |||
| filter: "", | |||
| setFilter, | |||
| }); | |||
| const data = usePagingHook( | |||
| state.page, | |||
| state.itemsPerPage, | |||
| state.sort, | |||
| state.filter, | |||
| apiCall | |||
| ); | |||
| return ( | |||
| <Context.Provider value={{ state, data }}>{children}</Context.Provider> | |||
| ); | |||
| }; | |||
| RandomDataProvider.propTypes = { | |||
| children: PropTypes.node, | |||
| }; | |||
| export default RandomDataProvider; | |||
| @@ -0,0 +1,48 @@ | |||
| import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; | |||
| import { setCredetnials, logOut } from "features/auth/authSlice"; | |||
| import { isExpired } from "react-jwt"; | |||
| const baseQuery = fetchBaseQuery({ | |||
| baseUrl: "https://strapi.dilig.net", | |||
| prepareHeaders: (headers, { getState }) => { | |||
| const token = getState().auth.token; | |||
| if (token) { | |||
| headers.set("Authorization", `Bearer ${token.jwt}`); | |||
| } | |||
| return headers; | |||
| }, | |||
| }); | |||
| const baseQueryWithReauth = async (args, api, extraOptions) => { | |||
| let result = await baseQuery(args, api, extraOptions); | |||
| if (result?.error?.status === 401) { | |||
| const token = api.getState().auth.token; | |||
| const expired = isExpired(token); | |||
| if (expired) { | |||
| const refreshResult = await baseQuery( | |||
| { | |||
| url: "/api/token/refresh", | |||
| method: "POST", | |||
| body: { refreshToken: token.refreshToken }, | |||
| }, | |||
| api, | |||
| extraOptions | |||
| ); | |||
| if (refreshResult?.data) { | |||
| const user = api.getState().auth.user; | |||
| api.dispatch(setCredetnials({ ...refreshResult.data, user })); | |||
| result = await baseQuery(args, api, extraOptions); | |||
| } else { | |||
| api.dispatch(logOut()); | |||
| } | |||
| } | |||
| } | |||
| return result; | |||
| }; | |||
| export const apiSlice = createApi({ | |||
| baseQuery: baseQueryWithReauth, | |||
| // eslint-disable-next-line no-unused-vars | |||
| endpoints: (builder) => ({}), | |||
| }); | |||
| @@ -0,0 +1,15 @@ | |||
| import { apiSlice } from "features/api/apiSlice"; | |||
| export const authApiSlice = apiSlice.injectEndpoints({ | |||
| endpoints: (builder) => ({ | |||
| login: builder.mutation({ | |||
| query: (credentials) => ({ | |||
| url: "/api/auth/local", | |||
| method: "POST", | |||
| body: { ...credentials }, | |||
| }), | |||
| }), | |||
| }), | |||
| }); | |||
| export const { useLoginMutation } = authApiSlice; | |||
| @@ -0,0 +1,23 @@ | |||
| import { createSlice } from "@reduxjs/toolkit"; | |||
| const authSlice = createSlice({ | |||
| name: "auth", | |||
| initialState: { user: null, token: null }, | |||
| reducers: { | |||
| setCredetnials: (state, action) => { | |||
| const { user, jwt, refreshToken } = action.payload; | |||
| state.user = user; | |||
| state.token = { jwt, refreshToken }; | |||
| }, | |||
| logOut: (state) => { | |||
| state.user = null; | |||
| state.token = null; | |||
| }, | |||
| }, | |||
| }); | |||
| export const { setCredetnials, logOut } = authSlice.actions; | |||
| export default authSlice.reducer; | |||
| export const selectCurrentUser = (state) => state.auth.user; | |||
| export const selectCurrentToken = (state) => state.auth.token; | |||
| @@ -0,0 +1,13 @@ | |||
| import { apiSlice } from "features/api/apiSlice"; | |||
| export const postsApiSlice = apiSlice.injectEndpoints({ | |||
| endpoints: (builder) => ({ | |||
| allPosts: builder.query({ | |||
| query: () => ({ | |||
| url: "api/posts", | |||
| }), | |||
| }), | |||
| }), | |||
| }); | |||
| export const { useAllPostsQuery } = postsApiSlice; | |||
| @@ -0,0 +1,13 @@ | |||
| import { apiSlice } from "features/api/apiSlice"; | |||
| export const providerApiSlice = apiSlice.injectEndpoints({ | |||
| endpoints: (builder) => ({ | |||
| providerLogin: builder.query({ | |||
| query: (data) => ({ | |||
| url: `api/auth/${data.provider}/callback${data.search}`, | |||
| }), | |||
| }), | |||
| }), | |||
| }); | |||
| export const { useProviderLoginQuery } = providerApiSlice; | |||
| @@ -0,0 +1,109 @@ | |||
| import { createSlice } from "@reduxjs/toolkit"; | |||
| import generate from "util/randomData"; | |||
| import { createSelector } from "reselect"; | |||
| const randomDataSlice = createSlice({ | |||
| name: "randomData", | |||
| initialState: { | |||
| items: [], | |||
| filteredItems: [], | |||
| count: 0, | |||
| page: 0, | |||
| itemsPerPage: 12, | |||
| filter: "", | |||
| sort: "", | |||
| }, | |||
| reducers: { | |||
| loadRandomData: (state, action) => { | |||
| const count = action.payload; | |||
| const generatedItems = generate(count); | |||
| state.items = generatedItems; | |||
| state.filteredItems = generatedItems; | |||
| state.count = generatedItems.length; | |||
| }, | |||
| updatePage: (state, action) => { | |||
| state.page = action.payload; | |||
| }, | |||
| updateItemsPerPage: (state, action) => { | |||
| state.itemsPerPage = action.payload; | |||
| }, | |||
| updateFilter: (state, action) => { | |||
| const filter = action.payload; | |||
| const filteredItems = filter | |||
| ? state.items.filter((item) => | |||
| item.name.toLowerCase().includes(filter.toLowerCase()) | |||
| ) | |||
| : state.items; | |||
| state.filter = filter; | |||
| state.filteredItems = filteredItems; | |||
| state.count = filteredItems.length; | |||
| }, | |||
| updateSort: (state, action) => { | |||
| const sort = action.payload; | |||
| const [field, direction] = sort.split("-"); | |||
| const sortDirection = direction === "asc" ? 1 : -1; | |||
| const dataItems = state.filteredItems.length | |||
| ? state.filteredItems | |||
| : state.items; | |||
| const sorted = [...dataItems].sort((a, b) => { | |||
| if (a[field] > b[field]) { | |||
| return sortDirection; | |||
| } | |||
| if (b[field] > a[field]) { | |||
| return sortDirection * -1; | |||
| } | |||
| return 0; | |||
| }); | |||
| state.sort = sort; | |||
| state.filteredItems = sorted; | |||
| }, | |||
| }, | |||
| }); | |||
| export const { | |||
| loadRandomData, | |||
| updatePage, | |||
| updateItemsPerPage, | |||
| updateFilter, | |||
| updateSort, | |||
| } = randomDataSlice.actions; | |||
| export default randomDataSlice.reducer; | |||
| // Random data selectors | |||
| const randomDataSelector = (state) => state.randomData; | |||
| export const itemsSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.filteredItems | |||
| ); | |||
| export const pageSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.page | |||
| ); | |||
| export const itemsPerPageSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.itemsPerPage | |||
| ); | |||
| export const countSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.count | |||
| ); | |||
| export const filterSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.filter | |||
| ); | |||
| export const sortSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.sort | |||
| ); | |||
| @@ -0,0 +1,15 @@ | |||
| import { apiSlice } from "features/api/apiSlice"; | |||
| export const registerApiSlice = apiSlice.injectEndpoints({ | |||
| endpoints: (builder) => ({ | |||
| register: builder.mutation({ | |||
| query: (credentials) => ({ | |||
| url: "api/auth/local/register", | |||
| method: "POST", | |||
| body: { ...credentials }, | |||
| }), | |||
| }), | |||
| }), | |||
| }); | |||
| export const { useRegisterMutation } = registerApiSlice; | |||
| @@ -0,0 +1,37 @@ | |||
| import { configureStore } from "@reduxjs/toolkit"; | |||
| import { apiSlice } from "./api/apiSlice"; | |||
| import authReducer from "../features/auth/authSlice"; | |||
| import randomDataReducer from "../features/randomData/randomDataSlice"; | |||
| import { | |||
| persistReducer, | |||
| persistStore, | |||
| FLUSH, | |||
| REHYDRATE, | |||
| PAUSE, | |||
| PERSIST, | |||
| PURGE, | |||
| REGISTER, | |||
| } from "redux-persist"; | |||
| import storage from "redux-persist/lib/storage"; | |||
| const authPersistConfig = { | |||
| key: "auth", | |||
| storage, | |||
| }; | |||
| export const store = configureStore({ | |||
| reducer: { | |||
| [apiSlice.reducerPath]: apiSlice.reducer, | |||
| auth: persistReducer(authPersistConfig, authReducer), | |||
| randomData: randomDataReducer, | |||
| }, | |||
| middleware: (getDefaultMiddleware) => | |||
| getDefaultMiddleware({ | |||
| serializableCheck: { | |||
| ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], | |||
| }, | |||
| }).concat(apiSlice.middleware), | |||
| devTools: true, | |||
| }); | |||
| export const persistor = persistStore(store); | |||
| @@ -0,0 +1,17 @@ | |||
| import { useEffect, useState } from "react"; | |||
| const useDebounce = (value, delay) => { | |||
| const [debouncedValue, setDebouncedValue] = useState(value); | |||
| useEffect(() => { | |||
| const timer = setTimeout(() => setDebouncedValue(value), delay || 500); | |||
| return () => { | |||
| clearTimeout(timer); | |||
| }; | |||
| }, [value, delay]); | |||
| return debouncedValue; | |||
| }; | |||
| export default useDebounce; | |||
| @@ -0,0 +1,65 @@ | |||
| import { useState, useCallback, useEffect } from "react"; | |||
| import { unstable_batchedUpdates } from "react-dom"; | |||
| const usePagingHook = (page, itemsPerPage, sort, filter, apiCallback) => { | |||
| const [items, setItems] = useState([]); | |||
| const [totalPages, setTotalPages] = useState(0); | |||
| const [currentPage, setCurrentPage] = useState(0); | |||
| const [loading, setLoading] = useState(false); | |||
| const [totalCount, setTotalCount] = useState(0); | |||
| const reload = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const [sortColumn, sortDirection] = sort.split("-"); | |||
| const {totalCount, data} = await apiCallback( | |||
| page, | |||
| itemsPerPage, | |||
| sortColumn, | |||
| sortDirection, | |||
| filter | |||
| ); | |||
| // Prevents multiple rerenders | |||
| unstable_batchedUpdates(() => { | |||
| setItems(data); | |||
| setTotalCount(parseInt(totalCount)); | |||
| setTotalPages( | |||
| Math.ceil(totalCount / itemsPerPage) | |||
| ); | |||
| setCurrentPage(page); | |||
| }); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [ | |||
| setItems, | |||
| setLoading, | |||
| setTotalPages, | |||
| setCurrentPage, | |||
| apiCallback, | |||
| page, | |||
| itemsPerPage, | |||
| sort, | |||
| filter, | |||
| ]); | |||
| useEffect(() => { | |||
| reload(); | |||
| }, [reload]); | |||
| return { | |||
| items, | |||
| loading, | |||
| reload, | |||
| totalCount, | |||
| totalPages, | |||
| currentPage, | |||
| itemsPerPage, | |||
| sort, | |||
| }; | |||
| }; | |||
| export default usePagingHook; | |||
| @@ -0,0 +1,44 @@ | |||
| import { useState, useMemo } from "react"; | |||
| import { createTheme } from "@mui/material/styles"; | |||
| import { | |||
| authScopeSetHelper, | |||
| authScopeStringGetHelper, | |||
| } from "util/authScopeHelpers"; | |||
| import selectedTheme from "themes"; | |||
| const useToggleColorMode = () => { | |||
| const currentColorMode = authScopeStringGetHelper("colorMode") || "light"; | |||
| const [mode, setMode] = useState(currentColorMode); | |||
| const toggleColorMode = () => { | |||
| const nextMode = mode === "light" ? "dark" : "light"; | |||
| setMode(nextMode); | |||
| authScopeSetHelper("colorMode", nextMode); | |||
| }; | |||
| const theme = useMemo( | |||
| () => | |||
| createTheme({ | |||
| palette: { | |||
| mode, | |||
| primary: { | |||
| main: | |||
| mode === "light" | |||
| ? selectedTheme.colors.primaryLight | |||
| : selectedTheme.colors.primaryDark, | |||
| }, | |||
| secondary: { | |||
| main: | |||
| mode === "light" | |||
| ? selectedTheme.colors.secondaryLight | |||
| : selectedTheme.colors.secondaryDark, | |||
| }, | |||
| }, | |||
| }), | |||
| [mode] | |||
| ); | |||
| return [toggleColorMode, theme]; | |||
| }; | |||
| export default useToggleColorMode; | |||
| @@ -0,0 +1,32 @@ | |||
| import { format as formatDate } from "date-fns"; | |||
| import i18n from "i18next"; | |||
| import { initReactI18next } from "react-i18next"; | |||
| import enTranslations from "./resources/en"; | |||
| import srTranslations from "./resources/sr"; | |||
| i18n.use(initReactI18next).init({ | |||
| lng: "en", | |||
| fallbackLng: "en", | |||
| debug: false, | |||
| supportedLngs: ["en", "sr"], | |||
| resources: { | |||
| en: { | |||
| translation: enTranslations, | |||
| }, | |||
| sr: { | |||
| translation: srTranslations, | |||
| }, | |||
| }, | |||
| interpolation: { | |||
| format: (value, format) => { | |||
| if (value instanceof Date) { | |||
| return formatDate(value, format); | |||
| } | |||
| return value; | |||
| }, | |||
| }, | |||
| }); | |||
| export default i18n; | |||
| @@ -0,0 +1,108 @@ | |||
| export default { | |||
| app: { | |||
| title: "React template", | |||
| }, | |||
| refresh: { | |||
| title: "Are you active?", | |||
| cta: "You were registered as not active, please confirm that you are active in the next minute, if you don't you will be logged out.", | |||
| }, | |||
| common: { | |||
| language: "Language", | |||
| english: "English", | |||
| serbian: "Serbian", | |||
| close: "Close", | |||
| dataGridExample: "Data Grid Example", | |||
| trademark: "TM", | |||
| search: "Search", | |||
| error: "Error", | |||
| continue: "Continue", | |||
| labelUsername: "Username", | |||
| labelEmail: "Email", | |||
| labelPassword: "Password", | |||
| next: "Next", | |||
| nextPage: "Next page", | |||
| previousPage: "Previous page", | |||
| back: "Back", | |||
| goBack: "Go Back", | |||
| ok: "Ok", | |||
| done: "Done", | |||
| confirm: "Confirm", | |||
| printDownload: "Print/Download", | |||
| cancel: "Cancel", | |||
| remove: "Remove", | |||
| invite: "Invite", | |||
| save: "Save", | |||
| complete: "Complete", | |||
| download: "Download", | |||
| yes: "Yes", | |||
| no: "No", | |||
| to: "to", | |||
| select: "Select...", | |||
| none: "None", | |||
| date: { | |||
| range: "{{start}} to {{end}}", | |||
| }, | |||
| }, | |||
| register: { | |||
| registerTitle: "Register", | |||
| usernameRequired: "Username is required.", | |||
| emailFormat: "Invalid email address format.", | |||
| emailRequired: "An email or username is required.", | |||
| passwordLength: "Your password contain between 8 and 50 characters.", | |||
| passwordRequired: "A Password is required.", | |||
| }, | |||
| login: { | |||
| welcome: "React template", | |||
| dontHaveAccount: "Don't have an account? ", | |||
| emailFormat: "Invalid email address format.", | |||
| emailRequired: "An email or username is required.", | |||
| noUsers: "There are no users with that email.", | |||
| passwordStrength: "Your password is {{strength}}.", | |||
| passwordLength: "Your password contain between 8 and 50 characters.", | |||
| signUpRecommendation: "Sign up", | |||
| email: "Please enter your email address or username to log in:", | |||
| logInTitle: "Log In", | |||
| logIn: "Log In", | |||
| signUp: "Sign Up", | |||
| usernameRequired: "Username is required.", | |||
| passwordRequired: "A Password is required.", | |||
| forgotYourPassword: "Forgot your password?", | |||
| forgotPasswordEmail: "Email", | |||
| useDifferentEmail: "Use different email address or username", | |||
| }, | |||
| password: { | |||
| weak: "weak", | |||
| average: "average", | |||
| good: "good", | |||
| strong: "strong", | |||
| }, | |||
| forgotPassword: { | |||
| title: "Forgot Password", | |||
| label: "Send email", | |||
| emailRequired: "An email is required.", | |||
| emailFormat: "Invalid email address format.", | |||
| forgotPassword: { | |||
| title: "Forgot Password", | |||
| subtitle: | |||
| "Please answer the security question to gain access to your account:", | |||
| label: "Reset Password", | |||
| }, | |||
| }, | |||
| notFound: { | |||
| text: "We're sorry but we couldn't find the page you were looking for.", | |||
| goBack: "Go back to homepage", | |||
| }, | |||
| errorPage: { | |||
| text: "We're sorry, an internal server error came up. Please be patient or try again later.", | |||
| goBack: "Go back to homepage", | |||
| logout: "Logout", | |||
| }, | |||
| apiErrors: { | |||
| ClientIpAddressIsNullOrEmpty: "Client Ip address is null or empty", | |||
| UsernameDoesNotExist: "Username does not exist", | |||
| WrongCredentials: "Wrong credentials", | |||
| SomethingWentWrong: "Something went wrong", | |||
| WrongPasswordAccountIsLocked: "Wrong credentials, account is locked", | |||
| AccountIsLocked: "Account is locked", | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,108 @@ | |||
| export default { | |||
| app: { | |||
| title: "React template", | |||
| }, | |||
| refresh: { | |||
| title: "Are you active?", | |||
| cta: "You were registered as not active, please confirm that you are active in the next minute, if you don't you will be logged out.", | |||
| }, | |||
| common: { | |||
| language: "Jezik", | |||
| english: "Engleski", | |||
| serbian: "Srpski", | |||
| dataGridExample: "Primer Data Grid-a", | |||
| close: "Close", | |||
| trademark: "TM", | |||
| search: "Pretraga", | |||
| error: "Greška", | |||
| continue: "Nastavite", | |||
| labelUsername: "Korisničko ime", | |||
| labelEmail: "E-mail", | |||
| labelPassword: "Šifra", | |||
| next: "Napred", | |||
| nextPage: "Sledeća stranica", | |||
| previousPage: "Predhodna stranica", | |||
| back: "Nazad", | |||
| goBack: "Idite nazad", | |||
| ok: "U redu", | |||
| done: "Gotovo", | |||
| confirm: "Potvrdite", | |||
| printDownload: "Print/Download", | |||
| cancel: "Cancel", | |||
| remove: "Remove", | |||
| invite: "Invite", | |||
| save: "Save", | |||
| complete: "Complete", | |||
| download: "Download", | |||
| yes: "Yes", | |||
| no: "No", | |||
| to: "to", | |||
| select: "Select...", | |||
| none: "None", | |||
| date: { | |||
| range: "{{start}} to {{end}}", | |||
| }, | |||
| }, | |||
| register: { | |||
| registerTitle: "Register", | |||
| usernameRequired: "Username is required.", | |||
| emailFormat: "Invalid email address format.", | |||
| emailRequired: "An email or username is required.", | |||
| passwordLength: "Your password contain between 8 and 50 characters.", | |||
| passwordRequired: "A Password is required.", | |||
| }, | |||
| login: { | |||
| welcome: "React template", | |||
| dontHaveAccount: "Nemate nalog? ", | |||
| emailFormat: "Loš format email-a", | |||
| emailRequired: "Email/korisničko ime je obavezno", | |||
| noUsers: "Ne postoji korisnik", | |||
| passwordStrength: "Your password is {{strength}}.", | |||
| passwordLength: "Your password contain between 8 and 50 characters.", | |||
| signUpRecommendation: "Registrujte se", | |||
| email: "Please enter your email address or username to log in:", | |||
| logInTitle: "Prijava", | |||
| logIn: "Ulogujte se", | |||
| signUp: "Sign Up", | |||
| usernameRequired: "Username is required.", | |||
| passwordRequired: "A Password is required.", | |||
| forgotYourPassword: "Zaboravili ste šifru?", | |||
| forgotPasswordEmail: "Email", | |||
| useDifferentEmail: "Use different email address or username", | |||
| }, | |||
| password: { | |||
| weak: "weak", | |||
| average: "average", | |||
| good: "good", | |||
| strong: "strong", | |||
| }, | |||
| forgotPassword: { | |||
| title: "Forgot Password", | |||
| label: "Send email", | |||
| emailRequired: "An email is required.", | |||
| emailFormat: "Invalid email address format.", | |||
| forgotPassword: { | |||
| title: "Forgot Password", | |||
| subtitle: | |||
| "Please answer the security question to gain access to your account:", | |||
| label: "Reset Password", | |||
| }, | |||
| }, | |||
| notFound: { | |||
| text: "We're sorry but we couldn't find the page you were looking for.", | |||
| goBack: "Go back to homepage", | |||
| }, | |||
| errorPage: { | |||
| text: "We're sorry, an internal server error came up. Please be patient or try again later.", | |||
| goBack: "Go back to homepage", | |||
| logout: "Logout", | |||
| }, | |||
| apiErrors: { | |||
| ClientIpAddressIsNullOrEmpty: "Client Ip address is null or empty", | |||
| UsernameDoesNotExist: "Username does not exist", | |||
| WrongCredentials: "Wrong credentials", | |||
| SomethingWentWrong: "Something went wrong", | |||
| WrongPasswordAccountIsLocked: "Wrong credentials, account is locked", | |||
| AccountIsLocked: "Account is locked", | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,13 @@ | |||
| body { | |||
| margin: 0; | |||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', | |||
| 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', | |||
| sans-serif; | |||
| -webkit-font-smoothing: antialiased; | |||
| -moz-osx-font-smoothing: grayscale; | |||
| } | |||
| code { | |||
| font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | |||
| monospace; | |||
| } | |||
| @@ -0,0 +1,38 @@ | |||
| import React from "react"; | |||
| import ReactDOM from "react-dom/client"; | |||
| import "./index.css"; | |||
| import App from "./App"; | |||
| import reportWebVitals from "./reportWebVitals"; | |||
| import "./i18n"; | |||
| import { HelmetProvider } from "react-helmet-async"; | |||
| import { BrowserRouter, Route, Routes } from "react-router-dom"; | |||
| import { store, persistor } from "./features/store"; | |||
| import { Provider } from "react-redux"; | |||
| import { PersistGate } from "redux-persist/integration/react"; | |||
| import ColorModeProvider from "context/ColorModeContext"; | |||
| import { CssBaseline } from "@mui/material"; | |||
| const root = ReactDOM.createRoot(document.getElementById("root")); | |||
| root.render( | |||
| <HelmetProvider> | |||
| <React.StrictMode> | |||
| <Provider store={store}> | |||
| <ColorModeProvider> | |||
| <CssBaseline /> | |||
| <PersistGate loading={null} persistor={persistor}> | |||
| <BrowserRouter> | |||
| <Routes> | |||
| <Route path="/*" element={<App />} /> | |||
| </Routes> | |||
| </BrowserRouter> | |||
| </PersistGate> | |||
| </ColorModeProvider> | |||
| </Provider> | |||
| </React.StrictMode> | |||
| </HelmetProvider> | |||
| ); | |||
| // If you want to start measuring performance in your app, pass a function | |||
| // to log results (for example: reportWebVitals(console.log)) | |||
| // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals | |||
| reportWebVitals(); | |||
| @@ -0,0 +1,4 @@ | |||
| export default { | |||
| email: "", | |||
| }; | |||
| @@ -0,0 +1,4 @@ | |||
| export default { | |||
| email: "", | |||
| password: "", | |||
| }; | |||
| @@ -0,0 +1,5 @@ | |||
| export default { | |||
| username: "", | |||
| email: "", | |||
| password: "", | |||
| }; | |||
| @@ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> | |||
| @@ -0,0 +1,40 @@ | |||
| import React, { useEffect } from "react"; | |||
| import { useLocation, useNavigate, useParams } from "react-router-dom"; | |||
| import PropTypes from "prop-types"; | |||
| // import Backdrop from 'components/MUI/BackdropComponent'; | |||
| import { useProviderLoginQuery } from "features/provider/providerApiSlice"; | |||
| import { useDispatch } from "react-redux"; | |||
| import { setCredetnials } from "features/auth/authSlice"; | |||
| import BackdropComponent from "components/Backdrop/BackdropComponent"; | |||
| function AuthCallback() { | |||
| const { provider } = useParams(); | |||
| const location = useLocation(); | |||
| const dispatch = useDispatch(); | |||
| const navigate = useNavigate() | |||
| const { data, isLoading } = useProviderLoginQuery({ provider, search: location.search }); | |||
| useEffect(() => { | |||
| if (data?.jwt && data?.refreshToken && data?.user) { | |||
| dispatch(setCredetnials(data)); | |||
| navigate("/home", { replace: true }); | |||
| } | |||
| }, [data]); | |||
| return <div> | |||
| <BackdropComponent position="absolute" isLoading={isLoading} /> | |||
| </div>; | |||
| } | |||
| AuthCallback.propTypes = { | |||
| history: PropTypes.shape({ | |||
| replace: PropTypes.func, | |||
| push: PropTypes.func, | |||
| location: PropTypes.shape({ | |||
| pathname: PropTypes.string, | |||
| }), | |||
| }), | |||
| }; | |||
| export default AuthCallback; | |||
| @@ -0,0 +1,41 @@ | |||
| import React from "react"; | |||
| import NavbarComponent from "components/Navbar/NavbarComponent"; | |||
| import { Box, Grid } from "@mui/material"; | |||
| import Modals from "components/Modals/ModalsExample"; | |||
| import DataGridExample from "components/DataGrid/DataGridExample"; | |||
| import RandomDataProvider from "context/RandomDataContext"; | |||
| import PagingSortingFilteringExampleServerSide from "components/PagingSorting/PagingSortingFilteringExampleServerSide"; | |||
| import PagingSortingFilteringExample from "components/PagingSorting/PagingSortingFilteringExample"; | |||
| import { useAllPostsQuery } from "features/posts/postsApiSlice"; | |||
| import BackdropComponent from "components/Backdrop/BackdropComponent"; | |||
| const HomePage = () => { | |||
| const {data, isLoading} = useAllPostsQuery() | |||
| console.log('posts', data?.data) | |||
| return ( | |||
| <> | |||
| <NavbarComponent /> | |||
| <BackdropComponent position="absolute" isLoading={isLoading} /> | |||
| <Box sx={{ mt: 4, mx: 4 }}> | |||
| <Grid container spacing={2} justifyContent="center"> | |||
| <Grid item xs={12} md={3}> | |||
| <Modals /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <DataGridExample /> | |||
| </Grid> | |||
| <Grid item xs={12} md={9}> | |||
| <PagingSortingFilteringExample /> | |||
| </Grid> | |||
| <Grid item xs={12} md={9}> | |||
| {/* Move to higher components? */} | |||
| <RandomDataProvider> | |||
| <PagingSortingFilteringExampleServerSide /> | |||
| </RandomDataProvider> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </> | |||
| ); | |||
| }; | |||
| export default HomePage; | |||
| @@ -0,0 +1,180 @@ | |||
| import React, { useState } from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { useFormik } from "formik"; | |||
| import { NavLink } from "react-router-dom"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { FORGOT_PASSWORD_PAGE, REGISTER_PAGE } from "constants/pages"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Container, | |||
| Grid, | |||
| IconButton, | |||
| InputAdornment, | |||
| Link, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | |||
| import loginValidation from "validations/loginValidation"; | |||
| import loginInitialValues from "initialValues/loginInitialValues"; | |||
| import GoogleIcon from "@mui/icons-material/Google"; | |||
| import { useLoginMutation } from "features/auth/authApiSlice"; | |||
| import BackdropComponent from "components/Backdrop/BackdropComponent"; | |||
| import { useDispatch } from "react-redux"; | |||
| import { setCredetnials } from "features/auth/authSlice"; | |||
| import { makeErrorToastMessage } from "util/toastMessage"; | |||
| import { useNavigate } from "react-router-dom"; | |||
| const LoginPage = () => { | |||
| const { t } = useTranslation(); | |||
| const navigate = useNavigate(); | |||
| const [login, { isLoading }] = useLoginMutation(); | |||
| const dispatch = useDispatch(); | |||
| const [showPassword, setShowPassword] = useState(false); | |||
| const handleClickShowPassword = () => setShowPassword(!showPassword); | |||
| const handleMouseDownPassword = () => setShowPassword(!showPassword); | |||
| const handleGoogle = () => { | |||
| window.location = "http://localhost:1337/api/connect/google"; | |||
| }; | |||
| const handleSubmit = async (values) => { | |||
| const { email: identifier, password } = values; | |||
| try { | |||
| const userData = await login({ identifier, password }).unwrap(); | |||
| dispatch(setCredetnials(userData)); | |||
| navigate("/home", { replace: true }); | |||
| } catch (err) { | |||
| makeErrorToastMessage(err.data.error.message); | |||
| } | |||
| }; | |||
| const formik = useFormik({ | |||
| initialValues: loginInitialValues, | |||
| validationSchema: loginValidation, | |||
| onSubmit: handleSubmit, | |||
| validateOnBlur: true, | |||
| enableReinitialize: true, | |||
| }); | |||
| return ( | |||
| <Container component="main" maxWidth="md"> | |||
| <Box | |||
| sx={{ | |||
| marginTop: 32, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| alignItems: "center", | |||
| }} | |||
| > | |||
| <Typography component="h1" variant="h5"> | |||
| {t("login.logInTitle")} | |||
| </Typography> | |||
| {/* {error && <ErrorMessage error={error} />} */} | |||
| <Box | |||
| component="form" | |||
| onSubmit={formik.handleSubmit} | |||
| sx={{ position: "relative", mt: 1, p: 1 }} | |||
| > | |||
| <BackdropComponent position="absolute" isLoading={isLoading} /> | |||
| <TextField | |||
| name="email" | |||
| label={t("common.labelEmail")} | |||
| margin="normal" | |||
| value={formik.values.email} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.email && Boolean(formik.errors.email)} | |||
| helperText={formik.touched.email && formik.errors.email} | |||
| autoFocus | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| name="password" | |||
| label={t("common.labelPassword")} | |||
| margin="normal" | |||
| type={showPassword ? "text" : "password"} | |||
| value={formik.values.password} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.password && Boolean(formik.errors.password)} | |||
| helperText={formik.touched.password && formik.errors.password} | |||
| fullWidth | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end"> | |||
| <IconButton | |||
| onClick={handleClickShowPassword} | |||
| onMouseDown={handleMouseDownPassword} | |||
| > | |||
| {showPassword ? <Visibility /> : <VisibilityOff />} | |||
| </IconButton> | |||
| </InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| <Button | |||
| type="submit" | |||
| variant="contained" | |||
| sx={{ mt: 3, mb: 2 }} | |||
| fullWidth | |||
| > | |||
| {t("login.logIn")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleGoogle} | |||
| startIcon={<GoogleIcon />} | |||
| fullWidth | |||
| variant="outlined" | |||
| > | |||
| Connect with Google | |||
| </Button> | |||
| <Grid container> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| md={6} | |||
| sx={{ textAlign: { xs: "center", md: "left" } }} | |||
| > | |||
| <Link | |||
| to={FORGOT_PASSWORD_PAGE} | |||
| component={NavLink} | |||
| variant="body2" | |||
| underline="hover" | |||
| > | |||
| {t("login.forgotYourPassword")} | |||
| </Link> | |||
| </Grid> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| md={6} | |||
| sx={{ textAlign: { xs: "center", md: "right" } }} | |||
| > | |||
| <Link | |||
| to={REGISTER_PAGE} | |||
| component={NavLink} | |||
| variant="body2" | |||
| underline="hover" | |||
| > | |||
| {t("login.dontHaveAccount")} | |||
| </Link> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </Box> | |||
| </Container> | |||
| ); | |||
| }; | |||
| LoginPage.propTypes = { | |||
| history: PropTypes.shape({ | |||
| replace: PropTypes.func, | |||
| push: PropTypes.func, | |||
| location: PropTypes.shape({ | |||
| pathname: PropTypes.string, | |||
| }), | |||
| }), | |||
| }; | |||
| export default LoginPage; | |||
| @@ -0,0 +1,186 @@ | |||
| /* eslint-disable */ | |||
| import React, { useState } from "react"; | |||
| import PropTypes from "prop-types"; | |||
| import { useFormik } from "formik"; | |||
| import { NavLink } from "react-router-dom"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { FORGOT_PASSWORD_PAGE, LOGIN_PAGE } from "constants/pages"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Container, | |||
| Grid, | |||
| IconButton, | |||
| InputAdornment, | |||
| Link, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | |||
| import GoogleIcon from "@mui/icons-material/Google"; | |||
| import registerInitialValues from "initialValues/registerInitialValues"; | |||
| import registerValidation from "validations/registerValidation"; | |||
| import { makeErrorToastMessage, makeToastMessage } from "util/toastMessage"; | |||
| import { useRegisterMutation } from "features/register/registerApiSlice"; | |||
| import { useNavigate } from "react-router-dom"; | |||
| import BackdropComponent from "components/Backdrop/BackdropComponent"; | |||
| const RegisterPage = () => { | |||
| const { t } = useTranslation(); | |||
| const navigate = useNavigate(); | |||
| const [showPassword, setShowPassword] = useState(false); | |||
| const handleClickShowPassword = () => setShowPassword(!showPassword); | |||
| const handleMouseDownPassword = () => setShowPassword(!showPassword); | |||
| const [register, { isLoading }] = useRegisterMutation(); | |||
| const handleGoogle = () => { | |||
| window.location = "http://localhost:1337/api/connect/google"; | |||
| }; | |||
| const handleSubmit = async (values) => { | |||
| const { username, email, password } = values; | |||
| try { | |||
| await register({ username, email, password }).unwrap(); | |||
| makeToastMessage("User successfuly registered. Please login."); | |||
| navigate("/login"); | |||
| } catch (err) { | |||
| makeErrorToastMessage(err.data.error.message); | |||
| } | |||
| }; | |||
| const formik = useFormik({ | |||
| initialValues: registerInitialValues, | |||
| validationSchema: registerValidation, | |||
| onSubmit: handleSubmit, | |||
| validateOnBlur: true, | |||
| enableReinitialize: true, | |||
| }); | |||
| return ( | |||
| <Container component="main" maxWidth="md"> | |||
| <Box | |||
| sx={{ | |||
| marginTop: 32, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| alignItems: "center", | |||
| }} | |||
| > | |||
| <Typography component="h1" variant="h5"> | |||
| {t("register.registerTitle")} | |||
| </Typography> | |||
| <Box | |||
| component="form" | |||
| onSubmit={formik.handleSubmit} | |||
| sx={{ position: "relative", mt: 1, p: 1 }} | |||
| > | |||
| <BackdropComponent position="absolute" isLoading={isLoading} /> | |||
| <TextField | |||
| name="username" | |||
| label={t("common.labelUsername")} | |||
| margin="normal" | |||
| value={formik.values.username} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.username && Boolean(formik.errors.username)} | |||
| helperText={formik.touched.username && formik.errors.username} | |||
| autoFocus | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| name="email" | |||
| label={t("common.labelEmail")} | |||
| margin="normal" | |||
| value={formik.values.email} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.email && Boolean(formik.errors.email)} | |||
| helperText={formik.touched.email && formik.errors.email} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| name="password" | |||
| label={t("common.labelPassword")} | |||
| margin="normal" | |||
| type={showPassword ? "text" : "password"} | |||
| value={formik.values.password} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.password && Boolean(formik.errors.password)} | |||
| helperText={formik.touched.password && formik.errors.password} | |||
| fullWidth | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end"> | |||
| <IconButton | |||
| onClick={handleClickShowPassword} | |||
| onMouseDown={handleMouseDownPassword} | |||
| > | |||
| {showPassword ? <Visibility /> : <VisibilityOff />} | |||
| </IconButton> | |||
| </InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| <Button | |||
| type="submit" | |||
| variant="contained" | |||
| sx={{ mt: 3, mb: 2 }} | |||
| fullWidth | |||
| > | |||
| {t("register.registerTitle")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleGoogle} | |||
| startIcon={<GoogleIcon />} | |||
| fullWidth | |||
| variant="outlined" | |||
| > | |||
| Connect with Google | |||
| </Button> | |||
| <Grid container> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| md={6} | |||
| sx={{ textAlign: { xs: "center", md: "left" } }} | |||
| > | |||
| <Link | |||
| to={FORGOT_PASSWORD_PAGE} | |||
| component={NavLink} | |||
| variant="body2" | |||
| underline="hover" | |||
| > | |||
| {t("login.forgotYourPassword")} | |||
| </Link> | |||
| </Grid> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| md={6} | |||
| sx={{ textAlign: { xs: "center", md: "right" } }} | |||
| > | |||
| <Link | |||
| to={LOGIN_PAGE} | |||
| component={NavLink} | |||
| variant="body2" | |||
| underline="hover" | |||
| > | |||
| {t("login.logIn")} | |||
| </Link> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </Box> | |||
| </Container> | |||
| ); | |||
| }; | |||
| RegisterPage.propTypes = { | |||
| history: PropTypes.shape({ | |||
| replace: PropTypes.func, | |||
| push: PropTypes.func, | |||
| location: PropTypes.shape({ | |||
| pathname: PropTypes.string, | |||
| }), | |||
| }), | |||
| }; | |||
| export default RegisterPage; | |||
| @@ -0,0 +1,13 @@ | |||
| const reportWebVitals = onPerfEntry => { | |||
| if (onPerfEntry && onPerfEntry instanceof Function) { | |||
| import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { | |||
| getCLS(onPerfEntry); | |||
| getFID(onPerfEntry); | |||
| getFCP(onPerfEntry); | |||
| getLCP(onPerfEntry); | |||
| getTTFB(onPerfEntry); | |||
| }); | |||
| } | |||
| }; | |||
| export default reportWebVitals; | |||
| @@ -0,0 +1,5 @@ | |||
| // jest-dom adds custom jest matchers for asserting on DOM nodes. | |||
| // allows you to do things like: | |||
| // expect(element).toHaveTextContent(/react/i) | |||
| // learn more: https://github.com/testing-library/jest-dom | |||
| import '@testing-library/jest-dom'; | |||
| @@ -0,0 +1,13 @@ | |||
| import primary from "./primaryTheme/primaryTheme"; | |||
| let selectedThemeNumber = 0; | |||
| const getTheme = () => { | |||
| if (selectedThemeNumber === 0) { | |||
| return {...primary} | |||
| } | |||
| } | |||
| const selectedTheme = getTheme(); | |||
| export default selectedTheme; | |||
| @@ -0,0 +1,7 @@ | |||
| import { primaryThemeColors } from "./primaryThemeColors"; | |||
| const primary = { | |||
| colors: primaryThemeColors, | |||
| }; | |||
| export default primary; | |||
| @@ -0,0 +1,7 @@ | |||
| export const primaryThemeColors = { | |||
| primaryLight: "#673ab7", | |||
| primaryDark: "#009688", | |||
| secondaryLight: "#212121", | |||
| secondaryDark: "#f5f5f5", | |||
| }; | |||
| @@ -0,0 +1,19 @@ | |||
| export function authScopeGetHelper(key) { | |||
| return JSON.parse(localStorage.getItem(key)); | |||
| } | |||
| export function authScopeStringGetHelper(key) { | |||
| return localStorage.getItem(key); | |||
| } | |||
| export function authScopeSetHelper(key, value) { | |||
| localStorage.setItem(key, value); | |||
| } | |||
| export function authScopeRemoveHelper(key) { | |||
| localStorage.removeItem(key); | |||
| } | |||
| export function authScopeClearHelper() { | |||
| localStorage.clear(); | |||
| } | |||
| @@ -0,0 +1,40 @@ | |||
| import { format } from "date-fns"; | |||
| import { enUS } from "date-fns/locale"; | |||
| import i18next from "i18next"; | |||
| export function formatDate(date, fmt = "MM/dd/y", locale = enUS) { | |||
| const dt = new Date(date); | |||
| return format(dt, fmt, { locale }); | |||
| } | |||
| export function formatDateTime(date) { | |||
| const dt = new Date(date); | |||
| return format(dt, "MM/dd/y hh:mm aa"); | |||
| } | |||
| export function getDateDay(date) { | |||
| const dt = new Date(date); | |||
| return format(dt, "dd"); | |||
| } | |||
| export function getDateMonth(date) { | |||
| const dt = new Date(date); | |||
| return format(dt, "MM"); | |||
| } | |||
| export function getDateYear(date) { | |||
| const dt = new Date(date); | |||
| return format(dt, "y"); | |||
| } | |||
| export function formatDateTimeLocale(date) { | |||
| const dt = new Date(date); | |||
| return format(dt, "MM/dd/y hh:mm aa"); | |||
| } | |||
| // TODO add locale | |||
| export function formatDateRange(dates) { | |||
| const start = formatDate(dates.start); | |||
| const end = formatDate(dates.end); | |||
| return i18next.t("common.date.range", { start, end }); | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| export const parseEnumType = (typeArray, index) => typeArray[index - 1]; | |||
| @@ -0,0 +1,65 @@ | |||
| const random = (arr) => { | |||
| return arr[Math.floor(Math.random() * arr.length)]; | |||
| }; | |||
| const size = () => { | |||
| return random(["Extra Small", "Small", "Medium", "Large", "Extra Large"]); | |||
| }; | |||
| const color = () => { | |||
| return random(["Red", "Green", "Blue", "Orange", "Yellow"]); | |||
| }; | |||
| const designer = () => { | |||
| return random([ | |||
| "Ralph Lauren", | |||
| "Alexander Wang", | |||
| "Grayse", | |||
| "Marc NY Performance", | |||
| "Scrapbook", | |||
| "J Brand Ready to Wear", | |||
| "Vintage Havana", | |||
| "Neiman Marcus Cashmere Collection", | |||
| "Derek Lam 10 Crosby", | |||
| "Jordan", | |||
| ]); | |||
| }; | |||
| const type = () => { | |||
| return random([ | |||
| "Cashmere", | |||
| "Cardigans", | |||
| "Crew and Scoop", | |||
| "V-Neck", | |||
| "Shoes", | |||
| "Cowl & Turtleneck", | |||
| ]); | |||
| }; | |||
| const price = () => { | |||
| return (Math.random() * 100).toFixed(2); | |||
| }; | |||
| function generate(count) { | |||
| const data = []; | |||
| for (let i = 0; i < count; i++) { | |||
| const currentColor = color(); | |||
| const currentSize = size(); | |||
| const currentType = type(); | |||
| const currentDesigner = designer(); | |||
| const currentPrice = price(); | |||
| data.push({ | |||
| name: `${currentDesigner} ${currentType} ${currentColor} ${currentSize}`, | |||
| color: currentColor, | |||
| size: currentSize, | |||
| designer: currentDesigner, | |||
| type: currentType, | |||
| price: currentPrice, | |||
| salesPrice: currentPrice, | |||
| }); | |||
| } | |||
| return data; | |||
| } | |||
| export default generate; | |||
| @@ -0,0 +1,11 @@ | |||
| export function separateByUppercase(string) { | |||
| return string.split(/(?=[A-Z])/).join(" "); | |||
| } | |||
| export function separateByUnderscore(string) { | |||
| return string.replaceAll("_", " "); | |||
| } | |||
| export function joinArrayWithComma(arr) { | |||
| return arr.join(", "); | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| import { toast } from "react-toastify"; | |||
| const defaultOptions = { | |||
| position: "top-center", | |||
| autoClose: 3000, | |||
| hideProgressBar: true, | |||
| closeOnClick: true, | |||
| pauseOnHover: true, | |||
| pauseOnFocusLoss: false, | |||
| draggable: true, | |||
| }; | |||
| export const makeToastMessage = (message, options = defaultOptions) => | |||
| toast(message, options); | |||
| export const makeErrorToastMessage = (message, options = defaultOptions) => | |||
| toast.error(message, options); | |||
| @@ -0,0 +1,8 @@ | |||
| import * as Yup from "yup"; | |||
| import i18next from "i18next"; | |||
| export default Yup.object().shape({ | |||
| email: Yup.string() | |||
| .email(i18next.t("login.emailFormat")) | |||
| .required(i18next.t("login.emailRequired")), | |||
| }); | |||
| @@ -0,0 +1,11 @@ | |||
| import * as Yup from "yup"; | |||
| import i18next from "i18next"; | |||
| export default Yup.object().shape({ | |||
| email: Yup.string() | |||
| .email(i18next.t("login.emailFormat")) | |||
| .required(i18next.t("login.emailRequired")), | |||
| password: Yup.string() | |||
| .required(i18next.t("login.passwordRequired")) | |||
| .min(8, i18next.t("login.passwordLength")), | |||
| }); | |||
| @@ -0,0 +1,12 @@ | |||
| import * as Yup from "yup"; | |||
| import i18next from "i18next"; | |||
| export default Yup.object().shape({ | |||
| username: Yup.string().required("register.usernameRequired"), | |||
| email: Yup.string() | |||
| .email(i18next.t("register.emailFormat")) | |||
| .required(i18next.t("register.emailRequired")), | |||
| password: Yup.string() | |||
| .required(i18next.t("register.passwordRequired")) | |||
| .min(8, i18next.t("register.passwordLength")), | |||
| }); | |||