From 70f860e7b110f2524a7319b3339fac4c5d621ffa Mon Sep 17 00:00:00 2001 From: Yann Stepienik Date: Sun, 12 Mar 2023 18:17:28 +0000 Subject: [PATCH] Add UI and login screen --- .circleci/config.yml | 17 + .gitignore | 2 + .nvmrc | 1 + client/TEMPLATE LICENSE | 25 ++ client/index.html | 13 + client/src/App.jsx | 18 + client/src/App.test.jsx | 9 + client/src/api/authentication.jsx | 26 ++ client/src/api/index.jsx | 4 + .../src/assets/images/auth/AuthBackground.jsx | 18 + client/src/assets/images/icons/discord(1).svg | 1 + client/src/assets/images/icons/discord.svg | 1 + client/src/assets/images/icons/facebook.svg | 3 + client/src/assets/images/icons/google.svg | 6 + client/src/assets/images/icons/twitter.svg | 3 + client/src/assets/images/users/avatar-1.png | Bin 0 -> 9490 bytes client/src/assets/images/users/avatar-2.png | Bin 0 -> 9589 bytes client/src/assets/images/users/avatar-3.png | Bin 0 -> 7112 bytes client/src/assets/images/users/avatar-4.png | Bin 0 -> 9192 bytes .../src/assets/images/users/avatar-group.png | Bin 0 -> 8828 bytes client/src/assets/third-party/apex-chart.css | 4 + .../components/@extended/AnimateButton.jsx | 29 ++ .../src/components/@extended/Breadcrumbs.jsx | 106 ++++++ client/src/components/@extended/Dot.jsx | 48 +++ .../src/components/@extended/Transitions.jsx | 62 +++ client/src/components/Loadable.jsx | 15 + client/src/components/Loader.jsx | 25 ++ client/src/components/Logo/Logo.jsx | 26 ++ client/src/components/Logo/index.jsx | 24 ++ client/src/components/MainCard.jsx | 109 ++++++ client/src/components/ScrollTop.jsx | 26 ++ client/src/components/cards/AuthFooter.jsx | 22 ++ .../cards/statistics/AnalyticEcommerce.jsx | 70 ++++ .../components/third-party/Highlighter.jsx | 65 ++++ .../src/components/third-party/SimpleBar.jsx | 62 +++ client/src/config.jsx | 19 + client/src/index.jsx | 36 ++ client/src/isLoggedIn.jsx | 13 + .../Drawer/DrawerContent/NavCard.jsx | 31 ++ .../DrawerContent/Navigation/NavGroup.jsx | 59 +++ .../DrawerContent/Navigation/NavItem.jsx | 146 +++++++ .../Drawer/DrawerContent/Navigation/index.jsx | 27 ++ .../MainLayout/Drawer/DrawerContent/index.jsx | 22 ++ .../DrawerHeader/DrawerHeaderStyled.jsx | 15 + .../MainLayout/Drawer/DrawerHeader/index.jsx | 38 ++ .../MainLayout/Drawer/MiniDrawerStyled.jsx | 47 +++ client/src/layout/MainLayout/Drawer/index.jsx | 66 ++++ .../layout/MainLayout/Header/AppBarStyled.jsx | 26 ++ .../Header/HeaderContent/MobileSection.jsx | 102 +++++ .../Header/HeaderContent/Notification.jsx | 278 ++++++++++++++ .../HeaderContent/Profile/ProfileTab.jsx | 62 +++ .../HeaderContent/Profile/SettingTab.jsx | 56 +++ .../Header/HeaderContent/Profile/index.jsx | 212 +++++++++++ .../Header/HeaderContent/Search.jsx | 30 ++ .../MainLayout/Header/HeaderContent/index.jsx | 28 ++ client/src/layout/MainLayout/Header/index.jsx | 69 ++++ client/src/layout/MainLayout/index.jsx | 60 +++ client/src/layout/MinimalLayout/index.jsx | 11 + client/src/main.css | 0 client/src/main.tsx | 9 + client/src/menu-items/dashboard.jsx | 27 ++ client/src/menu-items/index.jsx | 12 + client/src/menu-items/pages.jsx | 42 ++ client/src/menu-items/support.jsx | 48 +++ client/src/pages/authentication/AuthCard.jsx | 35 ++ .../src/pages/authentication/AuthWrapper.jsx | 55 +++ client/src/pages/authentication/Login.jsx | 30 ++ client/src/pages/authentication/Register.jsx | 30 ++ .../authentication/auth-forms/AuthLogin.jsx | 226 +++++++++++ .../auth-forms/AuthRegister.jsx | 271 +++++++++++++ .../auth-forms/FirebaseSocial.jsx | 66 ++++ .../pages/components-overview/AntIcons.jsx | 24 ++ .../src/pages/components-overview/Color.jsx | 141 +++++++ .../components-overview/ComponentSkeleton.jsx | 59 +++ .../src/pages/components-overview/Shadow.jsx | 152 ++++++++ .../pages/components-overview/Typography.jsx | 263 +++++++++++++ .../src/pages/dashboard/IncomeAreaChart.jsx | 121 ++++++ .../src/pages/dashboard/MonthlyBarChart.jsx | 85 +++++ client/src/pages/dashboard/OrdersTable.jsx | 224 +++++++++++ .../src/pages/dashboard/ReportAreaChart.jsx | 105 +++++ .../src/pages/dashboard/SalesColumnChart.jsx | 148 ++++++++ client/src/pages/dashboard/index.jsx | 358 ++++++++++++++++++ client/src/pages/extra-pages/SamplePage.jsx | 20 + client/src/react-app-env.d.jsx | 1 + client/src/reportWebVitals.jsx | 13 + client/src/routes/LoginRoutes.jsx | 28 ++ client/src/routes/MainRoutes.jsx | 61 +++ client/src/routes/index.jsx | 11 + client/src/setupTests.jsx | 5 + client/src/store/index.jsx | 15 + client/src/store/reducers/actions.jsx | 4 + client/src/store/reducers/index.jsx | 11 + client/src/store/reducers/menu.jsx | 38 ++ client/src/themes/index.jsx | 64 ++++ client/src/themes/overrides/Badge.jsx | 15 + client/src/themes/overrides/Button.jsx | 28 ++ client/src/themes/overrides/CardContent.jsx | 16 + client/src/themes/overrides/Checkbox.jsx | 13 + client/src/themes/overrides/Chip.jsx | 40 ++ client/src/themes/overrides/IconButton.jsx | 28 ++ client/src/themes/overrides/InputLabel.jsx | 25 ++ .../src/themes/overrides/LinearProgress.jsx | 17 + client/src/themes/overrides/Link.jsx | 11 + client/src/themes/overrides/ListItemIcon.jsx | 13 + client/src/themes/overrides/OutlinedInput.jsx | 47 +++ client/src/themes/overrides/Tab.jsx | 14 + client/src/themes/overrides/TableCell.jsx | 20 + client/src/themes/overrides/Tabs.jsx | 13 + client/src/themes/overrides/Typography.jsx | 13 + client/src/themes/overrides/index.jsx | 41 ++ client/src/themes/palette.jsx | 60 +++ client/src/themes/shadows.jsx | 13 + client/src/themes/theme/index.jsx | 92 +++++ client/src/themes/typography.jsx | 71 ++++ client/src/utils/SyntaxHighlight.jsx | 19 + client/src/utils/password-strength.jsx | 29 ++ dockerfile | 1 + dockerfile.arm64 | 1 + gupm.json | 62 ++- readme.md | 3 +- src/httpServer.go | 33 +- src/proxy/buildFromConfig.go | 3 +- src/proxy/routerGen.go | 4 +- src/user/get.go | 4 + src/user/me.go | 17 + src/user/token.go | 12 +- src/utils/db.go | 2 +- src/utils/log.go | 12 +- src/utils/types.go | 3 + src/utils/utils.go | 3 + vite.config.js | 19 + 131 files changed, 5888 insertions(+), 24 deletions(-) create mode 100644 .nvmrc create mode 100644 client/TEMPLATE LICENSE create mode 100644 client/index.html create mode 100644 client/src/App.jsx create mode 100644 client/src/App.test.jsx create mode 100644 client/src/api/authentication.jsx create mode 100644 client/src/api/index.jsx create mode 100644 client/src/assets/images/auth/AuthBackground.jsx create mode 100644 client/src/assets/images/icons/discord(1).svg create mode 100644 client/src/assets/images/icons/discord.svg create mode 100644 client/src/assets/images/icons/facebook.svg create mode 100644 client/src/assets/images/icons/google.svg create mode 100644 client/src/assets/images/icons/twitter.svg create mode 100644 client/src/assets/images/users/avatar-1.png create mode 100644 client/src/assets/images/users/avatar-2.png create mode 100644 client/src/assets/images/users/avatar-3.png create mode 100644 client/src/assets/images/users/avatar-4.png create mode 100644 client/src/assets/images/users/avatar-group.png create mode 100644 client/src/assets/third-party/apex-chart.css create mode 100644 client/src/components/@extended/AnimateButton.jsx create mode 100644 client/src/components/@extended/Breadcrumbs.jsx create mode 100644 client/src/components/@extended/Dot.jsx create mode 100644 client/src/components/@extended/Transitions.jsx create mode 100644 client/src/components/Loadable.jsx create mode 100644 client/src/components/Loader.jsx create mode 100644 client/src/components/Logo/Logo.jsx create mode 100644 client/src/components/Logo/index.jsx create mode 100644 client/src/components/MainCard.jsx create mode 100644 client/src/components/ScrollTop.jsx create mode 100644 client/src/components/cards/AuthFooter.jsx create mode 100644 client/src/components/cards/statistics/AnalyticEcommerce.jsx create mode 100644 client/src/components/third-party/Highlighter.jsx create mode 100644 client/src/components/third-party/SimpleBar.jsx create mode 100644 client/src/config.jsx create mode 100644 client/src/index.jsx create mode 100644 client/src/isLoggedIn.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerContent/NavCard.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/index.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerContent/index.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerHeader/DrawerHeaderStyled.jsx create mode 100644 client/src/layout/MainLayout/Drawer/DrawerHeader/index.jsx create mode 100644 client/src/layout/MainLayout/Drawer/MiniDrawerStyled.jsx create mode 100644 client/src/layout/MainLayout/Drawer/index.jsx create mode 100644 client/src/layout/MainLayout/Header/AppBarStyled.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/MobileSection.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/Notification.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/Profile/ProfileTab.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/Profile/SettingTab.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/Profile/index.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/Search.jsx create mode 100644 client/src/layout/MainLayout/Header/HeaderContent/index.jsx create mode 100644 client/src/layout/MainLayout/Header/index.jsx create mode 100644 client/src/layout/MainLayout/index.jsx create mode 100644 client/src/layout/MinimalLayout/index.jsx create mode 100644 client/src/main.css create mode 100644 client/src/main.tsx create mode 100644 client/src/menu-items/dashboard.jsx create mode 100644 client/src/menu-items/index.jsx create mode 100644 client/src/menu-items/pages.jsx create mode 100644 client/src/menu-items/support.jsx create mode 100644 client/src/pages/authentication/AuthCard.jsx create mode 100644 client/src/pages/authentication/AuthWrapper.jsx create mode 100644 client/src/pages/authentication/Login.jsx create mode 100644 client/src/pages/authentication/Register.jsx create mode 100644 client/src/pages/authentication/auth-forms/AuthLogin.jsx create mode 100644 client/src/pages/authentication/auth-forms/AuthRegister.jsx create mode 100644 client/src/pages/authentication/auth-forms/FirebaseSocial.jsx create mode 100644 client/src/pages/components-overview/AntIcons.jsx create mode 100644 client/src/pages/components-overview/Color.jsx create mode 100644 client/src/pages/components-overview/ComponentSkeleton.jsx create mode 100644 client/src/pages/components-overview/Shadow.jsx create mode 100644 client/src/pages/components-overview/Typography.jsx create mode 100644 client/src/pages/dashboard/IncomeAreaChart.jsx create mode 100644 client/src/pages/dashboard/MonthlyBarChart.jsx create mode 100644 client/src/pages/dashboard/OrdersTable.jsx create mode 100644 client/src/pages/dashboard/ReportAreaChart.jsx create mode 100644 client/src/pages/dashboard/SalesColumnChart.jsx create mode 100644 client/src/pages/dashboard/index.jsx create mode 100644 client/src/pages/extra-pages/SamplePage.jsx create mode 100644 client/src/react-app-env.d.jsx create mode 100644 client/src/reportWebVitals.jsx create mode 100644 client/src/routes/LoginRoutes.jsx create mode 100644 client/src/routes/MainRoutes.jsx create mode 100644 client/src/routes/index.jsx create mode 100644 client/src/setupTests.jsx create mode 100644 client/src/store/index.jsx create mode 100644 client/src/store/reducers/actions.jsx create mode 100644 client/src/store/reducers/index.jsx create mode 100644 client/src/store/reducers/menu.jsx create mode 100644 client/src/themes/index.jsx create mode 100644 client/src/themes/overrides/Badge.jsx create mode 100644 client/src/themes/overrides/Button.jsx create mode 100644 client/src/themes/overrides/CardContent.jsx create mode 100644 client/src/themes/overrides/Checkbox.jsx create mode 100644 client/src/themes/overrides/Chip.jsx create mode 100644 client/src/themes/overrides/IconButton.jsx create mode 100644 client/src/themes/overrides/InputLabel.jsx create mode 100644 client/src/themes/overrides/LinearProgress.jsx create mode 100644 client/src/themes/overrides/Link.jsx create mode 100644 client/src/themes/overrides/ListItemIcon.jsx create mode 100644 client/src/themes/overrides/OutlinedInput.jsx create mode 100644 client/src/themes/overrides/Tab.jsx create mode 100644 client/src/themes/overrides/TableCell.jsx create mode 100644 client/src/themes/overrides/Tabs.jsx create mode 100644 client/src/themes/overrides/Typography.jsx create mode 100644 client/src/themes/overrides/index.jsx create mode 100644 client/src/themes/palette.jsx create mode 100644 client/src/themes/shadows.jsx create mode 100644 client/src/themes/theme/index.jsx create mode 100644 client/src/themes/typography.jsx create mode 100644 client/src/utils/SyntaxHighlight.jsx create mode 100644 client/src/utils/password-strength.jsx create mode 100644 src/user/me.go create mode 100644 vite.config.js diff --git a/.circleci/config.yml b/.circleci/config.yml index cb873be..fca431a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,6 +23,18 @@ jobs: name: set Go path command: echo 'export PATH=$PATH:/usr/local/go/bin' >> $BASH_ENV + - run: + name: install Node 1/3 + command: curl -sL https://deb.nodesource.com/setup_16.x -o nodesource_setup.sh + + - run: + name: install Node 2/3 + command: sudo bash ./nodesource_setup.sh + + - run: + name: install Node 3/3 + command: sudo apt-get install -y nodejs + - run: name: Install GuPM command: curl -fsSL https://azukaar.github.io/GuPM/install.sh | bash @@ -36,6 +48,11 @@ jobs: - run: name: Install dependencies command: ~/.gupm/gupm/g make + + - run: + name: Build UI + command: g vite build + - run: name: Build Linux (ARM) command: ~/.gupm/gupm/g ci/build linux arm64 diff --git a/.gitignore b/.gitignore index 7aec21b..0c7870e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,10 @@ localcert.crt localcert.key .vite dev.json +static .bin client/dist +client/.vite config_dev.json tests todo.txt diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..19c7bdb --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16 \ No newline at end of file diff --git a/client/TEMPLATE LICENSE b/client/TEMPLATE LICENSE new file mode 100644 index 0000000..453768a --- /dev/null +++ b/client/TEMPLATE LICENSE @@ -0,0 +1,25 @@ +Built from original template from CodedThemes. +The template was distributed with the licence. +This licence does not cover the changes made to the template: + +MIT License + +Copyright (c) 2022 CodedThemes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..44adf8a --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Cosmos + + +
+ + + diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..5f2615d --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,18 @@ +// project import +import Routes from './routes'; +import ThemeCustomization from './themes'; +import ScrollTop from './components/ScrollTop'; +// ==============================|| APP - THEME, ROUTER, LOCAL ||============================== // + +const App = () => { + + return ( + + + + + + ) +} + +export default App; diff --git a/client/src/App.test.jsx b/client/src/App.test.jsx new file mode 100644 index 0000000..414b21c --- /dev/null +++ b/client/src/App.test.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/client/src/api/authentication.jsx b/client/src/api/authentication.jsx new file mode 100644 index 0000000..821f5f3 --- /dev/null +++ b/client/src/api/authentication.jsx @@ -0,0 +1,26 @@ + +function login(values) { + return fetch('/cosmos/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(values) + }) + .then((res) => res.json()) +} + +function me() { + return fetch('/cosmos/api/me/', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + .then((res) => res.json()) +} + +export { + login, + me +}; \ No newline at end of file diff --git a/client/src/api/index.jsx b/client/src/api/index.jsx new file mode 100644 index 0000000..97dd64a --- /dev/null +++ b/client/src/api/index.jsx @@ -0,0 +1,4 @@ +import * as auth from './authentication.jsx'; +export { + auth +}; \ No newline at end of file diff --git a/client/src/assets/images/auth/AuthBackground.jsx b/client/src/assets/images/auth/AuthBackground.jsx new file mode 100644 index 0000000..b05f6ae --- /dev/null +++ b/client/src/assets/images/auth/AuthBackground.jsx @@ -0,0 +1,18 @@ +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Box } from '@mui/material'; + +import logo from '../../../../../logo.png'; + +// ==============================|| AUTH BLUR BACK SVG ||============================== // + +const AuthBackground = () => { + const theme = useTheme(); + return ( + + Cosmos + + ); +}; + +export default AuthBackground; diff --git a/client/src/assets/images/icons/discord(1).svg b/client/src/assets/images/icons/discord(1).svg new file mode 100644 index 0000000..4b74773 --- /dev/null +++ b/client/src/assets/images/icons/discord(1).svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/assets/images/icons/discord.svg b/client/src/assets/images/icons/discord.svg new file mode 100644 index 0000000..4b74773 --- /dev/null +++ b/client/src/assets/images/icons/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/assets/images/icons/facebook.svg b/client/src/assets/images/icons/facebook.svg new file mode 100644 index 0000000..6d4fd87 --- /dev/null +++ b/client/src/assets/images/icons/facebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/images/icons/google.svg b/client/src/assets/images/icons/google.svg new file mode 100644 index 0000000..bd30fd9 --- /dev/null +++ b/client/src/assets/images/icons/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/src/assets/images/icons/twitter.svg b/client/src/assets/images/icons/twitter.svg new file mode 100644 index 0000000..f868d36 --- /dev/null +++ b/client/src/assets/images/icons/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/images/users/avatar-1.png b/client/src/assets/images/users/avatar-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6f833448665d1cef05a78d3b555f11f69fc21185 GIT binary patch literal 9490 zcmV+tCGFaYP);Csu0CGTAI60ZEGKDGF%3%6Ov^yk-c_|midc@R#!1(%Oz zeh&*%QqAJ1NS9v^&h1OM)?{s;0+N1ew~e|eTb5Rq^ME%{Be3CD3@TXJq7$p6B& zg?yu>u4nOhfMc{R7vH>c8Grr5|BPTN3Gf_hfHG#8gFL~J38~|H?0lB%x|Tmf+q|&l zM`}>6qb94~$YIM2Q$DwCb#gg@{;Q24C+{8@uYHRD z^xysxjcOINnm=YedsiFGj8VOpal3j!MS#`Nigy#y= z8^j1=4Eau(9#d_*i1B(MKEk|yo1gFy{`TE>as88P9B*40Ry(I&VR_?NWTs*2TvL9eJV=?x^90L`pM%zhRc8pLGaOZ0pSab)gD-D`K8+v! z;#c@z?|*NS8~;49#-4wc*nl+2W8bPv7#IZ2H~4VCahK-J{HJ-j)xVN z#sU?0vWj%$;>J2Y{o*FxyK@)UrswF|Erh}Ww7B}V2~ic7vhwD+V`TLLVP2z(OKg1t@=i9L32_K)7_u5{}ixFI91Z^cpJGkXx z0SE8GSjjcE_nuAc`+r>h9ozq&i5C98QY_-5-+zP~)3dlfKd&wj2ym4hGf@SQdg6{F z&yMl{?|MyoUyLCTB&Y>=-2k7X(I}%$@WLe~xTx^=GWl3gFu_KX?|=N>Z&2RZ@v*@5 z99>P?)^jjMQ8*`;lyFrJBO|vhv)} z;p3TEHL!rK0Z}=YnpA**9nh}>wnBUVuf!~Iq(LtVSo(K@4fRHy=V~fLX@Dd+(s=Ud zir9$Jz+X(y<1s;3yGcvCzURb__uBG9TEBM8dNKt3#eKrv_j!bPh;93yc}wql;A^gC zb7FJLD_AcSm7&^dRj4*Y2+42`0Cy!<#{To-OBk`tKG|bftAQg+!1A+_%S9z;W zK{}N}g^LDaFxxI7vNGKNzg^}Oc#pKemj&rVli(j3JuU?3KXvq@T*j+$}lh(;Tl)*9nv)u=5wo5 zQebNtc(Pl#BY6Jw8Fb}(FgtaR*<%$~A8*2{Hxy%R zsdKkGOi|WQ!Uy{oz*d3K;^YVV2S}4GQwQh((G%>XcJX4{g~0*+TbXyziwQGl&GGF{ z>~yZUpGA2Dn&hNj#qC>PVsdRmks3)yDt9xjxdubzy8*t=MFRNN$uWH8naemhI*Nh8 zAsip*#_Ieu-Tpp)ap#d{n#LF5bU32hc1%XlD`y)bRuQ~0xaAi_-gU7ae0S_-^NCBXzN6v4k6w4^XJq^kgm949>Hms|*$;c{!F#;=nL!)f4U0dZs7thSn>V{)bAdLiS3pbrCvWvL;NzOLtY0gp$(V|jYDU9d@T(5&zqO(aqYbf;7J=BZI+Nu7fxJzAdKeUH-vcMXm9Flf76 zQCI_8(;CG-m8YA6$Zh!^ZR$i#g$1KrQ{1@bLSDXgd@ijGsSN1uOJA}2f6EjA&$ZDsih-aEcIYF;@6!e_ZA%q@ctwpT1@mv()2CV=61);DpaxpgPK zsTfMcF_=jqEQjoUI)VY8Ue>Vx{?*fX`ME1dWiyHlxkPu+19S#)$NYz0_fu zIFWRwQjwzz4$r;J#X_+-UOsmg$w*{huGR)3RmWdEJ%kIVPa&2_Ai!eB4n-7g7F3rQ z4vGfA--Z(zWPAG2KQM&v9qUEHHYAJ&SD51G_YN>S9_n*gx9;85F7N6Z#`VC^^I~iN zv!Ti=*;}>`UQZ&v>m#n0QZy#G9SaIxa56`a9>L+kL6ZhejPm7pf~bFi^?G+7!kI3F zx(5-=4XKBn%E5}KVMU?{#*;Km2;pcPUBhEIGky?1IGUkXNzLKs1YJqG`;6dv%*Dd3 z$%lG8UKqeJ^WP`rbmFt42Uy+-y)?3)?HB-7g_1Iq;Rv>NU9@zul%o3zd$Y`8&Aouj zL!1y-HIPW6VMnmFTg3Km3BgbR*+hhoI0Xrk9{$K7m%Z~p02m9}z6U8?&_iIAAxt>% zf(}7!{lOR5+1>EZET_v-!g*%24|XDp`;X?ZxSq%A_8#s&UcrNnZ6%f*8|uUBPo2T} z!v`?b(?wbwVwl8{?H|GDks7|8uVHd;7DcK*;-zh;LuNZtf{QTm;?eA!R_1~x=X>kv zxW>Z`__-!Eo6!cn)Zg){ea`Rho08@3n;x`mMkBOXuzc{#pP^MLDcMRiogk5AAZFv> zKp%GZ@?>~ToEhoDL^g~G3O6Hx8jY}pso7~fT3o;j69@25zxGv(j*h58s>+(;57SHd z_3SpCOuN17>5_(Ad1H~$M(I&~$plu`RxvViK!>ZL0+ydrU6W3|#n-GisL6v3pL6X) zUDvCd{U|hkw=d@lI6lfZV3U$63}V5PB>G($$zW*s01~kn+QmKG_~<&W&XDe=dQjtc zRthzwSgc&gaquXhzPFBgE`vG+)j1mG#{IjfQV%#t9NDS^8>G}hq1Pt;sWBPN@^DWt zdYDFoirkoTTqigm164ufIv#~Ii>{cU)sdDJhqp~JWWqwC!Zq%1xhB3v>e4!5gC%K& z{+6YDU2oT!zJNZazL!rNLWc2QFR(|jFoUbJn^*~EaQ)#D3!WBosW{qF?ygtxI=}C> zDr!>Iavqy2^N2WgWJzSSSk^o96f+oJZ?yw)xj>4$vmmdr^Hr= z@XQ3=T0|m@b`qowWfc)g4DYmP3UsvqcX(J28eY*k_0ZH0N(<7Dtshq;Y_J z#P(KPQC+Pt*&Z9T@c)axu4OPlp{DJxvQIfps_PxPaR5Qo#D_FMW^7tQow;PiRqh7S_x3aYIYBtHm~KQImvPG*&=8;d4z{NM=eXqxhO81>=~&Z37& zhdLl@hdT=^*q++OUP~rV?8#;^K0eN>GJ-@VeTq;0OMh^R`&7x{}G67K3H%B28K&}C|r z{y~JEknZbMXx(nsq=SPdCmN4MaOGqfKbihq0pAba_`1p_@t&MveU3j7pXK`cqzzvg zv9zxTpAZf9xw~cZy=(f0KKB--$MA;rEfN*R^3=*QzJKW{f?EsW8qqu4#X&c)x3Wka z7|?(ziG-7~Juf2{sO@d4RkK4;{W+f_tOs`27FiO9ROJ}tGug6|-W>v<^bZPll)z}= zJFk2N=`=-^%m^$NRa)lq+}!o!*7DN4<%P8Q%;y0H-Bnp^d!(pSGwbgvdsv<}+&!9*u!J<_oPVnY|lxBEIE2y0&x#PVU#HKJr^zktsF4&Apw8El5#pzpr?JT6|mj8HOz%*bIJ zo;X7AnnS`V^SQ)AX;*E8Xb1Hk0iAdPtPp##H9N%?xP=@^Q>D0zY9WsV8zOc%rZ!tR zM12W?CS~(FACFdc@Zszle(=(Bxcg`tdc%NL(gJVLrn{4qVL%vKpekl_06-Fb5V zAink7bGUrzB5vHijSEkm*W=Xkj`4CY&>cgM_p8dTpB96j#-?1~?^9WR`T#uk7f=(F z*m9WPXIXs>_w-?dY495qF@mCVS(d8Vd;0t7;(Lt$0zt7R7_PR!sNb`q3rAvXri4^G z?!@cgK>EQ=EX_QkXu?XcCxyCfw;^CQ<+l~q2{eGL;-k4G{N%IBRjK{gzZ z5sP*((9`X6d>4iX7(?hLuNTU0C!=vugRYq*{3>hTT8Y4Y$5If~HL|U9gqr?>yrpaG(6|E?{z~v%Sql*}iAL<@Kq}Eha zx{IvQR-L3KU7i>qt4~j;70dYBn{%jfEPwt-ud&dX|A52sMBvIcp)j50}6hV8Kc-MY$3&}Mh1>+mSE zK@4_73{Yd}v@x@?fe)68=t(9q(bL1?C#FC&%xW>p--|XAR3ebH>x>A}Y?+og{H-*) z+;8h*$QCihs63|b@ui~urKC?N`CpYv%_f_XCypM*@9*5hpT04KNGxJxFul!2ye3AK zXme`=88$wmWP)|Bc$s)ns+6(3x`l@edl+j_$k@qajqVIGxe`|^PCz@nxrfn1YdAjE z4|WbwF6QySAFQIn35KW~y+;-J&%gU^h5-!_OK{?@DxFg_ieJgfV%eRI5FJU()`GA2 z3c)dj1M-5qy?7!BZ3C8raAOgjv1L2{F>f5&R@R5cXydBoXA zyf(jv4_C_QqLF%IVSM=DA^r`yf013}WG<)bhjkLK6l($@Lbjv(7Sf$kF0Z!n82vzX z!A*#35Y(2j_GN#=60|FM?!)K3tu58^4$$TP{{5?XmSX7G=s~6M%J)l!0utG7NMq;c zpM9=Yv&p)CeRGqm!(P7$L}nFtLQ<>b%CZPhzya<%8^ zA%A^mo#(Oer_Wu&$M^10W!k})45R+;E;X?RF+$qwUUpMN&GmFG%ecQJvVELK#_TF= zYP$6x&M8%QuFrTJOsAVeZ3#N*vt4{-g? zErcB^MM8VI8e4n2C{;!H3?WM3%8iU+{Njsf9UR83)-FETTf%)ek8;#fqN#vago3G=+ z(h8>7@^;szlm`VAr)T4X6BZ9y-dIP8y~OJ74s}2piAWzI@@E(VS(2$V4I~ZEYYUrr zYrdc`y+G0Btv}qtl{2SsbN(@&KRkgIimD>G3i!2XG-U>z-Zf1RIa$jz6j{!$&Cj8iR@X$x!tJ~F@WX%muTke$C%HoJJR%#U+?%2? zruf}hvxFpblG^^tb4m9{HbBWh>J&&9<|vJkM`VZ>7BfVM%@NTEWTR!3{Qvg+68`Sd z4k@yimo2<~XA+;0`r51qx>#n*LQM00gkU&wUK!x zO8>5n40**m)31c&z0DnDlhg}2=`9{RIxzvC(0SY?VA?@#-6@iGoNlja(|BF*pu0~W zGp|f2w6L-y|}-D9{B*C{l#dAr2;w+p|mf z=igwBetU^JT}Thh0|WT|)I5$*JP~THw!zr}-wRyRyb-#s01@pDxA7`k zSM3${T*vl+A1m7h{P;JY;={!PoM=YHV2P`9wl|Eu-E{=n*W0X%^_tjWu72Uf1c9rJ zh0R@7$rdiLSvtLZjX)PyKqDKZ!XXhLgRBuKg|X6eMCjlEYHks`toY`5&9@)zDQfz! zF7zw@zsSx|g!wSZc3C|T!=;NaAb8^%3N+dh`Tye9XSlZ7#A{!!;0G^0jq`^Fl_8nu~e+#U~e~RYzrqTNsChZ`RX>N*EWgRarBb-tg#syCAdlJOztC)RkD>Lq>Z4Y zGKSf1xsDsld-!CzfRp`ceETHZXmM@H5u{1I z$GS53*{3)0)*TjS#D>?-9Kkow9m27J43g2H@(fp@zZQEn>CH1bS-e6T%iN|0EZgJ43QbT#DWIvC9+A8 zzi5E4)x_H7D!Pw7!;BL{cmFU_1LLUduA;?$LydCS!M<+HZxyjzis7f9PvRFe#z>lN z`(q<`l1T%e)+q)M2#`Vi=#v>$TpaDskbzaO!uC|vVHcNT%i*yR{Os?3 zir(=vOo{9QyCp2s1<#KTs_idJJ5{qm%E547kE#aaOlQ%M-hNZ7J;h16s9_ZL~JSLfBlB zkBn@ywJPZD9YBz(vSo)<>rFyVLMt$E7(?VDUDOd?eC|cO|NeWZ?JOZ79Vu2$!D0oW zR0#j~;WDl-Y~w%v(P=z)s8>y1XFith?cob=TGc%6EG}Xz(gUQ}ouOh=CT)H3$N+W! z8t&b`u4w2pmtR7arRe}CdiLOes=v1xqr0?tCK^C#dlj!deMWU?+j}cqun2j2oxMP^ zNg8S=U&I>myv60^|m={ce4?K)h)pHgp8t!xO4QR4kXV zF+Zuc&u5NYBsfOdE~A9bbB*%4cbB&mz@>OFO5hFgT%f2T_c?sVG<|NUhb47XgT8pc z!s0ycZ)~D#{H!7#wLOjGS0)C~N3DOJz_z%(P3`R*K`oAc_Adsx^07!r6*nKxZm14T z50Awe_vuWxvT#6czP1?43GC(zYU0O_9>Z&tx`pbxvP+Bs(vpEW)lP;*49gm)ithg zh!fA!t#X;P+KnL?k`XD2lUmp*AjMeCmn#ITEu0%afMMoncW+fGIn#5qNU#LHeCaZJ zyL0BI1vWJ)l!)jNl*x;TMdiq){wEY%`UgR>L*Wm1x5!5V-6{-fZMjb=ils^B{HBzK zGVv0LNsEHTH=a3xA$rL+HOU?}K2l^|a$&irIpcF z@@4#PFNs{=0JXvazAy2dOouJqUhhuEloBL8f>OPqu%S%0`jyiYxWBlrw%UhG>fNii zvC|45K&^6}UVrS+Aq;jUXzcNeZ;EQGq%68#evOHvGWA6*=>RvYxt)Q^y0?sGHQh##JSq%$A-|y%D5|=;bg7Ottt!+o1q~w9+cf; z9GK_=R!CMnNsTbB*rH^P3XT2C$xYmK`_W6XwBCrQST8r__8KT7lxi)tK~iqa5bkVX>4woaz&FgH)trKf<`G3>~3NNadwKGU>z?IkXNcMIY<+Eg6{HenPX$W zk#U}jivp=OJuqIkig! zKIZ%@xnb)@O0 z8>I&BJziBcfL?Jatn|Hm-u^&gfYKk7m6z92Evg$U4ILN#f2zQGA=!t~!2txA@15m` z2<@y;k)1&{&IW34R*hv>#8p~zjAZB(c}anuI?ImL(&{Sy2~)U~$|9P2TjvCPWsA;y zJB0s3i9KKs9(@yZi9jNY3ll?JpfGy4`m(2gn`uk7s$}^4AtEvrHjMU=meS?KFtS1F z?(8ClLM_Y}8VE3@B_;jvLMKvfRY5AkA(Qv+;0oAg!-D0vvs|M^ikjRcn(tD1dV4#` z7HeEhvWL1~as4qaJ@X85{bZ@s0);@i)e^esd50LwE?wWI{QmUV0NZs{ym0ymVoZad zPA$^FG&ZlhsU*ENIfmL!rewGP`p4g7dzBy;$G!PQ+~sC*wl{ifOUs#o>-r~%kfQ-yi{;#m=opd9~&TO66{J0)A8Y6 z)k9<-4Yz0JuuKloq&LN45!H5=ZdOYPfdLhcHM!+R-z*XEm6T#Bk8FAb@87zQ>aX5X zgOkmUXHTEQ@F3AVk!I(%#qP`~G7RadFW0diJ)o$tq%FBGXqzj*o+*-SZ-vzU16SSj z>~&_;tu|&ChqrOrq4Bf=MX9_N!!k2~aDzdvz7V1$y(7Q2gj?6IsZhE4&d)J0bO85q zeWb{7G#JY7?Cz3B(8EH^Fo}!;TTz!9Y`$z$N~_aL04!UqgMPg^^rFm7UKp&0DSVsUdD{imNtcPg#+_aE$M7{t2q_+d9%duyx?>$rI7MSLz>StTt= zZsO@SxN%8MWU_SRx>QDmTTIZw&Mf%+E@;Q zgT2aFbC2>kap^UDIW@yFy-10U=TG+_!A7gxGgK#-N0D`lr7N^hX<@EvmLbfHp1A zv;_*JD4IG!UDs_IHHlrPwPA^tWKkqVN;Ju#NDgOT@4nx&<$1qz?#z&)94W~NBhJjd z=bZ0*m*;)n=l!l$EBAan;!D%CNBq@);QP_<`Q%@|FW2kg^W0B#&B15-zRz#DUi&G} z@b+GJ91nJX0C&(vr@et{WfkVbH+{q{@aRo{^3BUNve-vG$M=WN*ni)nu7A|e!Iu}_ zLqD)CU;Qga4c;EC-82pOUIeaf(g*|G*tiDMFoGB5CcbZ|8xC*$Q7>zM?nXb|YrcAp zrtLrH)7~TaTW}wBN8f+oy+fl)qU**O>F5e&ZzEAs};l>Aj7tKx&+tpq4I(>S7n?P8D>6?g{h#vkct`B~Zn|hwFLTVTy2SMx8e0i*N^@@#Vvj?5m8-|XZaur>cr{hP)kS|2> zfYm?2*Z!tGwN5FmviV+bfG)i*r7sbSA(~7e-HV~!YoOe*acE>7BgGQ5R=w-1n+&}o zz@%$?zstsLo27;~*9}eKgkfkOYlMeADL>x5RmJ&mf^m5PeY|c3h(z^I zoCYdVU5%mdpxJ2Q=KTfS-&%obMr3;wOWf>VRwre$LSKfKe*Zdw*i3Aey z0yHxZBT}FtlFWnv0wt`(c$zXyP%?)R@No;<*lgnO{j-0IYxmxUBOCYkGfRx46Hm-Y z9I0du5gL6Ylj3t6ew*;)d6=0Ay!6;p_}j012FcV~V80N>t0L}Mg-eL6Z1ON$_BS}^>n$ya-Bhap0V8Z}cJS-J^;^7Vo1P_jy8XZauB{BFhs;7(*D|HF z&=7V{0hUCAt`Q6Pp7QQ0Y`&-F>NtP+2-0cmBi{EVvV2V$z!}(Rx2mYu*RipE1Dm_o zuv@)_t(`kquhmg&^|4bcE_+rMf_8+sfPWT9n{+D3ig-36{G2nDVkmuUj~3F1!HU4lXQOB^HzE;wiJ{|k9W1W*QO)=) zC0r=k)PVVU_3i?WPo2i3F@b2z3Mzt%ga@hUhf7Puk%7v6i*J}p#_;&mIG#Cq8gmne zkw~W4fS8C|7A$GRnDdebMkLDb7+PH$M!JYtu7Gb{d>5;&E~dwd$mF62Xu7uNeIaL# z!*FMt+hrQtb|2|P2KmesOKSo3>LxEp@HLGNlyrc_+ex-MH(-KWIB&{ia^8->v85`G!2Vhk0$d-y$ZjhL8aWf-%jF2uaYFpT?v{jh7Jr9+N zi#OhT7t^IFoSQp~c+BL&2wp9=n9RfJ$!Uh#fcG)*#QZ$wCuWGa5wbcGtRd)0HQ>WN zRRtt~Ol1U33?i5zz{w41jIewmkH?Q5$F4ZQO5Gr^|4 zP~(vjsD9EJT}kBi2^u4=)SijrapZG{aOBWw#1qm4wa84%xVLqcY`9J56fikbVlzZ< zr7{v8F0%A!BI=;hE~6)L!v&<`tp8llz*E%>UFogcZQNX5M$*$!j1p*C1R)wP7LAfb z_0%G_%QYf$hlp&EnsyZN(WHVf%0z3B4Vw&mZMboUu*=^a+h+^gV5-F!VWh4^>TDv8 z&B~4f8tF+qKTvk0nn-j;tf;~wTS%5g*^6c~>5PPLskOVL#+J#{Gc`K6$jsw&$T%D9 z1x$^baJmw5E*ETIX{UwFa*K-)!XlQ9sfYQrfX&s$QVh*Z1SyMNlLfGx86-G`Y>CE7 z)9X=1dKR}CJ)Ie(82*Dn3N1!L%WlFkn@E!SaxI9EehW_+@E%*`3ch#a2Hsz}k2;y) zq462~@{3=_@tH%&#V^zN1Z0u46p;$r@+b|ivD1@C81zk+@EFZtz>B*`63pS~=riA2 zjXFC0CYE;JXS8l0#flTL;xqz5(4$BDePIzE>2C|IA7%=lDXBo=zB<$UFRNi8cor~YZIwPpks$=Op3O*S&b5Ji!By}@*v9xtc`Kc|eH?Y1_XPK-} z8tY;w86jv|IC+ZYJe?4A)MI^5GNgP=P&mjYN0{>l^KP5yZzIV(eD1_Es+TU63OIk} zS@!Ia2bEWWDn`>hYrTM6j-kmVVw3>;g_$wbI$hXQWKxj?`rQF8EiU2IzigJR?-u!o zF2xLUM;KjC9meeBJWTOahTsK{NX?sA%%#!XC~0I8MMgrD;gn*iEB%JVCzgz=&3oEM z#q1bEuE11yetrgbb}LHq%90p8HaATcmIe1kAaqe4NS7E_2GQ7=MaGZfEOYkgu?`zo zK7&zWaSc*3#fs8Brcb

h>0_BQ8@?9AI#*EUSh{`K&x{n_kaQ{V|Y@Q%Dh@V{u3l zgcDrTXeN$4n<-K~oIhH?czy(h^c2j+-40$jc^HpPrE!PMD4iU|xfu~5ib}v}w^-k~ z&}3TZsduZx0RrWgFHrQrSo$Xo-2qavuwhO6Ca68v`s10py^1Ru}l=v{{dh|Db-M~0gg4IobGD#n@`OIYdw zYN(<=S_O*cVkjPa9P8`vv++o)k`*xpTe8TNW=MKGHJDgmzpJD!+i_GK5Bd#LRS~;+ zpBO>H(MwRp?ZGHbu*{N=Bn{YwqS6$EH}K}HIMP*;WJ&b0g1Jl$n^f0l{rA}bC8H%& z8f=E>^_anmWA?DsX<=!(&XT=~VEe~gbT$m<_Gi-Vu z_WYib9Q<26egwEOjWoN<-A0+EkUhSXCFZP01K0zxe1lwKoXjx=H`EB?g%S8Ia){kK zXzVVKlXNk!pG8z-B$VbA6YVr>Xoyp63q9UvU{^_n34+qDk;rxXT{KyR4I14SanxZd z#Af(2cnTIZr;X zGyg>`7GQh82oKFlbK@lxre+!9*NM1Y)_!ibwt)zHb%$ZMva^E4%`M~^VJlQ?CKy=} z)?|^xv_uI8kv_-GI)fTL*_RoQ5>5W}=0z+KhsFw{biPf2W04Ko4pY*_1nYV+l|h0S z(Ba^-%2LVYiVi#Bl1+2oBGlL@fOn%xbfPckV2sOaz@try%Ok zrNIj3wm!tM$&;8He@Y3JGHKEEs0u&L$ARAbfRfK#v#2c=Mn}=9q_IQdQ}5JqfB6Q< z5)DbZ>yf=>D1ctPdk5RZm))I3eD1^?qJ=q(9Y4&DlwGC}m`WdU3Yc0nsnX=a8rMhw z#nOFl+9yyt^m`X4CQIx%XHiII)EalWpDH(epTuc-eG~DhiCiwvwYwx-6@t5tXU=>E z@!UCuW2W%^HtXslRoq^iVO#E!G4*+)Ch239q$o#29#S2Z!@SIqph#OfoDlhHf*h*7 zegI%koPUP-ZX-W^67PNRIyS1L#f+i}ma}d0a-Xu(nWHE05)E#4YlJE)(gXT>`yOu8 zo4B#?9-d_@t;bF<0Fx?>bhnCrs||})OZfbe$x##&DU>oflyWT1G;pumVDz#tu}IBh zHq6NcG9+XXM!{BH2uzYmEy-QA{`?-_|i#6uu)rQ%8;Y5C3n5H>Oe=W zNsLirFo^a88c4?6(nj}b&=DHu`C~`PYobcCXt4U+Vgi{=>jWcV(Ani4)9AK7gmLt# zxmQi8??Qpe_#8GpeTJHNSSIc}tqOK2Fw}OI5${y!r6o*6xaqSms;;ADg@@>%4zQ3&W%kfCYZ^Lsq)@uM3@sr3oF~QIf5J!m7@`L6Z!vCB8n6F zDYm@^6`49ww}KA)d%f00ib%_-BonihmcUt8xN##y1_^r~zw!J31HH{nJTpNGZ0xF3 zNSQHm4h`4VKVYl6OyHzmS4<&RdJ+*TOdz1?Y_ht&HglA^;LSJTB)^W&Pn^V|>;_KG z9mmM%H1APhKj0uGs*vaeES+U8Ws9QC=2jVfR*M(T&5+!&>0#Q$X;hKrG|G99b3&ah znA5o&w&)=lDv)z2Lx}s5hKnKz&{T1rPPGV>wl{ejze%FOh+&FRbV8+j2hScqj;T_PR69Xk!6Gu#IOG8?)4G8~4|%)EC;+ zD42FLGnCndl+kgPP;Y*-zpypFZ+8u*P1aQ+8|spu7a1Q@yr>nok)x-l(&Xz@YN_=w zFQ?H;5Y1gGNU;I|sjnt495^%2Y?i)B3LF`w@%4q?(wKj8>3ucu@Y&_vDtBdM6vyTmNlZmHTtr_QwXc9xaG*`D&}T~??N;#Kl{fMD*ym7p){th- zZ&61`(^1C?CC)AQe8WSDsBaTIj~z|m7d~?g$7Y{ms-_idbcqaX`sq*+P%2p5Vg&d- z2N~wJ%pZU8&@?7<NMEPk_@ldLaVFh>-QB=!qQ?pHDDup@M2uc$>dcTpot|66 zGmjm@iJ3Emk~x)aRAyh8`D>JZr_y4I-l znq!M;)MN}zR2L=2W;a&jeMM_L%#JY5 z&E}~778zX$t|6jw-%$O2kCAg{XIDvb9!sO`i+n!dAZL@DfZZaE^zH}m;^K{~cr`nP zbcR^KIpSD4hCy$YGs&Oei%*@!*PcEFx4J|*?Kbi=XWHXP45@Q^=$cRkm``kf`09d5~>WXKG0UgTgu8e`{ zS=p?N+4|k48m2hAcTumcp}v*Gf;4ahRZ*NTT)m2K{=a$ryI(zrL}eX%ag0Nt94Fr_ z>zsX>cQzN*z*077kqM?^If{!t^6I7}nm{Rlp4%wg_KM>$#__FOa6`_(&^5)7Ce*6DN z@y|au&NQT(=#*Nci+YnpD9>Jdhi#>hnUo{vX#1+kF(U;9I$e5JgbY_EJq%5bk`Nq3 z^wiOpfvwshymkFU{LBCHpRm5Ms17?wT8MWHdMQd?;E>cfT%6$rYFxEzuD-sJ^+`Scg@Jlpk-L^@0UG$-Tc7_~Rck+RTL;AM&4rir{H8bCya zyPD^-x3^`K#l;NI+7jhh(kl4Y_g}}i|L>o0O$}v?p-K!PGm*FwLNpq}pdKJY6PXl6 zD8xFpt^}9V_%S)OjN2T>EL`2h8>@AcSP|a5xz5r?X^ynjAv)w!9D8w+Jm5OA>_s#J z=;qxW)o18rdajJx<$#6o3DGS@OOPp&L=FG`Z@znp*FD9xx7FXCm}F-w;FU-xxv*xS z^ir#rCTGCDz-413$Ihf36=Y5h(tJo3I}D#I_g3)TA78|~w{CKNP!Ep2F(M4fJ>T0C zi$tQbESc9)a3v#iIrfrypetd_A%!NP%o#*9S3+NA6Okg`sSS{_Q)p!8**uZhkPX_5 zrVgXiU^63RyVk2=eEK*lq_=~1H5jkS*e$F!@*Ww(4G+tUY<_=Z3qQD3$FF_yEXKzU zqcyh)vrGx9(%$6YV42twQRin2*3KT=<0fl$Z{P;nAc@X(hS7!VchsPv%}wj7`3R1_ zVR#B#Q|Cv5zYCx;3I;}lYhfgiC`IF0HKO*Npy$xIAxXO$jT|KSeY!# zCL<`yHm$)Dno1X7@ySW*2<;XzsJVOKpoXtD9Gq%ZXIH`_&zf?G81mNQ3jX(xF5_3e z@{&56WWILsGDgM<kxIR@|k+_AJgdc9uDT>BOw+HOug+cm^0*9a|1J@cO!xUlPC6JM| zIrMIna+}`;`qIpJqo|T;svca^RoI()@GruXu0bBU!FfkbQ)d#STKtRO`CUA7_B4)6 zaW3$cXU}r-GOCV^rO5E)e9brh;D^{=TN9hn!H(2HGlSW=g=C=U>0Xn{7M zB!b{{bvV7()hRKD`EQa`i5w-zU?lXLjE*>~gc^48yO7SQ6ls%8Y1~Yg6k85i*o1n+ zF_{{iJ5mdfGrt2l`l6!G!!QUOK1XJh{?Pccwjp^74dSqx{QZCZ@A2P$^Pj@}+Vfv$ zex^c&GdKgdy7`*&wo-uQB!(UuTD|V=si8ukd@Vd*tRV!PFjB$;J?ezHT7;oadJ#L+ zs6Xnj{_yNyc#RgyW1bh}yR^q)bU3|MfR!A+LZ(?P(GpEOAaILCy#VNB)qZ#`ST?yIMt+zG0V`C-(!5~AAr>%#kWr!U z57QQ1y{$3<7>B2npRn3(R`g;RZKO{)uaTsoTAbfy7 z%W*mJsKvZ+34SrlP#nahL0i=Hbb3F!xTy4I?bikcPJ2w&>_dyqqul&0c)JX4#7?hD#OEh zVf3rb=zC!3sZV6-TIdA?NUR5HX?QyP;5mYDvcyOmbZg3Mq9n<&HH{wch|DG@KA9SM z>i?Z~1@*2R-{p+a(i9jz#69;hfBb4t44&}36a2kH9G^q}5 zy#3DimE&Bg9KZS6w>c`N_Lq|(LmrEZq^ojrOEDT<1(#4`(F}*5A@IYq@0u1&XH|6g z`{Oc+8p$!C(;=j&*MpK71g%nzq@U=u6@!d(8g0d;yX)*7e`z!-18@EK`--#Je6D$T zG>7SY5!p0Lp<_{kss)S+A$uC4XfPJu4{6CaRnCpD+=XF|;F!9v%W?aCBdC<|h9^CP z+)xoAr^V@Q>G{R;QWc~mtHV%)YmWfg6%3$89}y}nKq?2XJ@3F+E;u=+#&%&O$Z%$o z9+!?a!!(l-zXaup393Z=K{a`Nzxj7x{F@9d4k9QV{>nf6C%AHJA;i@H0M)o_n9Q0V zhJ3L2z^iWX9z8HnKnW3&5lyw9N+t_GjuV9K{;3jORSB8av|z@3ndoyZmMUXW<}dW% zOtMW%KF~Qkkh19o2eDNIcwxy6dB-qMGpQsy#~9~>i-@sg`fR`yH{i{Z(IC}K{;ub8 zGgP$mRDCQ(FI7v4gH!LYt`8Yra6)m9-v&z44)-Ont=(klg4A}TYOc7~1N>!F-C>>|h#%OZ+zD~QbpH9Qg4|zbCvKf|CSxki^dWk0a z5S1JV6XQm$NRTe-BS4g_MSl9>#kcY9^$&tGfjF?i&=NzU;f0%&pH&Ilhd34E!-5w& zT1AGZ1QG0UjPR9xpdC_GdA5{9-nG-JhvEixzI0ZpQ%ROahdO~TQhT`0y$?`?hIZ9# zQbRFcQ01}7DVBu1w8?^F5wc1-HY1VXs1I%c>S*ad`tAS74zD|G=!UrRz^A~pVQ5H= z4@y8tJ%XQI&ky?mH;A2Zv-;2oTJWh3f4Ja1)CfP`A_*k}&R$1tP|hgG&@)ArC`aJr z#JV(K(#7pVF0j8WUmc$@+2R+Ald9vB>Jn!el`^Z2)yM%${yvOaQx^Q6$%r;7KB_SC z!Vg3FLsl66PcBdk4akqTR|Es(_53{|yjK~9bO|8|QhmB%JP=*pFxs@>LlXkLD$*GC zL_UQNU6LR9oY6p?v!;$9itaF@!pKoSvsx&?76gf;ap8j-)c!hHSXxJffP8dnjEpb~ fCz?@)miPS`8s~Jw2{cn)00000NkvXXu0mjfFyM>1d1v`Zn#P1f-jPyT9$6cYEIFea~t2!+-fFtzrKZU-CsM`(3*; ziU<5fn*Yfct>QiMuN+t58h_kBRQWrTWAPe4j=fKwmAiQ;Hz#ec@!$Q${w_I~^Hb)I zYaO5ECM9p-7C1rqS2hpTZpz9Dp9+rRpHeEwV06JJ+s`T-RLR`zOmuQjBE1enMXk8c zDn2fJzphg|RX3k3Y9?1!_&67pjF8)Nd|=MO%UD)lVqnacT*_e>efjwAQE51BIZW`4q% z%Irkkl|kdmI3?Y9mSU9n(R!)D~832jxlovQFC>iNv zjZn(W6R`5A@ZFh0S*f+oy&hCBtRK+0z8_N_X_TUTctHn&orun2DhYg@4w#rwr=KGV z1XZCRmnvGq+7sz%j|=_#36k9EN9hQBS_d!59h! zM^7JOb#se~cokc>Ub16mNEB;qVg|_k_e3O(!~nrjbhrnI$P_(YNg7P!Mh7(m}aSjR47E)LOd49$LqLofmUK-4(>@+tIeiq-8+EH2Mc=fEYs#B|WZ`@ejG;b??*qmJQhfhoP^ zYA``>IK^l>L#13c=Z-H&Xzm}PdwzyLe)F%;Xm=8;#*kH7y#SLsP6~nP>O?_GhoH~A zCZbz-PmAcRfWpg1pGx?te4f*_kH2_=!$%*ZS;}!i*IrCVI6FDP@yQwby({QB!Bdqm zoaWd+8{p((gz-Wf!B<;Ntgp7vq5_(=iV2VI)d0WwpSKwz%c5|FcZB%&pACR#NqW!X z>`jQcaLD|>2xKPa#+00Rzw151veW>LEx9`7-Qsog|e&wstowb`*GFR=2tq`P!zhnkvqd;DLh6ltO!Z}?)7^mIn zA<^d$O4S~LE|Y<>GAeZ4X1$IRW`YtgTVHKpH0)zEQW*BH821d7ZXdP!bA0EGucOmg z!A7Hs8ZWMuGwdB4K}l9PJjY@&$4aG)(bXkRI9_d~fgMScp5UZL^Ab9b@OYsE8$%Ji zg$HP#@&W(`ABt|4sLmeFts-)Z5sTRz3tCIY8I$Oo@O!mV!B(e@C9Su$whD&60Y6Z% z%|;VjtvbrQ=JfOwy;2F2$q4K123C3O=GHnMAD-c&KSHfiGB}`~JVsWlp}o0@?cJM9 z8l?~d9?(L~pM+*SizGWla%Vk2r5K-}rx)5cKy;d6C3OiYSmONjm;l%1*D{41V#x5Q za)RrfbyW4-T)kW@peVFFXQf)fjZTxomC>Ir@tpW@-d9+bSB%B$S&my&LW$2`3ivUd z3Vq(DLf6;U&9N*?d3s`5<((Ty{z=iS(9bh6?koT-K4WB<679CUd%A#m6HW2ur8rq ztE11bsIw*o6;NkDHs}SFd}2JORV$%YFJm%S6u3mCWd>h@rFFtjY@y(w$$Nn106~I+ zCtESg%DK355*mSAwWNJIL4pF#lRQTskH+Ypogkw{W(x{OEa-6Juit-(vEOEdERd7k zWegQ*RaNNrTGue6C-5fJStBf|z*2dR1_zi67BT(x$8(gZyqqcW;H--|l_yA^i2`(- zkt)nCA%&_qck98))gA+MN-eb2s_zT>oS&x9SP}Za+=3?s)eEs2fodf)gvMK=MCBl> z@|q>@Un1ZNzY77XSC^QLW(FHFvcuL&og}D=83|LlzQWL$qf6dUCa#n#RldrFR9H}f z1I1iNwihu?Fj$9*(~^IUr2B(F8lsXFVy_AXjmlq*>268E1>GCg`2W#ynzIy*2kCo{)Gwi(d5^4;c z>xMy*=NO};#4QAZ6UCc7Qz}UhIza>F%Svr4K&LfTPy}O>X{+Aq;9^Mjc6ni9PTV?o z&k&b29+F!R5xP8^FHP|yRuYRySd_&}Xz?ilZ$@QGpFtEMvEOVoP^VXnr*l(Wr7rAk z1SH`J--Gn9SNw~Gql&!)sKnUr-a;$DH6n}>+{3LaW!SuVixt}{Mw1CAof&%I`(=iI zQ8-)HYU`T3aXMXKLV>KX41;=&X|cl8$Lj^1jZeT@oeStiJ15)#^k#nLFqIE}b? zxr)k4o3b&E84i*;7Sz^^SkP>*a;S`{vPL#pGH73Bm{@d&2{EY&YGjQn8b4Uqd#lS$ ziBb{}cLu`?JRtOyVn$Hm0Qgwr@S!Dke$snD?DSGGU1d0 zKRG!^_o9p5#Rc^}djufFO5*1TQ|L<*x{&{;UB%y2#?Ry=WP975D zCa$qXg5X?Adq9jRRKDtAb>{~D?3=HnMI4xuvolyxC1Pj1snuA;y^S^OklU|H6<2Mb z!A6KMzzNITD&xA|XklZ0l`N7Zs-keavx1vDJLC>4h5@d!S+ZIyy&$iOK7ok)@S*O! zLPtE0H(4g=W7=yfS~l;`QE0+9@fJtuBp0#>Q>*)H$Oo1RrW)@iN;=Wg6i0@!ryr^t5Se~5Z$60L! zD?(LirHnBvZZFp;v%HpDOG9XF4Vf@IzEmc_mKmZWqt<;Mw!WAbO7>h<&pY(?HmQIP2UtW`QY4->)7Db0kCncm8oB-2f}8FO!?@I>%Z2cGaD}|D z$EYOj$ma=D-9A5eDA;7wXO~Bz>bf);+g}+iE$afNCZKoB+3_iF?_iG$rO&z-SQ0zt zrkB8iNJtRV>6Gnp8Na!?gO}g@JEp{%8!rpAHgtUxFSY+GBnXZK45G(~cwTUCN#{M$ z>S=OKJ1Sm9zU9RCIhAnGxl2-WN;fT0%*?pFViu6Nm70D|&lvMHkiJN1iSA_=Epmce zofdn3Q}h`3Bc{x(!Uihu6HlnEZW3E+NuJLWu`D7MAj?cFPi6{-4?0jnt-LB#ye{$s zyK6)FW`PR>rHjkrDTxAX;bRLRz9_JHFE9%}?*K$qm_ zayZ1%q>p*Vb%c#JF=E9KDQS|du;Jo`%~OyLO0XUns#GUM8m0lWbdnN{0>XoIq9G?` zuA>*YMUYSm7I>u&O%i!4HP=v?o*5tv*x~NiME445?I!#CwT&A%J3PWVm&-;^KjWHW zMvC0PcrrAtbGct$t&PoS-?Y7j#0ymfz1*};toNZf%{YB`Upd5bYy*ev>N~3zC=*mQ zPmvhQphLgiofXzbBp0$XU_LLI{LZA9+ve%c?k?Yn4?NdBJH_L{Ar>t1YV9%_LaKOO zMdF=`lZMI0?R%!E~}u?61qz!$mR5ww81&?iXl5CQ!U;V zegQwci|qisNhT_wpeuttQOtlBX6-;d3zVopc>K$KQgJyvjX$p<>jQZhK^wBdj*d`V z_5kKwFI?!%STrXaoD&Eo&9&L=yv2q{gJtyDXiCqi7_88IUzTck^^HFzwQeC+p8Efh zB9+VvUBLDzOhE}=f~@d>^j#xVfo63@>DVP!NTSfKzUWkQk_T-6zb*|?D(8}2<1yLa zLf9Qs+(^oRTEi$snx9KPU`V1cBJUs5HShoax3T%soe-LtE9yPJ4_j@Wd-$z&A%&+N zfvAmwVYO+A*;o!-z&*hC0>VyiUDRFbinglqA;2Z>;knroBa0!ddFkF<6C}D{u^qNx z=_`w(;+H~udxN3zo;g$E#dyTNVTPUdI<|ISLR#JhfpKjfb}0*R%}^H^DP?he4yTb^ z7f4;XfTbQSbjo>}p@0Zpkw%gmcsfDj*?TB2htLEzU9MRrD7Q#L>uysq8%%-1_IiB^Pmr9B zhj@+r;qm@Hs;lc*VVf+7P0fxE0mueHa4N+POWyO# zOSZrp>T(he`-dM2#YDdotZMmZ0X;#n$O6(PydR5$X=}m22S;rNWh@1%~xS~lu z580%6`<;iFEHivC?BFM#4RO-#87d+za@jJY%@P@7Ko(47A%xA2Eazv2OO55&&W#(U zVIp``VMb`KZP^*+*IYQo0T*&!+RzX1vMA4QfMQCBSZ%;H)vUiMCIZI?bgEl~jS|0m zu!pz)^CMicoRtkxvJUv^xQ!ouy1=uOu2n*wTN_9ZkoCWy{gg<*aGj?mF`!8$$X>!m zQ#_!G$9qSy4A+QYn+Oq_x6nu%0UiwkCkcSBLxUuqfaL)pIpjJB4u|?_TJd9##xwl) zdtczm-X-1t%q(*)*c3fI{L*xK&vXa>_-+qRo}XCmE-Ii0NQ+F$VcGF9VGUrKsX~a% z+CPgSR)r^*L;U1F{tN&9vtJpY)PY~<#4K>xCz$dYMR1K99hdU2?}PBWUrXQp{y+S{ zgL*6`uH}$q<>B&bj9=!B_1vjM&v$S66 zyg?^Z_Zds$dPcCSDSiKY-~B;BNbfSrgHJ4WE(a65^TBgYev0$%DXuQh*x#$MaWOUk zCbwRkpJ2j#E`32+4?O5E@!MzTEOnP=7g)6{VX!ovAXBVRaZ>(D|1kAm`e` zGB^8?FBlFkxMs-ZZGzxvS;9w$V?1JP zS1~d_QjpzXeFCU>(PTC=;jrYjZ~gjXR608hi-lR9GbJ>Y_2`QOhQt-tJF8|X9p247 z1xAd2g!gOS98{x24k>-_d!hh;N+lrZP?cZy4zKXRgJW84LBTGWw?}4BSq5L2j;%If zzGS*HS|<6C&5)z33$ykw=Y`|T2I%*m9Ap1tz?I}OnvE(;->KQ5-&|e8yASv9(cT4G ztpBAbl9agMGi5`Cgu;1$h|j-x&hf|C;JRX^S_#%ot%~dR^D?De!PgD{`on+LscurG z2`E0^>*C{wY;i3$7daXCBs;QCPcwfHM?EvKCA+<5L0bq9+gKu$>=W#E&rB<6PO>s(2C z{k6Mzpy5h39tOQF7UAoDO z$(-r3f<}o|pllMN;zhvOaDh2%2k94-&3?lL-hO|GPuW@i>+k#lcG*->Ztu58Ny8(Q z?Zs&5gd9bZ99v>K`pkf(>H8la5S*;$&IowQF)+#MOVj5sn{BYNye*(-eig>^vQ<_P zTlW2w+zOEtN|TBGEr4urtO;<+ZjtKT+_NAFk`0$jdY9}7Etv*ton57w*@GnEB;$+) zEag`xGp<5sQ!2QIet(32W4ip?zxWf}y>mO}d}qu&B~PIah?tS#T3dPl=n}ttZ;t?_ z%pU*e?9~s=u5Z)XGkLpQ$xJV(L^_*gwp%Nclb^&04$N8hPA4<7t4p3Q`+8&(g}}<3}fW_rqhlb;kI;H2ZfdoKThZyCAZ} znEW0{EHbCNN=lP0Yb8}N6I!s%H6pK{N{r8^#+nP+*UJ=Fq9tWv#Go$$vBYPRK$vt{ zp+{63vj1n5madtuu8I5H#r`+@DOkP;(tvTg|?S> zc8__6lX-2}Y+xE9Lt5+Szxo0ne0pr^^Z|vK$kLVU*W+ZXoTMaK0m8Xhkrop?7?Q?L zd7UUp`h229DR$<3jhPl_q6AJ)NNY@beX`|sA>P5s%M?rtQzp5vOwm@3Y^yDn z#!Cdm=KNf5aao;86nbq!gDXZ$dWTt7V;DVn_z1uG-GgXtZM#)*H4AYONLj+19g)%f z_@^He?3lOZrz~`_Az7M<;ZntblYpBDkZTKqVsQ~_!fOQ;#L8yP&M-Y~Xs%2wXO1_n zYj7;%?f(Gm(=mS()07qf0000P)6^Wc~-h0j-)?RzK<}d&9 zU%e9;`BfAK632mrc#fhN&mo>e2?Aey-`8i)^YpbJ1Y-DS4BqoMk^ZhyspxBatdH*n z{|sIm`&)d5xB8tu7v2;`ilUG#%Oy>xcx000Dc)!JPV_jQC&%j?pXGUu@9lH>$kJ5O zSt1D^$xLRmnWQ)-N$@_ycWJKI&~^Bp1lWV&`S9CdG8l-51t)2&7r`ZQV5$dana6in zGcJTx7%tYsAvl@tOz(k~oT>eLSF5e{KOdu}AHDs&#{l@??P*s6|lomAImoqsQPgE}k2Oyd{HX zxcGXrITr*ijl$;|GcKA|0F5Q8!CW=Z>*&AS2^z=cnH6@y*ZMiECe2j)39LaipC)QC zMaJ(_6+}k0&{!AegaATpAkfW+FnyXdO@-#FW)IWx^f^#dreaF7rbMGK z(hXUmx!=;N5pI*HV|EfT?w7aXcq~8w}tA0WLm)CdQ$!ejhq*Qw1J1 z;FUJ4i`cNOsL2$+)So0=`_pj8VbE6c4l{@g2|nT z2&RR@BX{niw3HG%cq9cQ`X7}AU(KwN`3O|m7vPigLME44S`Z5HV`oALV*xI zjRm-nD2vxi3o8&#tXiGIKZ)=SXtmcnm+5Sx2tAq3G-_}?jW|-5n50G$TzEFk@EA$= z{7goZnS!P`^PTHMRVwrMd?H5ZpSp*$5-FS&nCAom7pTx=Bba_*!xDWr^v(R78A8Zp zn#d%fSryKYtQH*BTf1<8MIlDOlX|tLcrqHn?~6<(lZhTT7)*6B#EZ$$Zazse#S{uk z&DHp@o~F!9Buh&h8rBGIw4RHLmDjgQppH#ptv1((r`J(@D6GZgg_&DQY0bg<`Z|2F zA3AZi$H%g_yC>bVzFsr}R@9)W27H%cYdo=L8UhDe&AQa$ zs*LlhOc9bM(^EBXf|4#%1!YaX0wHkGqhca6nz@JYhohP}Z>3U`>2xaNMq3({)6%4UXiKSHpozAi>F16&=%`JIhYg0Nc z2%%bc+8WC7aZip0*JWvSRcd%m7bd_z+dq)qoyW5G?GxP(g`x(b7C`|lcjUzvF3a~{dsQ|z zu1K}kl(15TgrNP=K$aH|W%o%a1NiOn!GTO*(u?hutSz^tqKq(?W}^mwZ%Vxq!e?{& z<`@PT4J7uV1#BjQ4ruSKMv*Ei!aKRt!vU*Si&Ca8eInco*W(Q$?DRI zw4l`OhYwZYWeCm41Gd&Xa^r1<0|5PMHV_8Jx_*MeRA5B?MIL0 z+r3DRYB2;bk@Lw^h7g)Kntgb9pc`tomtYA9jiJH1jI=?hwk|Ep3IrcjVp)D+OPZ@2 zQfszB=_|?_>ovM*BrTkKso~3N+sMh`F$7%53;;ZXdj!i%OR}=EBK6jy!~hc5NhBa;|32t+mY_cGpPeyZ*6YKpa1lSa{cBl zEG$xLokPoHhF4Zs^_-uPmZtosR4whMkmQPNO5r{IWMGTLb zYX8;EMS1z^vMgckh75G6BR((;VFVTdY{GA|I97sJg+e>ENG>ndqZXpU} zV1V!`{3r~L;wMdlU$B_AWvX^zJd*DtKftMG~1A~aZmQ1 z?!)XwF0C%gpTF~C`R?mKz~<}N_*gO^dhQ|@&(~;n0C;dYU<(P%RS5m%SI=bYlRNUk zgD29*LYF&j8NoUzK3eByJC^TVYRM)npa%06SZ!VbZb3-}uqB7(7I1-pK291B7mQ&! zwTdURwROdy1i(mU8dP9Dq{7CrH_rr0bcm|}Wlc(|2x)DYc%V4|gaAK(3UIXXID^Ro zZBt*?{HA>Q%_H@_OG}Hgb@`IqeEC(Fa7%qz!0Twz_s1QXb0!Ho1b0tSz?S4!%_CH8HT!xZL2ne$6tbFJ;%#^E9fHgG@2Bou=u{Uye2>X(Hrvd z{cX9ww-175WMN^!W_|eau-BJn3phl<7M3LzhR-l-CO)~UP{EED4CvarZumV|DAyif zO{77N#2A<2<}L%X)X;b?x2MKn>ZztxmV0aZiz8>y)D*jqD>mf}aiIhop>=79#3CVb z6$%q73NT|#pyg%8rmQBjBn7`S1vceXq_eUrx2|56FCOp7eSqZ}gxp00DolvzbS^Cj zpx*9cbDpK{utpEeGdEOBW@B^V%@vvX7K6xPeW&ff5-ZQqTjTQVTyuZp1YnZ~#A!r; zS<37xQE6sLqLCoWLQOt$NOlpf^7YA~yt2Bcv7bPx=g}fWZrui8sk`PzaV;&3P%veJ zBlfcxrdxqew-MJ5dqcGhAEJBs_>lzp7$!WHAJIJ^;Gl-@BSe=N?%?AYLdxK-xjxMN z+~hHuIWiZ)Lj_QB`10Z5M@q>&q@p+}&4(M{nx_d#3nGLmG+u|0fTF5r&5hE&y1At% z-nz7^VfESmk=(xhxgK}(>Y6OIA{C%#)u!j+Vn&lx26&LlUcR(OK!*@nYXGX75w4vf z!xg-m%D%M+-44{}W6yg!o z+7f~Y1rhlHgjY;e2(!tE0;pJRuuP;{v9Vqefqa1Ak=2wPwqS}i`1NF*YH~rrF`1zd z8A+JP5M0jAy7Jwhz9t?_CYudp@c5oQ-LWj}2iF#X`VcP74~t5{_L9j^wi*b3Z)`}2 zJfhjIf!H8iVZ$46rGRBGqDBI)F&s+|oM9EY3$wlf0C57}`0ehw-0o&lM_6BsJ?S(M z(!o0-taFJHcb&}!u9?j8A$|M|bmexI>-EX%N>zj))CTwlK=5yDb2 z901LmvW>L(S6}SP;RyZ=MSSn_9r=r&{|SWNmgOcfV4!Zu!pdme(bSs!U$Gv-pR$OG z^`49~xWxe1I?~X4g>3QCmQ-?PhXi~m3Jaeh0!%W7fQeQVT$br_OvMGGzGDh25N26q zh%rc$S5)*jMg|(8kK5Yqk{*MM>p^gC#U$%gA#)@ooMLMhn89R@GnYG`dzB1t#w0| z%VCm%R(Ek3Y_b9iV3s!p2Vi!{vc(?-Fg*?QoAE@V^Kmp&rnBbRzoeiPLVvZHqaXmrH|xnwv03y0_R41*gQe4 z(BgnCl~3{8Armb8E(VSdL%2OuIAPriwmJG83CaroAzbYJC=i|&ycc?Y|Rkb zW%-+irnNV0B!!i*s1f7hRU`tVK_9vKhOOC2w?<`(-oGMS2aRpOva#X8_ld6Ypg%X z<@Og}e6$q>9_OzzRD zs4Fy)CQoo2!!e2x2*b7?&^@2XN4FnJYx{|={}ilp28D6$2!a*aJDtcse0*Q7!YsIW zN_jLTGfU*`dE>g%s* zeLp!rk)7?wGC9rV;eUEhmQj2OnExY*Ar6>2)_{6LwH^Vmj-|EGR)S?rMYGJmzyDaX zK-TlLjOBY;UW;(yA+V-18EX%Mu1T{-HC-F5BxRXvjwW>`OpntOr!|q()<> zuQ4@eV>hp{*aVDS}7ADhxgw16X3P#ZSL{AVYL|8j)>AGBewG zawJ#(>R-s!pZ;^OJO~ZtE^S)@X#0mx<$wOo`_e+8g$>} z+ys3*sQ6m4PXLmQjV-krZg|41l|4piKGlAN6$S;ECW)5UXQ&1EUf{Ap*DXmk<{M4> zsck>d{}tYhLg*}wewGt?WViaQesfI`MGg?cRS{oRN1_G>Sp<5QQ; z>bk7_^6cx^0S`VBlUTI1F z`mO8N@-+Vi%THu}G&QEUVZg?wugwnoI@F7i4e~WVW7s3>V^!wp$+zeMH09)YU(vsc zPvs%yf zLa$%DehuN^7up-de|AD7m`{l4rrEQLSY1W?I}SD3;pa3<0#OEL#)>ZSWEYyq*`9_# z+lbHiAMMJYzWJ&&PyzO%2HQTk=(42I46JS--eN4P|MG3Aedh&jjIyyqpZ@D#{zm@p z&bH!C75I{CrfD0hEYc|dUARKUOVODX}ix;7U_HCbc*XD z%_~AI8yV}{rH1VWkp)&FH!>6HXEYmae`^udv-LQ)UVtRb9s2j|i; zRoMly$H?3kn;QyJ1xj7@w|^+%u&?;Tnq!7F1jwRh-H~iR_)30z|B(V6wU!mBhS?;u zMOC^f1>75s)jh^A>*nrbl-^%f1=R1~k?*|q6VP-|v1{g8ip0h*@ruP9GSdKVt_2Yi zix?&wqjSr&mlG|OQ&Bb-~agcK`xTe-jBKk*~0c zLSfLSbNG6vvncydpUTFwr?QGVfX@mRA-xE-!p(w>hbSk?WMLtd3d|WM&UOs z{YH)+KLkAms|0B34q3fL$m>6QBv0;rMveu+#CB%D*o~6f+TkWo-GG41G8yw$`Zj^1 zd(LF2FDr{}ZSM3B_Oa1K+ivU&g!6}|{l*}$?`=hhE%7jLb3u`t`ikA_P^&$m0}~y8 zadShS8#d|>deZJJ$vI5E1=HWU@uKWLfy~eK~t9jz7cz zOq(^5p+$63)))uK7P|8mkqW8V4Bqi%Gnv?HvAc(@*J(R!C6ns(=nJ}6;+=IAett*vbk2p zPJ5-wMs4ARVT~@jvA!ll(7g#lTnr#)GIUNdG>WB-3eyC5!Ok*g4Ent@sV*+hS*vlp z#*!+}O^9f04=uanCi&V`fOJaC`8B1>cXwHZ3YhVoVq*CDZ9H4l=;LYw+J8OY}tj z%+CC9(J|cY>WxcsaD+xDOIyI1^U+k+*~DS-6vCL!kevd{DtSfg9R?O{{z>)QMFGE@ z{$dW`1)Atk81pGHm0me5_MG`YXAR^FS5S5gzv)VThVV87Q31JH8u;?`y?+$5UX!h# zyrM1>p>*vnZP=b5Q(mRtqQg#wNi-4Lf6|pE=<+cdQfz^7W2wtNpB+4tKY8a}`Q+0- zpzgrF|&1p^X5|BWBMbq(dJU+8=v>1=8K zbHuk%A}B3E$KU*UJSCy191W!Ye{M_h-#(Box;WkQ9zYM}CNxa9rjgF-?S8T?&vy5u zx>SQpWm+d7I+LBvprH)zK@FXt=<=HnAIjkVGgtyz=y*)sP=taYvpSsr6tw`Zi%nC4 zT+OYGpnVxGxqon=rS(>eog{>K0Jqi%+(2ih4U)9%4A~-LSsm61FyD^B`Oa7D{LjTR z&8OC#Nc|%$_M^{b7loA(nAlM`LMNvtJ?~Wfp`HxIM;1m!P_h3CJzR4Qs_#VgU3Qh6t_#4i57gX9ttQr6_P{v2F z_!dGw0T|x8`I12X^4pI;lGopS3uU;bxEZ;5@z-bvM1qUaRkvBK$B9jIFGgEEU4(}M zN`%FFpY6!`@9)Wj-F=ul;iZe>stHcAC_R0BBrXFkgg}7oNBE-lJNkQvO;+T#%HG0M9 zQ1`V`MPBj%0R4RwVwuvH9njJZP!DkPJ^C;j9#odDRam%Z4-VL%y$`wxza7}* zA}(Y*Uh8mYR!kH6EGpLl#oB!Rls+wYY+upW@)So(@P_A*^vO z0H5~|-lkaK0ErN56zoH2`inZFK?a#jY1Ze;9vL;(uWZUCw97vEhT|n~-SWab}%NhAB%zZ?+pjBZ#xlW;F zI-19`ouFi%B17Cd z$@#-wne~UZ(gLk>fhn7$h0F&S3)gn$BP^^2eF#!<9@?!Ho@+p-YW|fX&^CEy*wwci z)pjIX?%Kzp^&qp%ZOtLn(3ndT#hPPWxR2kl%VSvI1F-dTJ4Hq=GG(c9`&g2dGbM0! z)D2Pyfd${ZR5a_0f$IX5^Zz;^Z|vrQPqOmAZcFdpBUDv~TBRkICcPvhOIfRDa-n~q z*UorKA=r&m$}nvT0-FgHY(uq-oIO8QZlUqW*s3oDlBjh+t2lQ9LgM?7lvwQX9bn6H zN+D#j7hxUjCP4^3NmMbGAzZVA#q5F!&OSo2)UpL1T@$U>F-xs4vep`jH92GE^QWq* z#(kLlZ$VwB133rKAyJhnw4N2Vi{u+?M7Ee_)MRbmo~br{9mn<5owxw{b;2zgiv>kt zH>~#!Xgkb)PWR~uH%<%5oa=D(h0M@K^BR=YL2qvve!mSN7*ctVpJ8eTkbE>q34~0| z3vWSw|KYD?d3{5!Y+XkIHF8m)ur6Q=A+8x>oG>6!^M7y4De84YWP9l0s#zlUlJW)y z&RYad<2szm{s2W|d^e412Um%NoZ8^D2Ccz!{>Q#S;!rkqWJsJ6n4-trFx^S5f@kuz zdz8&bV38l7!&}8B5qxBT(t3vnOwp=0Xr+g~s^%?-bM;T(MNRUtY(M-8wS$-KnvMK% zO|Tq4SBAQGDCghq0raLgA38Bu3fh=2(b+I|7uv^Hl;^~`DNFkn z9(7jNcZ&bmAeE4RZ*EE^Z@u8bM{zKbU0Sq@1;?$)Im88m)_oC3W_%1RXIYhkav;jZ8W;@+>MR6nt6_f z$mjupub9dyHh%`)55dtV>cLh+c9y^i!ML#Ibjh|lor(ihnQ=X5{>D}^^fQgNE>x=Z z6c@qJ%stUIUZ{EtrUYhBt%l{=z*&aQv{-FI=y`dQk~%9fhL$`2avKGcT*qpOby?!> z7@6Rx$OD3=oUp>P4+}ssV{mvZWBhPvW_CEKus|0&#McW$6MkV)Nhvdt+SvG9l>Y2y z4|Et>36mv2R%qfz|BLy30B%Cj*8bdIK+6$#kAz&@DnQWGXx{u}*AHINzs0m8tia{Xg5H%9S;GuCP#! zpYxFx`Fzl#aDSP8f8h=mEFJioV!ka?*tvqzs#0~k@_rSUs}$}A4g6JC_XHrHJw8ygk85ZES7J-wOAJu-w}>T4uMfY;R72uGjVDbVl^v;A3ato7;!M}= zPh&eaI3p?-mIclTonEeju=o5~3*rqDSz!gGgO?5~${s*TnM?j|@Aa|T<>#T5rpsB1 zxe`>^?mi&`FW=Ler^j6dc~=Rt-C?=V%RCGwIeaRSf{+5yH9E_8-(8b!?lp+;Iy z6Q1(^of6Q@#mU7@tm-05Dyp|+y5AWibFJr2m{fq?DW{az@)g~iv1adU81$5#j=4dH z8#GNjkZnWy%-ujUMY$|WZM4hWU1XLBiLJDh_t@wysLpSTdDOPlm9^X+bP$5u$HsC2 z-&sd)#8FPqEx^n}q|O7EZc;d{iDifvxtX2sk&;m5&Lxzm7{@7O?wE=o&N|0lu2xG> z&Wb;F$;9$Zof-0{iBkB?-N6PbjLqaF&vy6Tv6<*@mn{X9DY#gnP}@XwuZW#*x_g0` z0I4b4^UcE~-|?0>#}-S{#lGSNW;^%sY+ldvs%tp$)R(W>*d5U^t$mVEndxUA`MrGCYX#pI929cm-QYrq}|L`|v5ds_MMJ8gAJ}HVh zuw{xw z2xy;b*Tio>M93VdIN7@pms>Ew>pCH*u*4&A zf=aD}I@7aBPUbj)4qrKHY^=GcTjXV4O0zGlHW?IX1qya$Pln)0pfmq06>&t4m0vOLzFT=t=9an799W%^Sryhr z%41Sj_DW6eM$#SKHCDq}VTGX9LM<83-TUiM-)%0l=r=!i=1e~~)^c*kF^bZ|aqbF^ yJ;(P{OypcuGY%D`XDd&-M(g5a7PNSl;Ql|8<|C&awNh^Y0000eqYjc-k;^9Y22lg|~$1 zb%NVQV2Hy!=1qe9HXPm)IC3N{9(cgQihL+?K@c^7eSL03{T(REPe9neNnozxU#{ZL zMjqm#S{eFSo=*tv|G3iHMxaEI>lpVQ<(^k*V*4On2XORgYz4>s8#yEXr1e$J#vDfMFWKVK`u#4l1C~s8V-sbiUR%F3q%(>^i_nfHY*ATet_~5q_)+jBeSu7Q0EIo~>#JEfm zNLy6kay#MkQ~UfL;q@w0YRO_TiEK8mv{c%*t^8v(A{7Ku`BPzh5?1LtM10Nii6=~~ z$bw~75VaOs$p)ganHr{iAX5l9%9Sz_nS_{5Ovp?wDI88ALjfP6kp?tJ8fCOGjK)w9 zexHk4X))(lK&FIcmDOpLwXH+x;j#v{@o|)751O03wG?JW79g{mQ>BF^(FtKPm9W=w zi$^`=Qn`p!I)OwwN!w|`?ex)HdI_3WnDm8IIa!ppFb#uBYayGkjXi`nnR7uB~-x0#kpsACuNDF#<6{ss9EO7I6CmG8_uX=7u1` z0TMZu(%ynHx2Jzboss7!O(!C-L=(y}FIHqhGMgqUE9r${Fukzcs0JMRAE(0!ug9y? ziF`gMvpKg2_6t zOGuygj|e(7<=j#}gy#c-IW40wGa8V+M@YC;T8#84_*SW}S*H#E}k*~n%xD3!`6F#w9k z(_%W7l$oNUQ2nC2Ge^HFF(WCg@~f6@)5fc>7U43|e8wdemDYe1 z1}QYJ$IrYlipbO?hDN6_K0S!h$%G6B{3ul{x>6|``PuLFpir@q%9lhuotHj;mWh8! z(C5m!X6y4BMhVi$+YYh9p)OF)1#$Oo;4`1e+Ni`B;iUMW)x&k_x&$qs%jHEwD2k4j zb~!Q`6NS++YGq8E7#?G=QzmcfL`zd6BB3CCWNea z6`8-B1ESKDk$wB}Ub`~H$98U^$Rv=I6!rIlHva`JzEs?O0bfNh| zfw^K{Wb=6v17#smMJyZ)p)Jy+BUmMRv#rV=Vw$<<(TZIjcS*CfXV0Z+;nOE%gP)~q zzB1i38Vw(~FB=KgP7=Jt1NW|FMD`Cz&ecSr-Bzja`s>4F&)zd2w-VU~Ix=^KNqeD` zVa+11ad5dz;dFXrdrMTN*K~?hHV57>8Kpws*le9@sA>)bKd@5DIM4C zf2GrD8@DpRxnJ7Wj$A&`m@UNU?bU)T2&jlrqls>$YJ&S+dQ(gTUyH9Xl#>0k~-3oht*p^oMV46F;RUjT+o~BB9Oc9zu%Y5 zC3{%8z0nr-P0aVU(x0{R&lT@!s^q~SaLR<%suX9ylajFm(%f>2?Aty=ik}yS^}0LK zehS^)#n}LSZcILGO09hjSROLK7w`{m?-b8q8Op`?oxb|2hYqiUiH3?+ujS^g+{1#g zjPNvyF;k_adR0Ub1T5#vEWsNm|Ju(CbAQFK-*DKKgE&4m%@?Dx=d1=Pog#Sg82{#; z3tiLn*eFH^j$!=NDWqdDeuPY=Zyj~yf_~Mta^PbP+66AZgwTp+aOym+rMKSQ%cejZs%ShQ~2MPsZ!QeCP zu;}k97#OIKP`A#PI-CzcrFl<1@*SjE(xSWNV%DSGv#z6-y%0DB6ybzzRY9z3qNSHg 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 yF^Cmekri2y6($4Vv(8v0000;>=u literal 0 HcmV?d00001 diff --git a/client/src/assets/third-party/apex-chart.css b/client/src/assets/third-party/apex-chart.css new file mode 100644 index 0000000..94ccd2b --- /dev/null +++ b/client/src/assets/third-party/apex-chart.css @@ -0,0 +1,4 @@ +.apexcharts-legend-series .apexcharts-legend-marker { + left: -4px !important; + top: 2px !important; +} diff --git a/client/src/components/@extended/AnimateButton.jsx b/client/src/components/@extended/AnimateButton.jsx new file mode 100644 index 0000000..19c465e --- /dev/null +++ b/client/src/components/@extended/AnimateButton.jsx @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; + +// third-party +import { motion } from 'framer-motion'; + +// ==============================|| ANIMATION BUTTON ||============================== // + +export default function AnimateButton({ children, type }) { + switch (type) { + case 'rotate': // only available in paid version + case 'slide': // only available in paid version + case 'scale': // only available in paid version + default: + return ( + + {children} + + ); + } +} + +AnimateButton.propTypes = { + children: PropTypes.node, + type: PropTypes.oneOf(['slide', 'scale', 'rotate']) +}; + +AnimateButton.defaultProps = { + type: 'scale' +}; diff --git a/client/src/components/@extended/Breadcrumbs.jsx b/client/src/components/@extended/Breadcrumbs.jsx new file mode 100644 index 0000000..613639b --- /dev/null +++ b/client/src/components/@extended/Breadcrumbs.jsx @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +// material-ui +import MuiBreadcrumbs from '@mui/material/Breadcrumbs'; +import { Grid, Typography } from '@mui/material'; + +// project imports +import MainCard from '../MainCard'; + +// ==============================|| BREADCRUMBS ||============================== // + +const Breadcrumbs = ({ navigation, title, ...others }) => { + const location = useLocation(); + const [main, setMain] = useState(); + const [item, setItem] = useState(); + + // set active item state + const getCollapse = (menu) => { + if (menu.children) { + menu.children.filter((collapse) => { + if (collapse.type && collapse.type === 'collapse') { + getCollapse(collapse); + } else if (collapse.type && collapse.type === 'item') { + if (location.pathname === collapse.url) { + setMain(menu); + setItem(collapse); + } + } + return false; + }); + } + }; + + useEffect(() => { + navigation?.items?.map((menu) => { + if (menu.type && menu.type === 'group') { + getCollapse(menu); + } + return false; + }); + }); + + // only used for component demo breadcrumbs + if (location.pathname === '/breadcrumbs') { + location.pathname = '/dashboard/analytics'; + } + + let mainContent; + let itemContent; + let breadcrumbContent = ; + let itemTitle = ''; + + // collapse item + if (main && main.type === 'collapse') { + mainContent = ( + + {main.title} + + ); + } + + // items + if (item && item.type === 'item') { + itemTitle = item.title; + itemContent = ( + + {itemTitle} + + ); + + // main + if (item.breadcrumbs !== false) { + breadcrumbContent = ( + + + + + + Home + + {mainContent} + {itemContent} + + + {title && ( + + {item.title} + + )} + + + ); + } + } + + return breadcrumbContent; +}; + +Breadcrumbs.propTypes = { + navigation: PropTypes.object, + title: PropTypes.bool +}; + +export default Breadcrumbs; diff --git a/client/src/components/@extended/Dot.jsx b/client/src/components/@extended/Dot.jsx new file mode 100644 index 0000000..5f8d700 --- /dev/null +++ b/client/src/components/@extended/Dot.jsx @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Box } from '@mui/material'; + +const Dot = ({ color, size }) => { + const theme = useTheme(); + let main; + switch (color) { + case 'secondary': + main = theme.palette.secondary.main; + break; + case 'error': + main = theme.palette.error.main; + break; + case 'warning': + main = theme.palette.warning.main; + break; + case 'info': + main = theme.palette.info.main; + break; + case 'success': + main = theme.palette.success.main; + break; + case 'primary': + default: + main = theme.palette.primary.main; + } + + return ( + + ); +}; + +Dot.propTypes = { + color: PropTypes.string, + size: PropTypes.number +}; + +export default Dot; diff --git a/client/src/components/@extended/Transitions.jsx b/client/src/components/@extended/Transitions.jsx new file mode 100644 index 0000000..1b5f6be --- /dev/null +++ b/client/src/components/@extended/Transitions.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { forwardRef } from 'react'; + +// material-ui +import { Fade, Box, Grow } from '@mui/material'; + +// ==============================|| TRANSITIONS ||============================== // + +const Transitions = forwardRef(({ children, position, type, ...others }, ref) => { + let positionSX = { + transformOrigin: '0 0 0' + }; + + switch (position) { + case 'top-right': + case 'top': + case 'bottom-left': + case 'bottom-right': + case 'bottom': + case 'top-left': + default: + positionSX = { + transformOrigin: '0 0 0' + }; + break; + } + + return ( + + {type === 'grow' && ( + + {children} + + )} + {type === 'fade' && ( + + {children} + + )} + + ); +}); + +Transitions.propTypes = { + children: PropTypes.node, + type: PropTypes.oneOf(['grow', 'fade', 'collapse', 'slide', 'zoom']), + position: PropTypes.oneOf(['top-left', 'top-right', 'top', 'bottom-left', 'bottom-right', 'bottom']) +}; + +Transitions.defaultProps = { + type: 'grow', + position: 'top-left' +}; + +export default Transitions; diff --git a/client/src/components/Loadable.jsx b/client/src/components/Loadable.jsx new file mode 100644 index 0000000..554555d --- /dev/null +++ b/client/src/components/Loadable.jsx @@ -0,0 +1,15 @@ +import { Suspense } from 'react'; + +// project import +import Loader from './Loader'; + +// ==============================|| LOADABLE - LAZY LOADING ||============================== // + +const Loadable = (Component) => (props) => + ( + }> + + + ); + +export default Loadable; diff --git a/client/src/components/Loader.jsx b/client/src/components/Loader.jsx new file mode 100644 index 0000000..43e79c1 --- /dev/null +++ b/client/src/components/Loader.jsx @@ -0,0 +1,25 @@ +// material-ui +import { styled } from '@mui/material/styles'; +import LinearProgress from '@mui/material/LinearProgress'; + +// loader style +const LoaderWrapper = styled('div')(({ theme }) => ({ + position: 'fixed', + top: 0, + left: 0, + zIndex: 2001, + width: '100%', + '& > * + *': { + marginTop: theme.spacing(2) + } +})); + +// ==============================|| Loader ||============================== // + +const Loader = () => ( + + + +); + +export default Loader; diff --git a/client/src/components/Logo/Logo.jsx b/client/src/components/Logo/Logo.jsx new file mode 100644 index 0000000..5b812b1 --- /dev/null +++ b/client/src/components/Logo/Logo.jsx @@ -0,0 +1,26 @@ +// material-ui +import { useTheme } from '@mui/material/styles'; +import { fontWeight } from '@mui/system'; + +import logo from '../../../../logo.png'; + +// ==============================|| LOGO SVG ||============================== // + +const Logo = () => { + const theme = useTheme(); + + return ( + /** + * if you want to use image instead of svg uncomment following, and comment out element. + * + * Mantis + * + */ + <> + Cosmos + Cosmos + + ); +}; + +export default Logo; diff --git a/client/src/components/Logo/index.jsx b/client/src/components/Logo/index.jsx new file mode 100644 index 0000000..11d53df --- /dev/null +++ b/client/src/components/Logo/index.jsx @@ -0,0 +1,24 @@ +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + +// material-ui +import { ButtonBase } from '@mui/material'; + +// project import +import Logo from './Logo'; +import config from '../../config'; + +// ==============================|| MAIN LOGO ||============================== // + +const LogoSection = ({ sx, to }) => ( + + + +); + +LogoSection.propTypes = { + sx: PropTypes.object, + to: PropTypes.string +}; + +export default LogoSection; diff --git a/client/src/components/MainCard.jsx b/client/src/components/MainCard.jsx new file mode 100644 index 0000000..b47b179 --- /dev/null +++ b/client/src/components/MainCard.jsx @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import { forwardRef } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Card, CardContent, CardHeader, Divider, Typography } from '@mui/material'; + +// project import +import Highlighter from './third-party/Highlighter'; + +// header style +const headerSX = { + p: 2.5, + '& .MuiCardHeader-action': { m: '0px auto', alignSelf: 'center' } +}; + +// ==============================|| CUSTOM - MAIN CARD ||============================== // + +const MainCard = forwardRef( + ( + { + border = true, + boxShadow, + children, + content = true, + contentSX = {}, + darkTitle, + divider = true, + elevation, + secondary, + shadow, + sx = {}, + title, + codeHighlight, + ...others + }, + ref + ) => { + const theme = useTheme(); + boxShadow = theme.palette.mode === 'dark' ? boxShadow || true : boxShadow; + + return ( + + {/* card header and action */} + {!darkTitle && title && ( + + )} + {darkTitle && title && ( + {title}} action={secondary} /> + )} + + {/* content & header divider */} + {title && divider && } + + {/* card content */} + {content && {children}} + {!content && children} + + {/* card footer - clipboard & highlighter */} + {codeHighlight && ( + <> + + + {children} + + + )} + + ); + } +); + +MainCard.propTypes = { + border: PropTypes.bool, + boxShadow: PropTypes.bool, + contentSX: PropTypes.object, + darkTitle: PropTypes.bool, + divider: PropTypes.bool, + elevation: PropTypes.number, + secondary: PropTypes.node, + shadow: PropTypes.string, + sx: PropTypes.object, + title: PropTypes.string, + codeHighlight: PropTypes.bool, + content: PropTypes.bool, + children: PropTypes.node +}; + +export default MainCard; diff --git a/client/src/components/ScrollTop.jsx b/client/src/components/ScrollTop.jsx new file mode 100644 index 0000000..809ac4d --- /dev/null +++ b/client/src/components/ScrollTop.jsx @@ -0,0 +1,26 @@ +import PropTypes from 'prop-types'; +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +// ==============================|| NAVIGATION - SCROLL TO TOP ||============================== // + +const ScrollTop = ({ children }) => { + const location = useLocation(); + const { pathname } = location; + + useEffect(() => { + window.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth' + }); + }, [pathname]); + + return children || null; +}; + +ScrollTop.propTypes = { + children: PropTypes.node +}; + +export default ScrollTop; diff --git a/client/src/components/cards/AuthFooter.jsx b/client/src/components/cards/AuthFooter.jsx new file mode 100644 index 0000000..f7996d9 --- /dev/null +++ b/client/src/components/cards/AuthFooter.jsx @@ -0,0 +1,22 @@ +// material-ui +import { useMediaQuery, Container, Link, Typography, Stack } from '@mui/material'; + +// ==============================|| FOOTER - AUTHENTICATION ||============================== // + +const AuthFooter = () => { + const matchDownSM = useMediaQuery((theme) => theme.breakpoints.down('sm')); + + return ( + + + + + ); +}; + +export default AuthFooter; diff --git a/client/src/components/cards/statistics/AnalyticEcommerce.jsx b/client/src/components/cards/statistics/AnalyticEcommerce.jsx new file mode 100644 index 0000000..7d1bb5f --- /dev/null +++ b/client/src/components/cards/statistics/AnalyticEcommerce.jsx @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { Box, Chip, Grid, Stack, Typography } from '@mui/material'; + +// project import +import MainCard from '../../../components/MainCard'; + +// assets +import { RiseOutlined, FallOutlined } from '@ant-design/icons'; + +// ==============================|| STATISTICS - ECOMMERCE CARD ||============================== // + +const AnalyticEcommerce = ({ color, title, count, percentage, isLoss, extra }) => ( + + + + {title} + + + + + {count} + + + {percentage && ( + + + {!isLoss && } + {isLoss && } + + } + label={`${percentage}%`} + sx={{ ml: 1.25, pl: 1 }} + size="small" + /> + + )} + + + + + You made an extra{' '} + + {extra} + {' '} + this year + + + +); + +AnalyticEcommerce.propTypes = { + color: PropTypes.string, + title: PropTypes.string, + count: PropTypes.string, + percentage: PropTypes.number, + isLoss: PropTypes.bool, + extra: PropTypes.oneOfType([PropTypes.node, PropTypes.string]) +}; + +AnalyticEcommerce.defaultProps = { + color: 'primary' +}; + +export default AnalyticEcommerce; diff --git a/client/src/components/third-party/Highlighter.jsx b/client/src/components/third-party/Highlighter.jsx new file mode 100644 index 0000000..410eddc --- /dev/null +++ b/client/src/components/third-party/Highlighter.jsx @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import { useState } from 'react'; + +// material-ui +import { Box, CardActions, Collapse, Divider, IconButton, Tooltip } from '@mui/material'; + +// third-party +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import reactElementToJSXString from 'react-element-to-jsx-string'; + +// project import +import SyntaxHighlight from '../../utils/SyntaxHighlight'; + +// assets +import { CodeOutlined, CopyOutlined } from '@ant-design/icons'; + +// ==============================|| CLIPBOARD & HIGHLIGHTER ||============================== // + +const Highlighter = ({ children }) => { + const [highlight, setHighlight] = useState(false); + + return ( + + + + + + + + + + + + + setHighlight(!highlight)} + > + + + + + + + {highlight && ( + + {reactElementToJSXString(children, { + showFunctions: true, + showDefaultProps: false, + maxInlineAttributesLineLength: 100 + })} + + )} + + + ); +}; + +Highlighter.propTypes = { + children: PropTypes.node +}; + +export default Highlighter; diff --git a/client/src/components/third-party/SimpleBar.jsx b/client/src/components/third-party/SimpleBar.jsx new file mode 100644 index 0000000..2d715d3 --- /dev/null +++ b/client/src/components/third-party/SimpleBar.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { alpha, styled } from '@mui/material/styles'; +import { Box } from '@mui/material'; + +// third-party +import SimpleBar from 'simplebar-react'; +import { BrowserView, MobileView } from 'react-device-detect'; + +// root style +const RootStyle = styled(BrowserView)({ + flexGrow: 1, + height: '100%', + overflow: 'hidden' +}); + +// scroll bar wrapper +const SimpleBarStyle = styled(SimpleBar)(({ theme }) => ({ + maxHeight: '100%', + '& .simplebar-scrollbar': { + '&:before': { + backgroundColor: alpha(theme.palette.grey[500], 0.48) + }, + '&.simplebar-visible:before': { + opacity: 1 + } + }, + '& .simplebar-track.simplebar-vertical': { + width: 10 + }, + '& .simplebar-track.simplebar-horizontal .simplebar-scrollbar': { + height: 6 + }, + '& .simplebar-mask': { + zIndex: 'inherit' + } +})); + +// ==============================|| SIMPLE SCROLL BAR ||============================== // + +export default function SimpleBarScroll({ children, sx, ...other }) { + return ( + <> + + + {children} + + + + + {children} + + + + ); +} + +SimpleBarScroll.propTypes = { + children: PropTypes.node, + sx: PropTypes.object +}; diff --git a/client/src/config.jsx b/client/src/config.jsx new file mode 100644 index 0000000..f2f6a7d --- /dev/null +++ b/client/src/config.jsx @@ -0,0 +1,19 @@ +// ==============================|| THEME CONFIG ||============================== // + +const config = { + defaultPath: '/dashboard/default', + fontFamily: `'Public Sans', sans-serif`, + i18n: 'en', + miniDrawer: false, + container: true, + mode: 'light', + presetColor: 'default', + themeDirection: 'ltr' +}; + +export default config; +export const drawerWidth = 260; + +export const twitterColor = '#1DA1F2'; +export const facebookColor = '#3b5998'; +export const linkedInColor = '#0e76a8'; diff --git a/client/src/index.jsx b/client/src/index.jsx new file mode 100644 index 0000000..7168d81 --- /dev/null +++ b/client/src/index.jsx @@ -0,0 +1,36 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; + +// scroll bar +import 'simplebar/src/simplebar.css'; + +// third-party +import { Provider as ReduxProvider } from 'react-redux'; + +// apex-chart +import './assets/third-party/apex-chart.css'; + +// project import +import App from './App'; +import { store } from './store'; +import reportWebVitals from './reportWebVitals'; + +// ==============================|| MAIN - REACT DOM RENDER ||============================== // + +const container = document.getElementById('root'); +const root = createRoot(container); // createRoot(container!) if you use TypeScript +root.render( + + + + + + + +); + +// 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(); diff --git a/client/src/isLoggedIn.jsx b/client/src/isLoggedIn.jsx new file mode 100644 index 0000000..4f90d15 --- /dev/null +++ b/client/src/isLoggedIn.jsx @@ -0,0 +1,13 @@ + +import * as API from './api'; +import { useEffect } from 'react'; + +const isLoggedIn = () => useEffect(() => { + API.auth.me().then((data) => { + if(data.status != 'OK') { + window.location.href = '/login'; + } + }); +}); + +export default isLoggedIn; \ No newline at end of file diff --git a/client/src/layout/MainLayout/Drawer/DrawerContent/NavCard.jsx b/client/src/layout/MainLayout/Drawer/DrawerContent/NavCard.jsx new file mode 100644 index 0000000..f89b0cc --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerContent/NavCard.jsx @@ -0,0 +1,31 @@ +// material-ui +import { Button, CardMedia, Link, Stack, Typography } from '@mui/material'; + +// project import +import MainCard from '../../../../components/MainCard'; + +// assets +import avatar from '../../../../assets/images/users/avatar-group.png'; +import AnimateButton from '../../../../components/@extended/AnimateButton'; + +// ==============================|| DRAWER CONTENT - NAVIGATION CARD ||============================== // + +const NavCard = () => ( + + + + Cosmos Pro + + Checkout pro features + + + + + + + +); + +export default NavCard; diff --git a/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.jsx b/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.jsx new file mode 100644 index 0000000..9c001af --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.jsx @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; + +// material-ui +import { Box, List, Typography } from '@mui/material'; + +// project import +import NavItem from './NavItem'; + +// ==============================|| NAVIGATION - LIST GROUP ||============================== // + +const NavGroup = ({ item }) => { + const menu = useSelector((state) => state.menu); + const { drawerOpen } = menu; + + const navCollapse = item.children?.map((menuItem) => { + switch (menuItem.type) { + case 'collapse': + return ( + + collapse - only available in paid version + + ); + case 'item': + return ; + default: + return ( + + Fix - Group Collapse or Items + + ); + } + }); + + return ( + + + {item.title} + + {/* only available in paid version */} + + ) + } + sx={{ mb: drawerOpen ? 1.5 : 0, py: 0, zIndex: 0 }} + > + {navCollapse} + + ); +}; + +NavGroup.propTypes = { + item: PropTypes.object +}; + +export default NavGroup; diff --git a/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.jsx b/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.jsx new file mode 100644 index 0000000..2be4088 --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.jsx @@ -0,0 +1,146 @@ +import PropTypes from 'prop-types'; +import { forwardRef, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material'; + +// project import +import { activeItem } from '../../../../../store/reducers/menu'; + +// ==============================|| NAVIGATION - LIST ITEM ||============================== // + +const NavItem = ({ item, level }) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const menu = useSelector((state) => state.menu); + const { drawerOpen, openItem } = menu; + + let itemTarget = '_self'; + if (item.target) { + itemTarget = '_blank'; + } + + let listItemProps = { component: forwardRef((props, ref) => ) }; + if (item?.external) { + listItemProps = { component: 'a', href: item.url, target: itemTarget }; + } + + const itemHandler = (id) => { + dispatch(activeItem({ openItem: [id] })); + }; + + const Icon = item.icon; + const itemIcon = item.icon ? : false; + + const isSelected = openItem.findIndex((id) => id === item.id) > -1; + + // active menu item on page load + useEffect(() => { + const currentIndex = document.location.pathname + .toString() + .split('/') + .findIndex((id) => id === item.id); + if (currentIndex > -1) { + dispatch(activeItem({ openItem: [item.id] })); + } + // eslint-disable-next-line + }, []); + + const textColor = 'text.primary'; + const iconSelectedColor = 'primary.main'; + + return ( + itemHandler(item.id)} + selected={isSelected} + sx={{ + zIndex: 1201, + pl: drawerOpen ? `${level * 28}px` : 1.5, + py: !drawerOpen && level === 1 ? 1.25 : 1, + ...(drawerOpen && { + '&:hover': { + bgcolor: 'primary.lighter' + }, + '&.Mui-selected': { + bgcolor: 'primary.lighter', + borderRight: `2px solid ${theme.palette.primary.main}`, + color: iconSelectedColor, + '&:hover': { + color: iconSelectedColor, + bgcolor: 'primary.lighter' + } + } + }), + ...(!drawerOpen && { + '&:hover': { + bgcolor: 'transparent' + }, + '&.Mui-selected': { + '&:hover': { + bgcolor: 'transparent' + }, + bgcolor: 'transparent' + } + }) + }} + > + {itemIcon && ( + + {itemIcon} + + )} + {(drawerOpen || (!drawerOpen && level !== 1)) && ( + + {item.title} + + } + /> + )} + {(drawerOpen || (!drawerOpen && level !== 1)) && item.chip && ( + {item.chip.avatar}} + /> + )} + + ); +}; + +NavItem.propTypes = { + item: PropTypes.object, + level: PropTypes.number +}; + +export default NavItem; diff --git a/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/index.jsx b/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/index.jsx new file mode 100644 index 0000000..9c1bb8c --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerContent/Navigation/index.jsx @@ -0,0 +1,27 @@ +// material-ui +import { Box, Typography } from '@mui/material'; + +// project import +import NavGroup from './NavGroup'; +import menuItem from '../../../../../menu-items'; + +// ==============================|| DRAWER CONTENT - NAVIGATION ||============================== // + +const Navigation = () => { + const navGroups = menuItem.items.map((item) => { + switch (item.type) { + case 'group': + return ; + default: + return ( + + Fix - Navigation Group + + ); + } + }); + + return {navGroups}; +}; + +export default Navigation; diff --git a/client/src/layout/MainLayout/Drawer/DrawerContent/index.jsx b/client/src/layout/MainLayout/Drawer/DrawerContent/index.jsx new file mode 100644 index 0000000..3c539aa --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerContent/index.jsx @@ -0,0 +1,22 @@ +// project import +import NavCard from './NavCard'; +import Navigation from './Navigation'; +import SimpleBar from '../../../../components/third-party/SimpleBar'; + +// ==============================|| DRAWER CONTENT ||============================== // + +const DrawerContent = () => ( + + + {/* */} + +); + +export default DrawerContent; diff --git a/client/src/layout/MainLayout/Drawer/DrawerHeader/DrawerHeaderStyled.jsx b/client/src/layout/MainLayout/Drawer/DrawerHeader/DrawerHeaderStyled.jsx new file mode 100644 index 0000000..057251c --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerHeader/DrawerHeaderStyled.jsx @@ -0,0 +1,15 @@ +// material-ui +import { styled } from '@mui/material/styles'; +import { Box } from '@mui/material'; + +// ==============================|| DRAWER HEADER - STYLED ||============================== // + +const DrawerHeaderStyled = styled(Box, { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({ + ...theme.mixins.toolbar, + display: 'flex', + alignItems: 'center', + justifyContent: open ? 'flex-start' : 'center', + paddingLeft: theme.spacing(open ? 3 : 0) +})); + +export default DrawerHeaderStyled; diff --git a/client/src/layout/MainLayout/Drawer/DrawerHeader/index.jsx b/client/src/layout/MainLayout/Drawer/DrawerHeader/index.jsx new file mode 100644 index 0000000..dcc5b00 --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/DrawerHeader/index.jsx @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Stack, Chip } from '@mui/material'; + +// project import +import DrawerHeaderStyled from './DrawerHeaderStyled'; +import Logo from '../../../../components/Logo'; +import {version} from '../../../../../../gupm.json'; + +// ==============================|| DRAWER HEADER ||============================== // + +const DrawerHeader = ({ open }) => { + const theme = useTheme(); + + return ( + + + + + + + ); +}; + +DrawerHeader.propTypes = { + open: PropTypes.bool +}; + +export default DrawerHeader; diff --git a/client/src/layout/MainLayout/Drawer/MiniDrawerStyled.jsx b/client/src/layout/MainLayout/Drawer/MiniDrawerStyled.jsx new file mode 100644 index 0000000..c574c54 --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/MiniDrawerStyled.jsx @@ -0,0 +1,47 @@ +// material-ui +import { styled } from '@mui/material/styles'; +import Drawer from '@mui/material/Drawer'; + +// project import +import { drawerWidth } from '../../../config'; + +const openedMixin = (theme) => ({ + width: drawerWidth, + borderRight: `1px solid ${theme.palette.divider}`, + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen + }), + overflowX: 'hidden', + boxShadow: 'none' +}); + +const closedMixin = (theme) => ({ + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen + }), + overflowX: 'hidden', + width: 0, + borderRight: 'none', + boxShadow: theme.customShadows.z1 +}); + +// ==============================|| DRAWER - MINI STYLED ||============================== // + +const MiniDrawerStyled = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({ + width: drawerWidth, + flexShrink: 0, + whiteSpace: 'nowrap', + boxSizing: 'border-box', + ...(open && { + ...openedMixin(theme), + '& .MuiDrawer-paper': openedMixin(theme) + }), + ...(!open && { + ...closedMixin(theme), + '& .MuiDrawer-paper': closedMixin(theme) + }) +})); + +export default MiniDrawerStyled; diff --git a/client/src/layout/MainLayout/Drawer/index.jsx b/client/src/layout/MainLayout/Drawer/index.jsx new file mode 100644 index 0000000..e0225b0 --- /dev/null +++ b/client/src/layout/MainLayout/Drawer/index.jsx @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import { useMemo } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Box, Drawer, useMediaQuery } from '@mui/material'; + +// project import +import DrawerHeader from './DrawerHeader'; +import DrawerContent from './DrawerContent'; +import MiniDrawerStyled from './MiniDrawerStyled'; +import { drawerWidth } from '../../../config'; + +// ==============================|| MAIN LAYOUT - DRAWER ||============================== // + +const MainDrawer = ({ open, handleDrawerToggle, window }) => { + const theme = useTheme(); + const matchDownMD = useMediaQuery(theme.breakpoints.down('lg')); + + // responsive drawer container + const container = window !== undefined ? () => window().document.body : undefined; + + // header content + const drawerContent = useMemo(() => , []); + const drawerHeader = useMemo(() => , [open]); + + return ( + + {!matchDownMD ? ( + + {drawerHeader} + {drawerContent} + + ) : ( + + {open && drawerHeader} + {open && drawerContent} + + )} + + ); +}; + +MainDrawer.propTypes = { + open: PropTypes.bool, + handleDrawerToggle: PropTypes.func, + window: PropTypes.object +}; + +export default MainDrawer; diff --git a/client/src/layout/MainLayout/Header/AppBarStyled.jsx b/client/src/layout/MainLayout/Header/AppBarStyled.jsx new file mode 100644 index 0000000..b34e929 --- /dev/null +++ b/client/src/layout/MainLayout/Header/AppBarStyled.jsx @@ -0,0 +1,26 @@ +// material-ui +import { styled } from '@mui/material/styles'; +import AppBar from '@mui/material/AppBar'; + +// project import +import { drawerWidth } from '../../../config'; + +// ==============================|| HEADER - APP BAR STYLED ||============================== // + +const AppBarStyled = styled(AppBar, { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({ + zIndex: theme.zIndex.drawer + 1, + transition: theme.transitions.create(['width', 'margin'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen + }), + ...(open && { + marginLeft: drawerWidth, + width: `calc(100% - ${drawerWidth}px)`, + transition: theme.transitions.create(['width', 'margin'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen + }) + }) +})); + +export default AppBarStyled; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/MobileSection.jsx b/client/src/layout/MainLayout/Header/HeaderContent/MobileSection.jsx new file mode 100644 index 0000000..e5db651 --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/MobileSection.jsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { AppBar, Box, ClickAwayListener, IconButton, Paper, Popper, Toolbar } from '@mui/material'; + +// project import +import Search from './Search'; +import Profile from './Profile'; +import Transitions from '../../../../components/@extended/Transitions'; + +// assets +import { MoreOutlined } from '@ant-design/icons'; + +// ==============================|| HEADER CONTENT - MOBILE ||============================== // + +const MobileSection = () => { + const theme = useTheme(); + + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + + setOpen(false); + }; + + const prevOpen = useRef(open); + useEffect(() => { + if (prevOpen.current === true && open === false) { + anchorRef.current.focus(); + } + + prevOpen.current = open; + }, [open]); + + return ( + <> + + + + + + + {({ TransitionProps }) => ( + + + + + + + + + + + + + )} + + + ); +}; + +export default MobileSection; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/Notification.jsx b/client/src/layout/MainLayout/Header/HeaderContent/Notification.jsx new file mode 100644 index 0000000..c397c1e --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/Notification.jsx @@ -0,0 +1,278 @@ +import { useRef, useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { + Avatar, + Badge, + Box, + ClickAwayListener, + Divider, + IconButton, + List, + ListItemButton, + ListItemAvatar, + ListItemText, + ListItemSecondaryAction, + Paper, + Popper, + Typography, + useMediaQuery +} from '@mui/material'; + +// project import +import MainCard from '../../../../components/MainCard'; +import Transitions from '../../../../components/@extended/Transitions'; +// assets +import { BellOutlined, CloseOutlined, GiftOutlined, MessageOutlined, SettingOutlined } from '@ant-design/icons'; + +// sx styles +const avatarSX = { + width: 36, + height: 36, + fontSize: '1rem' +}; + +const actionSX = { + mt: '6px', + ml: 1, + top: 'auto', + right: 'auto', + alignSelf: 'flex-start', + + transform: 'none' +}; + +// ==============================|| HEADER CONTENT - NOTIFICATION ||============================== // + +const Notification = () => { + const theme = useTheme(); + const matchesXs = useMediaQuery(theme.breakpoints.down('md')); + + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + setOpen(false); + }; + + const iconBackColorOpen = 'grey.300'; + const iconBackColor = 'grey.100'; + + return ( + + + + + + + + {({ TransitionProps }) => ( + + + + + + + } + > + + + + + + + + + It's{' '} + + Cristina danny's + {' '} + birthday today. + + } + secondary="2 min ago" + /> + + + 3:00 AM + + + + + + + + + + + + + Aida Burg + {' '} + commented your post. + + } + secondary="5 August" + /> + + + 6:00 PM + + + + + + + + + + + + Your Profile is Complete   + + 60% + {' '} + + } + secondary="7 hours ago" + /> + + + 2:45 PM + + + + + + + + C + + + + + Cristina Danny + {' '} + invited to join{' '} + + Meeting. + + + } + secondary="Daily scrum meeting time" + /> + + + 9:10 PM + + + + + + + View All + + } + /> + + + + + + + )} + + + ); +}; + +export default Notification; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/Profile/ProfileTab.jsx b/client/src/layout/MainLayout/Header/HeaderContent/Profile/ProfileTab.jsx new file mode 100644 index 0000000..99242cd --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/Profile/ProfileTab.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { List, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; + +// assets +import { EditOutlined, ProfileOutlined, LogoutOutlined, UserOutlined, WalletOutlined } from '@ant-design/icons'; + +// ==============================|| HEADER PROFILE - PROFILE TAB ||============================== // + +const ProfileTab = ({ handleLogout }) => { + const theme = useTheme(); + + const [selectedIndex, setSelectedIndex] = useState(0); + const handleListItemClick = (event, index) => { + setSelectedIndex(index); + }; + + return ( + + handleListItemClick(event, 0)}> + + + + + + handleListItemClick(event, 1)}> + + + + + + + handleListItemClick(event, 3)}> + + + + + + handleListItemClick(event, 4)}> + + + + + + + + + + + + + ); +}; + +ProfileTab.propTypes = { + handleLogout: PropTypes.func +}; + +export default ProfileTab; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/Profile/SettingTab.jsx b/client/src/layout/MainLayout/Header/HeaderContent/Profile/SettingTab.jsx new file mode 100644 index 0000000..5f7a79b --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/Profile/SettingTab.jsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { List, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; + +// assets +import { CommentOutlined, LockOutlined, QuestionCircleOutlined, UserOutlined, UnorderedListOutlined } from '@ant-design/icons'; + +// ==============================|| HEADER PROFILE - SETTING TAB ||============================== // + +const SettingTab = () => { + const theme = useTheme(); + + const [selectedIndex, setSelectedIndex] = useState(0); + const handleListItemClick = (event, index) => { + setSelectedIndex(index); + }; + + return ( + + handleListItemClick(event, 0)}> + + + + + + handleListItemClick(event, 1)}> + + + + + + handleListItemClick(event, 2)}> + + + + + + handleListItemClick(event, 3)}> + + + + + + handleListItemClick(event, 4)}> + + + + + + + ); +}; + +export default SettingTab; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/Profile/index.jsx b/client/src/layout/MainLayout/Header/HeaderContent/Profile/index.jsx new file mode 100644 index 0000000..245a0b0 --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/Profile/index.jsx @@ -0,0 +1,212 @@ +import PropTypes from 'prop-types'; +import { useRef, useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { + Avatar, + Box, + ButtonBase, + CardContent, + ClickAwayListener, + Grid, + IconButton, + Paper, + Popper, + Stack, + Tab, + Tabs, + Typography +} from '@mui/material'; + +// project import +import MainCard from '../../../../../components/MainCard'; +import Transitions from '../../../../../components/@extended/Transitions'; +import ProfileTab from './ProfileTab'; +import SettingTab from './SettingTab'; + +// assets +import avatar1 from '../../../../../assets/images/users/avatar-1.png'; +import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'; + +// tab panel wrapper +function TabPanel({ children, value, index, ...other }) { + return ( +

+ ); +} + +TabPanel.propTypes = { + children: PropTypes.node, + index: PropTypes.any.isRequired, + value: PropTypes.any.isRequired +}; + +function a11yProps(index) { + return { + id: `profile-tab-${index}`, + 'aria-controls': `profile-tabpanel-${index}` + }; +} + +// ==============================|| HEADER CONTENT - PROFILE ||============================== // + +const Profile = () => { + const theme = useTheme(); + + const handleLogout = async () => { + // logout + }; + + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + setOpen(false); + }; + + const [value, setValue] = useState(0); + + const handleChange = (event, newValue) => { + setValue(newValue); + }; + + const iconBackColorOpen = 'grey.300'; + + return ( + + + + + John Doe + + + + {({ TransitionProps }) => ( + + {open && ( + + + + + + + + + + John Doe + + UI/UX Designer + + + + + + + + + + + + {open && ( + <> + + + } + label="Profile" + {...a11yProps(0)} + /> + } + label="Setting" + {...a11yProps(1)} + /> + + + + + + + + + + )} + + + + )} + + )} + + + ); +}; + +export default Profile; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/Search.jsx b/client/src/layout/MainLayout/Header/HeaderContent/Search.jsx new file mode 100644 index 0000000..55eb11e --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/Search.jsx @@ -0,0 +1,30 @@ +// material-ui +import { Box, FormControl, InputAdornment, OutlinedInput } from '@mui/material'; + +// assets +import { SearchOutlined } from '@ant-design/icons'; + +// ==============================|| HEADER CONTENT - SEARCH ||============================== // + +const Search = () => ( + + {/* + + + + } + aria-describedby="header-search-text" + inputProps={{ + 'aria-label': 'weight' + }} + placeholder="Ctrl + K" + /> + */} + +); + +export default Search; diff --git a/client/src/layout/MainLayout/Header/HeaderContent/index.jsx b/client/src/layout/MainLayout/Header/HeaderContent/index.jsx new file mode 100644 index 0000000..9c65b12 --- /dev/null +++ b/client/src/layout/MainLayout/Header/HeaderContent/index.jsx @@ -0,0 +1,28 @@ +// material-ui +import { Box, IconButton, Link, useMediaQuery } from '@mui/material'; +import { GithubOutlined } from '@ant-design/icons'; + +// project import +import Search from './Search'; +import Profile from './Profile'; +import Notification from './Notification'; +import MobileSection from './MobileSection'; + +// ==============================|| HEADER - CONTENT ||============================== // + +const HeaderContent = () => { + const matchesXs = useMediaQuery((theme) => theme.breakpoints.down('md')); + + return ( + <> + {!matchesXs && } + {matchesXs && } + + + {!matchesXs && } + {matchesXs && } + + ); +}; + +export default HeaderContent; diff --git a/client/src/layout/MainLayout/Header/index.jsx b/client/src/layout/MainLayout/Header/index.jsx new file mode 100644 index 0000000..f18513d --- /dev/null +++ b/client/src/layout/MainLayout/Header/index.jsx @@ -0,0 +1,69 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { AppBar, IconButton, Toolbar, useMediaQuery } from '@mui/material'; + +// project import +import AppBarStyled from './AppBarStyled'; +import HeaderContent from './HeaderContent'; + +// assets +import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'; + +// ==============================|| MAIN LAYOUT - HEADER ||============================== // + +const Header = ({ open, handleDrawerToggle }) => { + const theme = useTheme(); + const matchDownMD = useMediaQuery(theme.breakpoints.down('lg')); + + const iconBackColor = 'grey.100'; + const iconBackColorOpen = 'grey.200'; + + // common header + const mainHeader = ( + + + {!open ? : } + + + + ); + + // app-bar params + const appBar = { + position: 'fixed', + color: 'inherit', + elevation: 0, + sx: { + borderBottom: `1px solid ${theme.palette.divider}` + // boxShadow: theme.customShadows.z1 + } + }; + + return ( + <> + {!matchDownMD ? ( + + {mainHeader} + + ) : ( + {mainHeader} + )} + + ); +}; + +Header.propTypes = { + open: PropTypes.bool, + handleDrawerToggle: PropTypes.func +}; + +export default Header; diff --git a/client/src/layout/MainLayout/index.jsx b/client/src/layout/MainLayout/index.jsx new file mode 100644 index 0000000..a6640ef --- /dev/null +++ b/client/src/layout/MainLayout/index.jsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { Outlet } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Box, Toolbar, useMediaQuery } from '@mui/material'; + +// project import +import Drawer from './Drawer'; +import Header from './Header'; +import navigation from '../../menu-items'; +import Breadcrumbs from '../../components/@extended/Breadcrumbs'; + +// types +import { openDrawer } from '../../store/reducers/menu'; + +// ==============================|| MAIN LAYOUT ||============================== // + +const MainLayout = () => { + const theme = useTheme(); + const matchDownLG = useMediaQuery(theme.breakpoints.down('xl')); + const dispatch = useDispatch(); + + const { drawerOpen } = useSelector((state) => state.menu); + + // drawer toggler + const [open, setOpen] = useState(drawerOpen); + const handleDrawerToggle = () => { + setOpen(!open); + dispatch(openDrawer({ drawerOpen: !open })); + }; + + // set media wise responsive drawer + useEffect(() => { + setOpen(!matchDownLG); + dispatch(openDrawer({ drawerOpen: !matchDownLG })); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [matchDownLG]); + + useEffect(() => { + if (open !== drawerOpen) setOpen(drawerOpen); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [drawerOpen]); + + return ( + +
+ + + + + + + + ); +}; + +export default MainLayout; diff --git a/client/src/layout/MinimalLayout/index.jsx b/client/src/layout/MinimalLayout/index.jsx new file mode 100644 index 0000000..d3fc724 --- /dev/null +++ b/client/src/layout/MinimalLayout/index.jsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; + +// ==============================|| MINIMAL LAYOUT ||============================== // + +const MinimalLayout = () => ( + <> + + +); + +export default MinimalLayout; diff --git a/client/src/main.css b/client/src/main.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..60801e6 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import './main.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + +
Hello
+
+) diff --git a/client/src/menu-items/dashboard.jsx b/client/src/menu-items/dashboard.jsx new file mode 100644 index 0000000..ba3f25d --- /dev/null +++ b/client/src/menu-items/dashboard.jsx @@ -0,0 +1,27 @@ +// assets +import { HomeOutlined } from '@ant-design/icons'; + +// icons +const icons = { + HomeOutlined +}; + +// ==============================|| MENU ITEMS - DASHBOARD ||============================== // + +const dashboard = { + id: 'group-dashboard', + title: 'Navigation', + type: 'group', + children: [ + { + id: 'home', + title: 'Home', + type: 'item', + url: '/', + icon: icons.HomeOutlined, + breadcrumbs: false + } + ] +}; + +export default dashboard; diff --git a/client/src/menu-items/index.jsx b/client/src/menu-items/index.jsx new file mode 100644 index 0000000..e5b5365 --- /dev/null +++ b/client/src/menu-items/index.jsx @@ -0,0 +1,12 @@ +// project import +import pages from './pages'; +import dashboard from './dashboard'; +import support from './support'; + +// ==============================|| MENU ITEMS ||============================== // + +const menuItems = { + items: [dashboard, pages, support] +}; + +export default menuItems; diff --git a/client/src/menu-items/pages.jsx b/client/src/menu-items/pages.jsx new file mode 100644 index 0000000..ca4f92b --- /dev/null +++ b/client/src/menu-items/pages.jsx @@ -0,0 +1,42 @@ +// assets +import { ProfileOutlined, SettingOutlined, NodeExpandOutlined} from '@ant-design/icons'; + +// icons +const icons = { + NodeExpandOutlined, + ProfileOutlined, + SettingOutlined +}; + +// ==============================|| MENU ITEMS - EXTRA PAGES ||============================== // + +const pages = { + id: 'management', + title: 'Management', + type: 'group', + children: [ + { + id: 'proxy', + title: 'Proxy Routes', + type: 'item', + url: '/config/proxy', + icon: icons.NodeExpandOutlined, + }, + { + id: 'users', + title: 'Manage Users', + type: 'item', + url: '/config/users', + icon: icons.ProfileOutlined, + }, + { + id: 'config', + title: 'Configuration', + type: 'item', + url: '/config/general', + icon: icons.SettingOutlined, + } + ] +}; + +export default pages; diff --git a/client/src/menu-items/support.jsx b/client/src/menu-items/support.jsx new file mode 100644 index 0000000..a1a31f1 --- /dev/null +++ b/client/src/menu-items/support.jsx @@ -0,0 +1,48 @@ +// assets +import { GithubOutlined, QuestionOutlined } from '@ant-design/icons'; +import DiscordOutlined from '../assets/images/icons/discord.svg' + +// ==============================|| MENU ITEMS - SAMPLE PAGE & DOCUMENTATION ||============================== // + +const DiscordOutlinedIcon = (props) => { + return ( + Discord + ); +}; + +const support = { + id: 'support', + title: 'Support', + type: 'group', + children: [ + { + id: 'discord', + title: 'Discord', + type: 'item', + url: 'https://discord.com/invite/PwMWwsrwHA', + icon: DiscordOutlinedIcon, + external: true, + target: true + }, + { + id: 'github', + title: 'Github', + type: 'item', + url: 'https://github.com/azukaar/Cosmos-Server', + icon: GithubOutlined, + external: true, + target: true + }, + { + id: 'documentation', + title: 'Documentation', + type: 'item', + url: 'https://github.com/azukaar/Cosmos-Server/wiki', + icon: QuestionOutlined, + external: true, + target: true + } + ] +}; + +export default support; diff --git a/client/src/pages/authentication/AuthCard.jsx b/client/src/pages/authentication/AuthCard.jsx new file mode 100644 index 0000000..0607bad --- /dev/null +++ b/client/src/pages/authentication/AuthCard.jsx @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { Box } from '@mui/material'; + +// project import +import MainCard from '../../components/MainCard'; + +// ==============================|| AUTHENTICATION - CARD WRAPPER ||============================== // + +const AuthCard = ({ children, ...other }) => ( + *': { + flexGrow: 1, + flexBasis: '50%' + } + }} + content={false} + {...other} + border={false} + boxShadow + shadow={(theme) => theme.customShadows.z1} + > + {children} + +); + +AuthCard.propTypes = { + children: PropTypes.node +}; + +export default AuthCard; diff --git a/client/src/pages/authentication/AuthWrapper.jsx b/client/src/pages/authentication/AuthWrapper.jsx new file mode 100644 index 0000000..cdcea5a --- /dev/null +++ b/client/src/pages/authentication/AuthWrapper.jsx @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { Box, Grid } from '@mui/material'; + +// project import +import AuthCard from './AuthCard'; +import Logo from '../../components/Logo'; +import AuthFooter from '../../components/cards/AuthFooter'; + +// assets +import AuthBackground from '../../assets/images/auth/AuthBackground'; + +// ==============================|| AUTHENTICATION - WRAPPER ||============================== // + +const AuthWrapper = ({ children }) => ( + + + + + + + + + + {children} + + + + + + + + +); + +AuthWrapper.propTypes = { + children: PropTypes.node +}; + +export default AuthWrapper; diff --git a/client/src/pages/authentication/Login.jsx b/client/src/pages/authentication/Login.jsx new file mode 100644 index 0000000..fed18e9 --- /dev/null +++ b/client/src/pages/authentication/Login.jsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom'; + +// material-ui +import { Grid, Stack, Typography } from '@mui/material'; + +// project import +import AuthLogin from './auth-forms/AuthLogin'; +import AuthWrapper from './AuthWrapper'; + +// ================================|| LOGIN ||================================ // + +const Login = () => ( + + + + + Login + {/* + Don't have an account? + */} + + + + + + + +); + +export default Login; diff --git a/client/src/pages/authentication/Register.jsx b/client/src/pages/authentication/Register.jsx new file mode 100644 index 0000000..a22df0a --- /dev/null +++ b/client/src/pages/authentication/Register.jsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom'; + +// material-ui +import { Grid, Stack, Typography } from '@mui/material'; + +// project import +import FirebaseRegister from './auth-forms/AuthRegister'; +import AuthWrapper from './AuthWrapper'; + +// ================================|| REGISTER ||================================ // + +const Register = () => ( + + + + + Sign up + + Already have an account? + + + + + + + + +); + +export default Register; diff --git a/client/src/pages/authentication/auth-forms/AuthLogin.jsx b/client/src/pages/authentication/auth-forms/AuthLogin.jsx new file mode 100644 index 0000000..424dc14 --- /dev/null +++ b/client/src/pages/authentication/auth-forms/AuthLogin.jsx @@ -0,0 +1,226 @@ +import React, { useEffect } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +// material-ui +import { + Button, + Checkbox, + Divider, + FormControlLabel, + FormHelperText, + Grid, + Link, + IconButton, + InputAdornment, + InputLabel, + OutlinedInput, + Stack, + Typography, + Alert +} from '@mui/material'; + +import * as API from "../../../api"; + +// third party +import * as Yup from 'yup'; +import { Formik } from 'formik'; + +// project import +import FirebaseSocial from './FirebaseSocial'; +import AnimateButton from '../../../components/@extended/AnimateButton'; + +// assets +import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; + +// ============================|| FIREBASE - LOGIN ||============================ // + +const AuthLogin = () => { + const [checked, setChecked] = React.useState(false); + + const [showPassword, setShowPassword] = React.useState(false); + const handleClickShowPassword = () => { + setShowPassword(!showPassword); + }; + + const handleMouseDownPassword = (event) => { + event.preventDefault(); + }; + + // TODO: Add ?notlogged=1 and ?invalid=1 to check for errors + // TODO: Extract ?redirect= to redirect to a specific page after login + const urlSearchParams = new URLSearchParams(window.location.search); + const notLogged = urlSearchParams.get('notlogged') == 1; + const invalid = urlSearchParams.get('invalid') == 1; + const redirectTo = urlSearchParams.get('redirect') ? urlSearchParams.get('redirect') : '/'; + + useEffect(() => { + API.auth.me().then((data) => { + if(data.status == 'OK') { + window.location.href = redirectTo; + } + }); + }); + + return ( + <> + { notLogged && + You need to be logged in to access this +
+
} + + { invalid && + You have been disconnected. Please login to continue +
+
} + { + try { + API.auth.login(values).then((data) => { + if(data.status == 'error') { + setStatus({ success: false }); + if(data.code == 'UL001') { + setErrors({ submit: 'Wrong nickname or password. Try again or try resetting your password' }); + } else if (data.code == 'UL002') { + setErrors({ submit: 'You have not yet registered your account. You should have an invite link in your emails. If you need a new one, contact your administrator.' }); + } else if(data.status == 'error') { + setErrors({ submit: 'Unexpected error. Try again later.' }); + } + setSubmitting(false); + return; + } else { + setStatus({ success: true }); + setSubmitting(false); + window.location.href = redirectTo; + } + }) + } catch (err) { + setStatus({ success: false }); + setErrors({ submit: err.message }); + setSubmitting(false); + } + }} + > + {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => ( +
+ + + + Nickname + + {touched.nickname && errors.nickname && ( + + {errors.nickname} + + )} + + + + + Password + + + {showPassword ? : } + + + } + placeholder="Enter your password" + /> + {touched.password && errors.password && ( + + {errors.password} + + )} + + + + + + setChecked(event.target.checked)} + name="checked" + color="primary" + size="small" + /> + } + label={Keep me sign in} + /> + + Forgot Password? + + + + {errors.submit && ( + + {errors.submit} + + )} + + + + + + {/* + + Login with + + + + + */} + +
+ )} +
+ + ); +}; + +export default AuthLogin; diff --git a/client/src/pages/authentication/auth-forms/AuthRegister.jsx b/client/src/pages/authentication/auth-forms/AuthRegister.jsx new file mode 100644 index 0000000..d905875 --- /dev/null +++ b/client/src/pages/authentication/auth-forms/AuthRegister.jsx @@ -0,0 +1,271 @@ +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +// material-ui +import { + Box, + Button, + Divider, + FormControl, + FormHelperText, + Grid, + Link, + IconButton, + InputAdornment, + InputLabel, + OutlinedInput, + Stack, + Typography +} from '@mui/material'; + +// third party +import * as Yup from 'yup'; +import { Formik } from 'formik'; + +// project import +import FirebaseSocial from './FirebaseSocial'; +import AnimateButton from '../../../components/@extended/AnimateButton'; +import { strengthColor, strengthIndicator } from '../../../utils/password-strength'; + +// assets +import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; + +// ============================|| FIREBASE - REGISTER ||============================ // + +const AuthRegister = () => { + const [level, setLevel] = useState(); + const [showPassword, setShowPassword] = useState(false); + const handleClickShowPassword = () => { + setShowPassword(!showPassword); + }; + + const handleMouseDownPassword = (event) => { + event.preventDefault(); + }; + + const changePassword = (value) => { + const temp = strengthIndicator(value); + setLevel(strengthColor(temp)); + }; + + useEffect(() => { + changePassword(''); + }, []); + + return ( + <> + { + try { + setStatus({ success: false }); + setSubmitting(false); + } catch (err) { + console.error(err); + setStatus({ success: false }); + setErrors({ submit: err.message }); + setSubmitting(false); + } + }} + > + {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => ( +
+ + + + First Name* + + {touched.firstname && errors.firstname && ( + + {errors.firstname} + + )} + + + + + Last Name* + + {touched.lastname && errors.lastname && ( + + {errors.lastname} + + )} + + + + + Company + + {touched.company && errors.company && ( + + {errors.company} + + )} + + + + + Email Address* + + {touched.email && errors.email && ( + + {errors.email} + + )} + + + + + Password + { + handleChange(e); + changePassword(e.target.value); + }} + endAdornment={ + + + {showPassword ? : } + + + } + placeholder="******" + inputProps={{}} + /> + {touched.password && errors.password && ( + + {errors.password} + + )} + + + + + + + + + {level?.label} + + + + + + + + By Signing up, you agree to our   + + Terms of Service + +   and   + + Privacy Policy + + + + {errors.submit && ( + + {errors.submit} + + )} + + + + + + + + Sign up with + + + + + + +
+ )} +
+ + ); +}; + +export default AuthRegister; diff --git a/client/src/pages/authentication/auth-forms/FirebaseSocial.jsx b/client/src/pages/authentication/auth-forms/FirebaseSocial.jsx new file mode 100644 index 0000000..05761a9 --- /dev/null +++ b/client/src/pages/authentication/auth-forms/FirebaseSocial.jsx @@ -0,0 +1,66 @@ +// material-ui +import { useTheme } from '@mui/material/styles'; +import { useMediaQuery, Button, Stack } from '@mui/material'; + +// assets +import Google from '../../../assets/images/icons/google.svg'; +import Twitter from '../../../assets/images/icons/twitter.svg'; +import Facebook from '../../../assets/images/icons/facebook.svg'; + +// ==============================|| FIREBASE - SOCIAL BUTTON ||============================== // + +const FirebaseSocial = () => { + const theme = useTheme(); + const matchDownSM = useMediaQuery(theme.breakpoints.down('sm')); + + const googleHandler = async () => { + // login || singup + }; + + const twitterHandler = async () => { + // login || singup + }; + + const facebookHandler = async () => { + // login || singup + }; + + return ( + + + + + + ); +}; + +export default FirebaseSocial; diff --git a/client/src/pages/components-overview/AntIcons.jsx b/client/src/pages/components-overview/AntIcons.jsx new file mode 100644 index 0000000..6cdbd0b --- /dev/null +++ b/client/src/pages/components-overview/AntIcons.jsx @@ -0,0 +1,24 @@ +// material-ui +import { styled } from '@mui/material/styles'; + +// project import +import ComponentSkeleton from './ComponentSkeleton'; +import MainCard from '../../components/MainCard'; + +// styles +const IFrameWrapper = styled('iframe')(() => ({ + height: 'calc(100vh - 210px)', + border: 'none' +})); + +// ============================|| ANT ICONS ||============================ // + +const AntIcons = () => ( + + + + + +); + +export default AntIcons; diff --git a/client/src/pages/components-overview/Color.jsx b/client/src/pages/components-overview/Color.jsx new file mode 100644 index 0000000..5a62b2d --- /dev/null +++ b/client/src/pages/components-overview/Color.jsx @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { Box, Card, Grid, Stack, Typography } from '@mui/material'; + +// project import +import MainCard from '../../components/MainCard'; +import ComponentSkeleton from './ComponentSkeleton'; + +// ===============================|| COLOR BOX ||=============================== // + +function ColorBox({ bgcolor, title, data, dark, main }) { + return ( + <> + + + {title && ( + + + {data && ( + + {data.label} + {data.color} + + )} + + + + {title} + + + + )} + + + + ); +} + +ColorBox.propTypes = { + bgcolor: PropTypes.string, + title: PropTypes.string, + data: PropTypes.object.isRequired, + dark: PropTypes.bool, + main: PropTypes.bool +}; + +// ===============================|| COMPONENT - COLOR ||=============================== // + +const ComponentColor = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default ComponentColor; diff --git a/client/src/pages/components-overview/ComponentSkeleton.jsx b/client/src/pages/components-overview/ComponentSkeleton.jsx new file mode 100644 index 0000000..999dc03 --- /dev/null +++ b/client/src/pages/components-overview/ComponentSkeleton.jsx @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; + +// material-ui +import { Grid, Skeleton, Stack } from '@mui/material'; + +// project import +import MainCard from '../../components/MainCard'; + +// ===============================|| COMPONENT - SKELETON ||=============================== // + +const ComponentSkeleton = ({ children }) => { + const [isLoading, setLoading] = useState(true); + useEffect(() => { + setLoading(false); + }, []); + + const skeletonCard = ( + } + secondary={} + > + + + + + + + + ); + + return ( + <> + {isLoading && ( + + + {skeletonCard} + + + {skeletonCard} + + + {skeletonCard} + + + {skeletonCard} + + + )} + {!isLoading && children} + + ); +}; + +ComponentSkeleton.propTypes = { + children: PropTypes.node +}; + +export default ComponentSkeleton; diff --git a/client/src/pages/components-overview/Shadow.jsx b/client/src/pages/components-overview/Shadow.jsx new file mode 100644 index 0000000..e0c3481 --- /dev/null +++ b/client/src/pages/components-overview/Shadow.jsx @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { useTheme } from '@mui/material/styles'; +import { Grid, Stack, Typography } from '@mui/material'; + +// project import +import MainCard from '../../components/MainCard'; +import ComponentSkeleton from './ComponentSkeleton'; + +// ===============================|| SHADOW BOX ||=============================== // + +function ShadowBox({ shadow }) { + return ( + + + boxShadow + {shadow} + + + ); +} + +ShadowBox.propTypes = { + shadow: PropTypes.string.isRequired +}; + +// ===============================|| CUSTOM - SHADOW BOX ||=============================== // + +function CustomShadowBox({ shadow, label, color, bgcolor }) { + return ( + + + + {label} + + + + ); +} + +CustomShadowBox.propTypes = { + shadow: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + bgcolor: PropTypes.string.isRequired +}; + +// ============================|| COMPONENT - SHADOW ||============================ // + +const ComponentShadow = () => { + const theme = useTheme(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ComponentShadow; diff --git a/client/src/pages/components-overview/Typography.jsx b/client/src/pages/components-overview/Typography.jsx new file mode 100644 index 0000000..71ee3c1 --- /dev/null +++ b/client/src/pages/components-overview/Typography.jsx @@ -0,0 +1,263 @@ +// material-ui +import { Breadcrumbs, Divider, Grid, Link, Stack, Typography } from '@mui/material'; + +// project import +import ComponentSkeleton from './ComponentSkeleton'; +import MainCard from '../../components/MainCard'; + +// ==============================|| COMPONENTS - TYPOGRAPHY ||============================== // + +const ComponentTypography = () => ( + + + + + + + Inter + Font Family + + Regular + Medium + Bold + + + + + + H1 Heading + + Size: 38px + Weight: Bold + Line Height: 46px + + + + H2 Heading + + Size: 30px + Weight: Bold + Line Height: 38px + + + + H3 Heading + + Size: 24px + Weight: Regular & Bold + Line Height: 32px + + + + H4 Heading + + Size: 20px + Weight: Bold + Line Height: 28px + + + + H5 Heading + + Size: 16px + Weight: Regular & Medium & Bold + Line Height: 24px + + + + H6 Heading / Subheading + + Size: 14px + Weight: Regular + Line Height: 22px + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 14px + Weight: Regular + Line Height: 22px + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 12px + Weight: Regular + Line Height: 20px + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 14px + Weight: Medium + Line Height: 22px + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 12px + Weight: Medium + Line Height: 20px + + + + + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 12px + Weight: Regular + Line Height: 20px + + + + + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 12px + Weight: Regular + Line Height: 20px + + + + + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 12px + Weight: Regular + Line Height: 20px + + + + + + www.mantis.com + + Size: 12px + Weight: Regular + Line Height: 20px + + + + + <> + + This is textPrimary text color. + + + This is textSecondary text color. + + + This is primary text color. + + + This is secondary text color. + + + This is success text color. + + + This is warning text color. + + + This is error text color. + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. + + + Size: 14px + Weight: Regular + Line Height: 22px + + + + + <> + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. + + + Size: 14px + Weight: Italic Regular & Italic Bold + Line Height: 22px + + + + + + + +); + +export default ComponentTypography; diff --git a/client/src/pages/dashboard/IncomeAreaChart.jsx b/client/src/pages/dashboard/IncomeAreaChart.jsx new file mode 100644 index 0000000..b7acc54 --- /dev/null +++ b/client/src/pages/dashboard/IncomeAreaChart.jsx @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import { useState, useEffect } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; + +// third-party +import ReactApexChart from 'react-apexcharts'; + +// chart options +const areaChartOptions = { + chart: { + height: 450, + type: 'area', + toolbar: { + show: false + } + }, + dataLabels: { + enabled: false + }, + stroke: { + curve: 'smooth', + width: 2 + }, + grid: { + strokeDashArray: 0 + } +}; + +// ==============================|| INCOME AREA CHART ||============================== // + +const IncomeAreaChart = ({ slot }) => { + const theme = useTheme(); + + const { primary, secondary } = theme.palette.text; + const line = theme.palette.divider; + + const [options, setOptions] = useState(areaChartOptions); + + useEffect(() => { + setOptions((prevState) => ({ + ...prevState, + colors: [theme.palette.primary.main, theme.palette.primary[700]], + xaxis: { + categories: + slot === 'month' + ? ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + labels: { + style: { + colors: [ + secondary, + secondary, + secondary, + secondary, + secondary, + secondary, + secondary, + secondary, + secondary, + secondary, + secondary, + secondary + ] + } + }, + axisBorder: { + show: true, + color: line + }, + tickAmount: slot === 'month' ? 11 : 7 + }, + yaxis: { + labels: { + style: { + colors: [secondary] + } + } + }, + grid: { + borderColor: line + }, + tooltip: { + theme: 'light' + } + })); + }, [primary, secondary, line, theme, slot]); + + const [series, setSeries] = useState([ + { + name: 'Page Views', + data: [0, 86, 28, 115, 48, 210, 136] + }, + { + name: 'Sessions', + data: [0, 43, 14, 56, 24, 105, 68] + } + ]); + + useEffect(() => { + setSeries([ + { + name: 'Page Views', + data: slot === 'month' ? [76, 85, 101, 98, 87, 105, 91, 114, 94, 86, 115, 35] : [31, 40, 28, 51, 42, 109, 100] + }, + { + name: 'Sessions', + data: slot === 'month' ? [110, 60, 150, 35, 60, 36, 26, 45, 65, 52, 53, 41] : [11, 32, 45, 32, 34, 52, 41] + } + ]); + }, [slot]); + + return ; +}; + +IncomeAreaChart.propTypes = { + slot: PropTypes.string +}; + +export default IncomeAreaChart; diff --git a/client/src/pages/dashboard/MonthlyBarChart.jsx b/client/src/pages/dashboard/MonthlyBarChart.jsx new file mode 100644 index 0000000..10cd0ad --- /dev/null +++ b/client/src/pages/dashboard/MonthlyBarChart.jsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; + +// third-party +import ReactApexChart from 'react-apexcharts'; + +// chart options +const barChartOptions = { + chart: { + type: 'bar', + height: 365, + toolbar: { + show: false + } + }, + plotOptions: { + bar: { + columnWidth: '45%', + borderRadius: 4 + } + }, + dataLabels: { + enabled: false + }, + xaxis: { + categories: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'], + axisBorder: { + show: false + }, + axisTicks: { + show: false + } + }, + yaxis: { + show: false + }, + grid: { + show: false + } +}; + +// ==============================|| MONTHLY BAR CHART ||============================== // + +const MonthlyBarChart = () => { + const theme = useTheme(); + + const { primary, secondary } = theme.palette.text; + const info = theme.palette.info.light; + + const [series] = useState([ + { + data: [80, 95, 70, 42, 65, 55, 78] + } + ]); + + const [options, setOptions] = useState(barChartOptions); + + useEffect(() => { + setOptions((prevState) => ({ + ...prevState, + colors: [info], + xaxis: { + labels: { + style: { + colors: [secondary, secondary, secondary, secondary, secondary, secondary, secondary] + } + } + }, + tooltip: { + theme: 'light' + } + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [primary, info, secondary]); + + return ( +
+ +
+ ); +}; + +export default MonthlyBarChart; diff --git a/client/src/pages/dashboard/OrdersTable.jsx b/client/src/pages/dashboard/OrdersTable.jsx new file mode 100644 index 0000000..a9a1781 --- /dev/null +++ b/client/src/pages/dashboard/OrdersTable.jsx @@ -0,0 +1,224 @@ +import PropTypes from 'prop-types'; +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +// material-ui +import { Box, Link, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; + +// third-party +import NumberFormat from 'react-number-format'; + +// project import +import Dot from '../../components/@extended/Dot'; + +function createData(trackingNo, name, fat, carbs, protein) { + return { trackingNo, name, fat, carbs, protein }; +} + +const rows = [ + createData(84564564, 'Camera Lens', 40, 2, 40570), + createData(98764564, 'Laptop', 300, 0, 180139), + createData(98756325, 'Mobile', 355, 1, 90989), + createData(98652366, 'Handset', 50, 1, 10239), + createData(13286564, 'Computer Accessories', 100, 1, 83348), + createData(86739658, 'TV', 99, 0, 410780), + createData(13256498, 'Keyboard', 125, 2, 70999), + createData(98753263, 'Mouse', 89, 2, 10570), + createData(98753275, 'Desktop', 185, 1, 98063), + createData(98753291, 'Chair', 100, 0, 14001) +]; + +function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +function getComparator(order, orderBy) { + return order === 'desc' ? (a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy); +} + +function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); + stabilizedThis.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + }); + return stabilizedThis.map((el) => el[0]); +} + +// ==============================|| ORDER TABLE - HEADER CELL ||============================== // + +const headCells = [ + { + id: 'trackingNo', + align: 'left', + disablePadding: false, + label: 'Tracking No.' + }, + { + id: 'name', + align: 'left', + disablePadding: true, + label: 'Product Name' + }, + { + id: 'fat', + align: 'right', + disablePadding: false, + label: 'Total Order' + }, + { + id: 'carbs', + align: 'left', + disablePadding: false, + + label: 'Status' + }, + { + id: 'protein', + align: 'right', + disablePadding: false, + label: 'Total Amount' + } +]; + +// ==============================|| ORDER TABLE - HEADER ||============================== // + +function OrderTableHead({ order, orderBy }) { + return ( + + + {headCells.map((headCell) => ( + + {headCell.label} + + ))} + + + ); +} + +OrderTableHead.propTypes = { + order: PropTypes.string, + orderBy: PropTypes.string +}; + +// ==============================|| ORDER TABLE - STATUS ||============================== // + +const OrderStatus = ({ status }) => { + let color; + let title; + + switch (status) { + case 0: + color = 'warning'; + title = 'Pending'; + break; + case 1: + color = 'success'; + title = 'Approved'; + break; + case 2: + color = 'error'; + title = 'Rejected'; + break; + default: + color = 'primary'; + title = 'None'; + } + + return ( + + + {title} + + ); +}; + +OrderStatus.propTypes = { + status: PropTypes.number +}; + +// ==============================|| ORDER TABLE ||============================== // + +export default function OrderTable() { + const [order] = useState('asc'); + const [orderBy] = useState('trackingNo'); + const [selected] = useState([]); + + const isSelected = (trackingNo) => selected.indexOf(trackingNo) !== -1; + + return ( + + + + + + {stableSort(rows, getComparator(order, orderBy)).map((row, index) => { + const isItemSelected = isSelected(row.trackingNo); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + + + + {row.trackingNo} + + + {row.name} + {row.fat} + + + + + + + + ); + })} + +
+
+
+ ); +} diff --git a/client/src/pages/dashboard/ReportAreaChart.jsx b/client/src/pages/dashboard/ReportAreaChart.jsx new file mode 100644 index 0000000..ed91490 --- /dev/null +++ b/client/src/pages/dashboard/ReportAreaChart.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; + +// third-party +import ReactApexChart from 'react-apexcharts'; + +// chart options +const areaChartOptions = { + chart: { + height: 340, + type: 'line', + toolbar: { + show: false + } + }, + dataLabels: { + enabled: false + }, + stroke: { + curve: 'smooth', + width: 1.5 + }, + grid: { + strokeDashArray: 4 + }, + xaxis: { + type: 'datetime', + categories: [ + '2018-05-19T00:00:00.000Z', + '2018-06-19T00:00:00.000Z', + '2018-07-19T01:30:00.000Z', + '2018-08-19T02:30:00.000Z', + '2018-09-19T03:30:00.000Z', + '2018-10-19T04:30:00.000Z', + '2018-11-19T05:30:00.000Z', + '2018-12-19T06:30:00.000Z' + ], + labels: { + format: 'MMM' + }, + axisBorder: { + show: false + }, + axisTicks: { + show: false + } + }, + yaxis: { + show: false + }, + tooltip: { + x: { + format: 'MM' + } + } +}; + +// ==============================|| REPORT AREA CHART ||============================== // + +const ReportAreaChart = () => { + const theme = useTheme(); + + const { primary, secondary } = theme.palette.text; + const line = theme.palette.divider; + + const [options, setOptions] = useState(areaChartOptions); + + useEffect(() => { + setOptions((prevState) => ({ + ...prevState, + colors: [theme.palette.warning.main], + xaxis: { + labels: { + style: { + colors: [secondary, secondary, secondary, secondary, secondary, secondary, secondary, secondary] + } + } + }, + grid: { + borderColor: line + }, + tooltip: { + theme: 'light' + }, + legend: { + labels: { + colors: 'grey.500' + } + } + })); + }, [primary, secondary, line, theme]); + + const [series] = useState([ + { + name: 'Series 1', + data: [58, 115, 28, 83, 63, 75, 35, 55] + } + ]); + + return ; +}; + +export default ReportAreaChart; diff --git a/client/src/pages/dashboard/SalesColumnChart.jsx b/client/src/pages/dashboard/SalesColumnChart.jsx new file mode 100644 index 0000000..beb789b --- /dev/null +++ b/client/src/pages/dashboard/SalesColumnChart.jsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; + +// material-ui +import { useTheme } from '@mui/material/styles'; + +// third-party +import ReactApexChart from 'react-apexcharts'; + +// chart options +const columnChartOptions = { + chart: { + type: 'bar', + height: 430, + toolbar: { + show: false + } + }, + plotOptions: { + bar: { + columnWidth: '30%', + borderRadius: 4 + } + }, + dataLabels: { + enabled: false + }, + stroke: { + show: true, + width: 8, + colors: ['transparent'] + }, + xaxis: { + categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] + }, + yaxis: { + title: { + text: '$ (thousands)' + } + }, + fill: { + opacity: 1 + }, + tooltip: { + y: { + formatter(val) { + return `$ ${val} thousands`; + } + } + }, + legend: { + show: true, + fontFamily: `'Public Sans', sans-serif`, + offsetX: 10, + offsetY: 10, + labels: { + useSeriesColors: false + }, + markers: { + width: 16, + height: 16, + radius: '50%', + offsexX: 2, + offsexY: 2 + }, + itemMargin: { + horizontal: 15, + vertical: 50 + } + }, + responsive: [ + { + breakpoint: 600, + options: { + yaxis: { + show: false + } + } + } + ] +}; + +// ==============================|| SALES COLUMN CHART ||============================== // + +const SalesColumnChart = () => { + const theme = useTheme(); + + const { primary, secondary } = theme.palette.text; + const line = theme.palette.divider; + + const warning = theme.palette.warning.main; + const primaryMain = theme.palette.primary.main; + const successDark = theme.palette.success.dark; + + const [series] = useState([ + { + name: 'Net Profit', + data: [180, 90, 135, 114, 120, 145] + }, + { + name: 'Revenue', + data: [120, 45, 78, 150, 168, 99] + } + ]); + + const [options, setOptions] = useState(columnChartOptions); + + useEffect(() => { + setOptions((prevState) => ({ + ...prevState, + colors: [warning, primaryMain], + xaxis: { + labels: { + style: { + colors: [secondary, secondary, secondary, secondary, secondary, secondary] + } + } + }, + yaxis: { + labels: { + style: { + colors: [secondary] + } + } + }, + grid: { + borderColor: line + }, + tooltip: { + theme: 'light' + }, + legend: { + position: 'top', + horizontalAlign: 'right', + labels: { + colors: 'grey.500' + } + } + })); + }, [primary, secondary, line, warning, primaryMain, successDark]); + + return ( +
+ +
+ ); +}; + +export default SalesColumnChart; diff --git a/client/src/pages/dashboard/index.jsx b/client/src/pages/dashboard/index.jsx new file mode 100644 index 0000000..f21b8c8 --- /dev/null +++ b/client/src/pages/dashboard/index.jsx @@ -0,0 +1,358 @@ +import { useState } from 'react'; + +// material-ui +import { + Avatar, + AvatarGroup, + Box, + Button, + Grid, + List, + ListItemAvatar, + ListItemButton, + ListItemSecondaryAction, + ListItemText, + MenuItem, + Stack, + TextField, + Typography, + Alert +} from '@mui/material'; + +// project import +import OrdersTable from './OrdersTable'; +import IncomeAreaChart from './IncomeAreaChart'; +import MonthlyBarChart from './MonthlyBarChart'; +import ReportAreaChart from './ReportAreaChart'; +import SalesColumnChart from './SalesColumnChart'; +import MainCard from '../../components/MainCard'; +import AnalyticEcommerce from '../../components/cards/statistics/AnalyticEcommerce'; + +// assets +import { GiftOutlined, MessageOutlined, SettingOutlined } from '@ant-design/icons'; +import avatar1 from '../../assets/images/users/avatar-1.png'; +import avatar2 from '../../assets/images/users/avatar-2.png'; +import avatar3 from '../../assets/images/users/avatar-3.png'; +import avatar4 from '../../assets/images/users/avatar-4.png'; +import isLoggedIn from '../../isLoggedIn'; + +// avatar style +const avatarSX = { + width: 36, + height: 36, + fontSize: '1rem' +}; + +// action style +const actionSX = { + mt: 0.75, + ml: 1, + top: 'auto', + right: 'auto', + alignSelf: 'flex-start', + transform: 'none' +}; + +// sales report status +const status = [ + { + value: 'today', + label: 'Today' + }, + { + value: 'month', + label: 'This Month' + }, + { + value: 'year', + label: 'This Year' + } +]; + +// ==============================|| DASHBOARD - DEFAULT ||============================== // + +const DashboardDefault = () => { + const [value, setValue] = useState('today'); + const [slot, setSlot] = useState('week'); + + isLoggedIn(); + + return ( + <> +
+ Dashboard implementation currently in progress! If you want to voice your opinion on where Cosmos is going, please join us on Discord! +
+
+ + {/* row 1 */} + + Dashboard + + + + + + + + + + + + + + + + + {/* row 2 */} + + + + Unique Visitor + + + + + + + + + + + + + + + + + + Income Overview + + + + + + + + This Week Statistics + + $7,650 + + + + + + + {/* row 3 */} + + + + Recent Orders + + + + + + + + + + + Analytics Report + + + + + + + + +45.14% + + + + 0.58% + + + + Low + + + + + + + {/* row 4 */} + + + + Sales Report + + + setValue(e.target.value)} + sx={{ '& .MuiInputBase-input': { py: 0.5, fontSize: '0.875rem' } }} + > + {status.map((option) => ( + + {option.label} + + ))} + + + + + + + Net Profit + + $1560 + + + + + + + + Transaction History + + + + + + + + + + + + Order #002434} secondary="Today, 2:00 AM" /> + + + + + $1,430 + + + 78% + + + + + + + + + + + Order #984947} + secondary="5 August, 1:45 PM" + /> + + + + + $302 + + + 8% + + + + + + + + + + + Order #988784} secondary="7 hours ago" /> + + + + + $682 + + + 16% + + + + + + + + + + + + + Help & Support Chat + + + Typical replay within 5 min + + + + + + + + + + + + + + + + + +
+ + ); +}; + +export default DashboardDefault; diff --git a/client/src/pages/extra-pages/SamplePage.jsx b/client/src/pages/extra-pages/SamplePage.jsx new file mode 100644 index 0000000..2cf4192 --- /dev/null +++ b/client/src/pages/extra-pages/SamplePage.jsx @@ -0,0 +1,20 @@ +// material-ui +import { Typography } from '@mui/material'; + +// project import +import MainCard from '../../components/MainCard'; + +// ==============================|| SAMPLE PAGE ||============================== // + +const SamplePage = () => ( + + + Lorem ipsum dolor sit amen, consenter nipissing eli, sed do elusion tempos incident ut laborers et doolie magna alissa. Ut enif + ad minim venice, quin nostrum exercitation illampu laborings nisi ut liquid ex ea commons construal. Duos aube grue dolor in + reprehended in voltage veil esse colum doolie eu fujian bulla parian. Exceptive sin ocean cuspidate non president, sunk in culpa + qui officiate descent molls anim id est labours. + + +); + +export default SamplePage; diff --git a/client/src/react-app-env.d.jsx b/client/src/react-app-env.d.jsx new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/client/src/react-app-env.d.jsx @@ -0,0 +1 @@ +/// diff --git a/client/src/reportWebVitals.jsx b/client/src/reportWebVitals.jsx new file mode 100644 index 0000000..17f8ad1 --- /dev/null +++ b/client/src/reportWebVitals.jsx @@ -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; diff --git a/client/src/routes/LoginRoutes.jsx b/client/src/routes/LoginRoutes.jsx new file mode 100644 index 0000000..ffd0031 --- /dev/null +++ b/client/src/routes/LoginRoutes.jsx @@ -0,0 +1,28 @@ +import { lazy } from 'react'; + +// project import +import Loadable from '../components/Loadable'; +import MinimalLayout from '../layout/MinimalLayout'; + +// render - login +const AuthLogin = Loadable(lazy(() => import('../pages/authentication/Login'))); +const AuthRegister = Loadable(lazy(() => import('../pages/authentication/Register'))); + +// ==============================|| AUTH ROUTING ||============================== // + +const LoginRoutes = { + path: '/', + element: , + children: [ + { + path: 'login', + element: + }, + { + path: 'register', + element: + } + ] +}; + +export default LoginRoutes; diff --git a/client/src/routes/MainRoutes.jsx b/client/src/routes/MainRoutes.jsx new file mode 100644 index 0000000..bc228cf --- /dev/null +++ b/client/src/routes/MainRoutes.jsx @@ -0,0 +1,61 @@ +import { lazy } from 'react'; + +// project import +import Loadable from '../components/Loadable'; +import MainLayout from '../layout/MainLayout'; + +// render - dashboard +const DashboardDefault = Loadable(lazy(() => import('../pages/dashboard'))); + +// render - sample page +const SamplePage = Loadable(lazy(() => import('../pages/extra-pages/SamplePage'))); + +// render - utilities +const Typography = Loadable(lazy(() => import('../pages/components-overview/Typography'))); +const Color = Loadable(lazy(() => import('../pages/components-overview/Color'))); +const Shadow = Loadable(lazy(() => import('../pages/components-overview/Shadow'))); +const AntIcons = Loadable(lazy(() => import('../pages/components-overview/AntIcons'))); + +// ==============================|| MAIN ROUTING ||============================== // + +const MainRoutes = { + path: '/', + element: , + children: [ + { + path: '/', + element: + }, + { + path: 'color', + element: + }, + { + path: 'dashboard', + children: [ + { + path: 'default', + element: + } + ] + }, + { + path: 'sample-page', + element: + }, + { + path: 'shadow', + element: + }, + { + path: 'typography', + element: + }, + { + path: 'icons/ant', + element: + } + ] +}; + +export default MainRoutes; diff --git a/client/src/routes/index.jsx b/client/src/routes/index.jsx new file mode 100644 index 0000000..4c82fe3 --- /dev/null +++ b/client/src/routes/index.jsx @@ -0,0 +1,11 @@ +import { useRoutes } from 'react-router-dom'; + +// project import +import LoginRoutes from './LoginRoutes'; +import MainRoutes from './MainRoutes'; + +// ==============================|| ROUTING RENDER ||============================== // + +export default function ThemeRoutes() { + return useRoutes([MainRoutes, LoginRoutes]); +} diff --git a/client/src/setupTests.jsx b/client/src/setupTests.jsx new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/client/src/setupTests.jsx @@ -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'; diff --git a/client/src/store/index.jsx b/client/src/store/index.jsx new file mode 100644 index 0000000..736fe07 --- /dev/null +++ b/client/src/store/index.jsx @@ -0,0 +1,15 @@ +// third-party +import { configureStore } from '@reduxjs/toolkit'; + +// project import +import reducers from './reducers'; + +// ==============================|| REDUX TOOLKIT - MAIN STORE ||============================== // + +const store = configureStore({ + reducer: reducers +}); + +const { dispatch } = store; + +export { store, dispatch }; diff --git a/client/src/store/reducers/actions.jsx b/client/src/store/reducers/actions.jsx new file mode 100644 index 0000000..d5a0eb8 --- /dev/null +++ b/client/src/store/reducers/actions.jsx @@ -0,0 +1,4 @@ +// action - account reducer +export const LOGIN = '@auth/LOGIN'; +export const LOGOUT = '@auth/LOGOUT'; +export const REGISTER = '@auth/REGISTER'; diff --git a/client/src/store/reducers/index.jsx b/client/src/store/reducers/index.jsx new file mode 100644 index 0000000..e5c3261 --- /dev/null +++ b/client/src/store/reducers/index.jsx @@ -0,0 +1,11 @@ +// third-party +import { combineReducers } from 'redux'; + +// project import +import menu from './menu'; + +// ==============================|| COMBINE REDUCERS ||============================== // + +const reducers = combineReducers({ menu }); + +export default reducers; diff --git a/client/src/store/reducers/menu.jsx b/client/src/store/reducers/menu.jsx new file mode 100644 index 0000000..00aba2e --- /dev/null +++ b/client/src/store/reducers/menu.jsx @@ -0,0 +1,38 @@ +// types +import { createSlice } from '@reduxjs/toolkit'; + +// initial state +const initialState = { + openItem: ['dashboard'], + openComponent: 'buttons', + drawerOpen: false, + componentDrawerOpen: true +}; + +// ==============================|| SLICE - MENU ||============================== // + +const menu = createSlice({ + name: 'menu', + initialState, + reducers: { + activeItem(state, action) { + state.openItem = action.payload.openItem; + }, + + activeComponent(state, action) { + state.openComponent = action.payload.openComponent; + }, + + openDrawer(state, action) { + state.drawerOpen = action.payload.drawerOpen; + }, + + openComponentDrawer(state, action) { + state.componentDrawerOpen = action.payload.componentDrawerOpen; + } + } +}); + +export default menu.reducer; + +export const { activeItem, activeComponent, openDrawer, openComponentDrawer } = menu.actions; diff --git a/client/src/themes/index.jsx b/client/src/themes/index.jsx new file mode 100644 index 0000000..4ef49ce --- /dev/null +++ b/client/src/themes/index.jsx @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import { useMemo } from 'react'; + +// material-ui +import { CssBaseline, StyledEngineProvider } from '@mui/material'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; + +// project import +import Palette from './palette'; +import Typography from './typography'; +import CustomShadows from './shadows'; +import componentsOverride from './overrides'; + +// ==============================|| DEFAULT THEME - MAIN ||============================== // + +export default function ThemeCustomization({ children }) { + const theme = Palette('light', 'default'); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const themeTypography = Typography(`'Public Sans', sans-serif`); + const themeCustomShadows = useMemo(() => CustomShadows(theme), [theme]); + + const themeOptions = useMemo( + () => ({ + breakpoints: { + values: { + xs: 0, + sm: 768, + md: 1024, + lg: 1266, + xl: 1536 + } + }, + direction: 'ltr', + mixins: { + toolbar: { + minHeight: 60, + paddingTop: 8, + paddingBottom: 8 + } + }, + palette: theme.palette, + customShadows: themeCustomShadows, + typography: themeTypography + }), + [theme, themeTypography, themeCustomShadows] + ); + + const themes = createTheme(themeOptions); + themes.components = componentsOverride(themes); + + return ( + + + + {children} + + + ); +} + +ThemeCustomization.propTypes = { + children: PropTypes.node +}; diff --git a/client/src/themes/overrides/Badge.jsx b/client/src/themes/overrides/Badge.jsx new file mode 100644 index 0000000..21e8a8c --- /dev/null +++ b/client/src/themes/overrides/Badge.jsx @@ -0,0 +1,15 @@ +// ==============================|| OVERRIDES - BADGE ||============================== // + +export default function Badge(theme) { + return { + MuiBadge: { + styleOverrides: { + standard: { + minWidth: theme.spacing(2), + height: theme.spacing(2), + padding: theme.spacing(0.5) + } + } + } + }; +} diff --git a/client/src/themes/overrides/Button.jsx b/client/src/themes/overrides/Button.jsx new file mode 100644 index 0000000..3cf97db --- /dev/null +++ b/client/src/themes/overrides/Button.jsx @@ -0,0 +1,28 @@ +// ==============================|| OVERRIDES - BUTTON ||============================== // + +export default function Button(theme) { + const disabledStyle = { + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[200] + } + }; + + return { + MuiButton: { + defaultProps: { + disableElevation: true + }, + styleOverrides: { + root: { + fontWeight: 400 + }, + contained: { + ...disabledStyle + }, + outlined: { + ...disabledStyle + } + } + } + }; +} diff --git a/client/src/themes/overrides/CardContent.jsx b/client/src/themes/overrides/CardContent.jsx new file mode 100644 index 0000000..2edc544 --- /dev/null +++ b/client/src/themes/overrides/CardContent.jsx @@ -0,0 +1,16 @@ +// ==============================|| OVERRIDES - CARD CONTENT ||============================== // + +export default function CardContent() { + return { + MuiCardContent: { + styleOverrides: { + root: { + padding: 20, + '&:last-child': { + paddingBottom: 20 + } + } + } + } + }; +} diff --git a/client/src/themes/overrides/Checkbox.jsx b/client/src/themes/overrides/Checkbox.jsx new file mode 100644 index 0000000..8120949 --- /dev/null +++ b/client/src/themes/overrides/Checkbox.jsx @@ -0,0 +1,13 @@ +// ==============================|| OVERRIDES - CHECKBOX ||============================== // + +export default function Checkbox(theme) { + return { + MuiCheckbox: { + styleOverrides: { + root: { + color: theme.palette.secondary[300] + } + } + } + }; +} diff --git a/client/src/themes/overrides/Chip.jsx b/client/src/themes/overrides/Chip.jsx new file mode 100644 index 0000000..db0a0bb --- /dev/null +++ b/client/src/themes/overrides/Chip.jsx @@ -0,0 +1,40 @@ +// ==============================|| OVERRIDES - CHIP ||============================== // + +export default function Chip(theme) { + return { + MuiChip: { + styleOverrides: { + root: { + borderRadius: 4, + '&:active': { + boxShadow: 'none' + } + }, + sizeLarge: { + fontSize: '1rem', + height: 40 + }, + light: { + color: theme.palette.primary.main, + backgroundColor: theme.palette.primary.lighter, + borderColor: theme.palette.primary.light, + '&.MuiChip-lightError': { + color: theme.palette.error.main, + backgroundColor: theme.palette.error.lighter, + borderColor: theme.palette.error.light + }, + '&.MuiChip-lightSuccess': { + color: theme.palette.success.main, + backgroundColor: theme.palette.success.lighter, + borderColor: theme.palette.success.light + }, + '&.MuiChip-lightWarning': { + color: theme.palette.warning.main, + backgroundColor: theme.palette.warning.lighter, + borderColor: theme.palette.warning.light + } + } + } + } + }; +} diff --git a/client/src/themes/overrides/IconButton.jsx b/client/src/themes/overrides/IconButton.jsx new file mode 100644 index 0000000..648dba4 --- /dev/null +++ b/client/src/themes/overrides/IconButton.jsx @@ -0,0 +1,28 @@ +// ==============================|| OVERRIDES - ICON BUTTON ||============================== // + +export default function IconButton(theme) { + return { + MuiIconButton: { + styleOverrides: { + root: { + borderRadius: 4 + }, + sizeLarge: { + width: theme.spacing(5.5), + height: theme.spacing(5.5), + fontSize: '1.25rem' + }, + sizeMedium: { + width: theme.spacing(4.5), + height: theme.spacing(4.5), + fontSize: '1rem' + }, + sizeSmall: { + width: theme.spacing(3.75), + height: theme.spacing(3.75), + fontSize: '0.75rem' + } + } + } + }; +} diff --git a/client/src/themes/overrides/InputLabel.jsx b/client/src/themes/overrides/InputLabel.jsx new file mode 100644 index 0000000..d2300a4 --- /dev/null +++ b/client/src/themes/overrides/InputLabel.jsx @@ -0,0 +1,25 @@ +// ==============================|| OVERRIDES - INPUT LABEL ||============================== // + +export default function InputLabel(theme) { + return { + MuiInputLabel: { + styleOverrides: { + root: { + color: theme.palette.grey[600] + }, + outlined: { + lineHeight: '0.8em', + '&.MuiInputLabel-sizeSmall': { + lineHeight: '1em' + }, + '&.MuiInputLabel-shrink': { + background: theme.palette.background.paper, + padding: '0 8px', + marginLeft: -6, + lineHeight: '1.4375em' + } + } + } + } + }; +} diff --git a/client/src/themes/overrides/LinearProgress.jsx b/client/src/themes/overrides/LinearProgress.jsx new file mode 100644 index 0000000..ffbe1ef --- /dev/null +++ b/client/src/themes/overrides/LinearProgress.jsx @@ -0,0 +1,17 @@ +// ==============================|| OVERRIDES - LINER PROGRESS ||============================== // + +export default function LinearProgress() { + return { + MuiLinearProgress: { + styleOverrides: { + root: { + height: 6, + borderRadius: 100 + }, + bar: { + borderRadius: 100 + } + } + } + }; +} diff --git a/client/src/themes/overrides/Link.jsx b/client/src/themes/overrides/Link.jsx new file mode 100644 index 0000000..16279df --- /dev/null +++ b/client/src/themes/overrides/Link.jsx @@ -0,0 +1,11 @@ +// ==============================|| OVERRIDES - LINK ||============================== // + +export default function Link() { + return { + MuiLink: { + defaultProps: { + underline: 'hover' + } + } + }; +} diff --git a/client/src/themes/overrides/ListItemIcon.jsx b/client/src/themes/overrides/ListItemIcon.jsx new file mode 100644 index 0000000..9001ab6 --- /dev/null +++ b/client/src/themes/overrides/ListItemIcon.jsx @@ -0,0 +1,13 @@ +// ==============================|| OVERRIDES - LIST ITEM ICON ||============================== // + +export default function ListItemIcon() { + return { + MuiListItemIcon: { + styleOverrides: { + root: { + minWidth: 24 + } + } + } + }; +} diff --git a/client/src/themes/overrides/OutlinedInput.jsx b/client/src/themes/overrides/OutlinedInput.jsx new file mode 100644 index 0000000..96709f2 --- /dev/null +++ b/client/src/themes/overrides/OutlinedInput.jsx @@ -0,0 +1,47 @@ +// material-ui +import { alpha } from '@mui/material/styles'; + +// ==============================|| OVERRIDES - OUTLINED INPUT ||============================== // + +export default function OutlinedInput(theme) { + return { + MuiOutlinedInput: { + styleOverrides: { + input: { + padding: '10.5px 14px 10.5px 12px' + }, + notchedOutline: { + borderColor: theme.palette.grey[300] + }, + root: { + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.primary.light + }, + '&.Mui-focused': { + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, + '& .MuiOutlinedInput-notchedOutline': { + border: `1px solid ${theme.palette.primary.light}` + } + }, + '&.Mui-error': { + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.error.light + }, + '&.Mui-focused': { + boxShadow: `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`, + '& .MuiOutlinedInput-notchedOutline': { + border: `1px solid ${theme.palette.error.light}` + } + } + } + }, + inputSizeSmall: { + padding: '7.5px 8px 7.5px 12px' + }, + inputMultiline: { + padding: 0 + } + } + } + }; +} diff --git a/client/src/themes/overrides/Tab.jsx b/client/src/themes/overrides/Tab.jsx new file mode 100644 index 0000000..dbd899f --- /dev/null +++ b/client/src/themes/overrides/Tab.jsx @@ -0,0 +1,14 @@ +// ==============================|| OVERRIDES - TAB ||============================== // + +export default function Tab(theme) { + return { + MuiTab: { + styleOverrides: { + root: { + minHeight: 46, + color: theme.palette.text.primary + } + } + } + }; +} diff --git a/client/src/themes/overrides/TableCell.jsx b/client/src/themes/overrides/TableCell.jsx new file mode 100644 index 0000000..581a364 --- /dev/null +++ b/client/src/themes/overrides/TableCell.jsx @@ -0,0 +1,20 @@ +// ==============================|| OVERRIDES - TABLE CELL ||============================== // + +export default function TableCell(theme) { + return { + MuiTableCell: { + styleOverrides: { + root: { + fontSize: '0.875rem', + padding: 12, + borderColor: theme.palette.divider + }, + head: { + fontWeight: 600, + paddingTop: 20, + paddingBottom: 20 + } + } + } + }; +} diff --git a/client/src/themes/overrides/Tabs.jsx b/client/src/themes/overrides/Tabs.jsx new file mode 100644 index 0000000..8e906cc --- /dev/null +++ b/client/src/themes/overrides/Tabs.jsx @@ -0,0 +1,13 @@ +// ==============================|| OVERRIDES - TABS ||============================== // + +export default function Tabs() { + return { + MuiTabs: { + styleOverrides: { + vertical: { + overflow: 'visible' + } + } + } + }; +} diff --git a/client/src/themes/overrides/Typography.jsx b/client/src/themes/overrides/Typography.jsx new file mode 100644 index 0000000..9c95a37 --- /dev/null +++ b/client/src/themes/overrides/Typography.jsx @@ -0,0 +1,13 @@ +// ==============================|| OVERRIDES - TYPOGRAPHY ||============================== // + +export default function Typography() { + return { + MuiTypography: { + styleOverrides: { + gutterBottom: { + marginBottom: 12 + } + } + } + }; +} diff --git a/client/src/themes/overrides/index.jsx b/client/src/themes/overrides/index.jsx new file mode 100644 index 0000000..e5e39e7 --- /dev/null +++ b/client/src/themes/overrides/index.jsx @@ -0,0 +1,41 @@ +// third-party +import { merge } from 'lodash'; + +// project import +import Badge from './Badge'; +import Button from './Button'; +import CardContent from './CardContent'; +import Checkbox from './Checkbox'; +import Chip from './Chip'; +import IconButton from './IconButton'; +import InputLabel from './InputLabel'; +import LinearProgress from './LinearProgress'; +import Link from './Link'; +import ListItemIcon from './ListItemIcon'; +import OutlinedInput from './OutlinedInput'; +import Tab from './Tab'; +import TableCell from './TableCell'; +import Tabs from './Tabs'; +import Typography from './Typography'; + +// ==============================|| OVERRIDES - MAIN ||============================== // + +export default function ComponentsOverrides(theme) { + return merge( + Button(theme), + Badge(theme), + CardContent(), + Checkbox(theme), + Chip(theme), + IconButton(theme), + InputLabel(theme), + LinearProgress(), + Link(), + ListItemIcon(), + OutlinedInput(theme), + Tab(theme), + TableCell(theme), + Tabs(), + Typography() + ); +} diff --git a/client/src/themes/palette.jsx b/client/src/themes/palette.jsx new file mode 100644 index 0000000..7a1b2f3 --- /dev/null +++ b/client/src/themes/palette.jsx @@ -0,0 +1,60 @@ +// material-ui +import { createTheme } from '@mui/material/styles'; + +// third-party +import { presetPalettes } from '@ant-design/colors'; + +// project import +import ThemeOption from './theme'; + +// ==============================|| DEFAULT THEME - PALETTE ||============================== // + +const Palette = (mode) => { + const colors = presetPalettes; + + const greyPrimary = [ + '#ffffff', + '#fafafa', + '#f5f5f5', + '#f0f0f0', + '#d9d9d9', + '#bfbfbf', + '#8c8c8c', + '#595959', + '#262626', + '#141414', + '#000000' + ]; + const greyAscent = ['#fafafa', '#bfbfbf', '#434343', '#1f1f1f']; + const greyConstant = ['#fafafb', '#e6ebf1']; + + colors.grey = [...greyPrimary, ...greyAscent, ...greyConstant]; + + const paletteColor = ThemeOption(colors); + + return createTheme({ + palette: { + mode, + common: { + black: '#000', + white: '#fff' + }, + ...paletteColor, + text: { + primary: paletteColor.grey[700], + secondary: paletteColor.grey[500], + disabled: paletteColor.grey[400] + }, + action: { + disabled: paletteColor.grey[300] + }, + divider: paletteColor.grey[200], + background: { + paper: paletteColor.grey[0], + default: paletteColor.grey.A50 + } + } + }); +}; + +export default Palette; diff --git a/client/src/themes/shadows.jsx b/client/src/themes/shadows.jsx new file mode 100644 index 0000000..712551e --- /dev/null +++ b/client/src/themes/shadows.jsx @@ -0,0 +1,13 @@ +// material-ui +import { alpha } from '@mui/material/styles'; + +// ==============================|| DEFAULT THEME - CUSTOM SHADOWS ||============================== // + +const CustomShadows = (theme) => ({ + button: `0 2px #0000000b`, + text: `0 -1px 0 rgb(0 0 0 / 12%)`, + z1: `0px 2px 8px ${alpha(theme.palette.grey[900], 0.15)}` + // only available in paid version +}); + +export default CustomShadows; diff --git a/client/src/themes/theme/index.jsx b/client/src/themes/theme/index.jsx new file mode 100644 index 0000000..03347aa --- /dev/null +++ b/client/src/themes/theme/index.jsx @@ -0,0 +1,92 @@ +// ==============================|| PRESET THEME - THEME SELECTOR ||============================== // + +const Theme = (colors) => { + const { blue, red, gold, cyan, green, grey } = colors; + const greyColors = { + 0: grey[0], + 50: grey[1], + 100: grey[2], + 200: grey[3], + 300: grey[4], + 400: grey[5], + 500: grey[6], + 600: grey[7], + 700: grey[8], + 800: grey[9], + 900: grey[10], + A50: grey[15], + A100: grey[11], + A200: grey[12], + A400: grey[13], + A700: grey[14], + A800: grey[16] + }; + const contrastText = '#fff'; + + return { + primary: { + lighter: '#C8A2C8', + 100: '#B785B7', + 200: '#B785B7', + light: '#A668A6', + 400: '#A668A6', + main: '#8D538D', + dark: '#704270', + 700: '#533153', + darker:'#362036', + 900: '#1A0E1A', + contrastText + }, + secondary: { + lighter: greyColors[100], + 100: greyColors[100], + 200: greyColors[200], + light: greyColors[300], + 400: greyColors[400], + main: greyColors[500], + 600: greyColors[600], + dark: greyColors[700], + 800: greyColors[800], + darker: greyColors[900], + A100: greyColors[0], + A200: greyColors.A400, + A300: greyColors.A700, + contrastText: greyColors[0] + }, + error: { + lighter: red[0], + light: red[2], + main: red[4], + dark: red[7], + darker: red[9], + contrastText + }, + warning: { + lighter: gold[0], + light: gold[3], + main: gold[5], + dark: gold[7], + darker: gold[9], + contrastText: greyColors[100] + }, + info: { + lighter: cyan[0], + light: cyan[3], + main: cyan[5], + dark: cyan[7], + darker: cyan[9], + contrastText + }, + success: { + lighter: green[0], + light: green[3], + main: green[5], + dark: green[7], + darker: green[9], + contrastText + }, + grey: greyColors + }; +}; + +export default Theme; diff --git a/client/src/themes/typography.jsx b/client/src/themes/typography.jsx new file mode 100644 index 0000000..448825f --- /dev/null +++ b/client/src/themes/typography.jsx @@ -0,0 +1,71 @@ +// ==============================|| DEFAULT THEME - TYPOGRAPHY ||============================== // + +const Typography = (fontFamily) => ({ + htmlFontSize: 16, + fontFamily, + fontWeightLight: 300, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightBold: 600, + h1: { + fontWeight: 600, + fontSize: '2.375rem', + lineHeight: 1.21 + }, + h2: { + fontWeight: 600, + fontSize: '1.875rem', + lineHeight: 1.27 + }, + h3: { + fontWeight: 600, + fontSize: '1.5rem', + lineHeight: 1.33 + }, + h4: { + fontWeight: 600, + fontSize: '1.25rem', + lineHeight: 1.4 + }, + h5: { + fontWeight: 600, + fontSize: '1rem', + lineHeight: 1.5 + }, + h6: { + fontWeight: 400, + fontSize: '0.875rem', + lineHeight: 1.57 + }, + caption: { + fontWeight: 400, + fontSize: '0.75rem', + lineHeight: 1.66 + }, + body1: { + fontSize: '0.875rem', + lineHeight: 1.57 + }, + body2: { + fontSize: '0.75rem', + lineHeight: 1.66 + }, + subtitle1: { + fontSize: '0.875rem', + fontWeight: 600, + lineHeight: 1.57 + }, + subtitle2: { + fontSize: '0.75rem', + fontWeight: 500, + lineHeight: 1.66 + }, + overline: { + lineHeight: 1.66 + }, + button: { + textTransform: 'capitalize' + } +}); + +export default Typography; diff --git a/client/src/utils/SyntaxHighlight.jsx b/client/src/utils/SyntaxHighlight.jsx new file mode 100644 index 0000000..a2f0088 --- /dev/null +++ b/client/src/utils/SyntaxHighlight.jsx @@ -0,0 +1,19 @@ +import PropTypes from 'prop-types'; + +// third-party +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { a11yDark } from 'react-syntax-highlighter/dist/esm/styles/hljs'; + +// ==============================|| CODE HIGHLIGHTER ||============================== // + +export default function SyntaxHighlight({ children, ...others }) { + return ( + + {children} + + ); +} + +SyntaxHighlight.propTypes = { + children: PropTypes.node +}; diff --git a/client/src/utils/password-strength.jsx b/client/src/utils/password-strength.jsx new file mode 100644 index 0000000..51e812d --- /dev/null +++ b/client/src/utils/password-strength.jsx @@ -0,0 +1,29 @@ +// has number +const hasNumber = (number) => new RegExp(/[0-9]/).test(number); + +// has mix of small and capitals +const hasMixed = (number) => new RegExp(/[a-z]/).test(number) && new RegExp(/[A-Z]/).test(number); + +// has special chars +const hasSpecial = (number) => new RegExp(/[!#@$%^&*)(+=._-]/).test(number); + +// set color based on password strength +export const strengthColor = (count) => { + if (count < 2) return { label: 'Poor', color: 'error.main' }; + if (count < 3) return { label: 'Weak', color: 'warning.main' }; + if (count < 4) return { label: 'Normal', color: 'warning.dark' }; + if (count < 5) return { label: 'Good', color: 'success.main' }; + if (count < 6) return { label: 'Strong', color: 'success.dark' }; + return { label: 'Poor', color: 'error.main' }; +}; + +// password strength indicator +export const strengthIndicator = (number) => { + let strengths = 0; + if (number.length > 5) strengths += 1; + if (number.length > 7) strengths += 1; + if (hasNumber(number)) strengths += 1; + if (hasSpecial(number)) strengths += 1; + if (hasMixed(number)) strengths += 1; + return strengths; +}; diff --git a/dockerfile b/dockerfile index 76766a3..55d383b 100644 --- a/dockerfile +++ b/dockerfile @@ -5,6 +5,7 @@ FROM debian WORKDIR /app COPY build/cosmos . +COPY static . VOLUME /config diff --git a/dockerfile.arm64 b/dockerfile.arm64 index 48e1d0d..b479a6f 100644 --- a/dockerfile.arm64 +++ b/dockerfile.arm64 @@ -5,6 +5,7 @@ FROM amd64/debian WORKDIR /app COPY build/cosmos . +COPY static . VOLUME /config diff --git a/gupm.json b/gupm.json index c9394af..f2efa72 100644 --- a/gupm.json +++ b/gupm.json @@ -20,13 +20,71 @@ "go://github.com/joho/godotenv": "master", "go://github.com/lib/pq": "master", "go://github.com/pquerna/ffjson": "master", + "go://github.com/roberthodgen/spa-server": "master", "go://go.mongodb.org/mongo-driver": "master", - "go://gopkg.in/ffmt.v1": "v1.5.6" + "go://gopkg.in/ffmt.v1": "v1.5.6", + "npm://@ant-design/colors": "^6.0.0", + "npm://@ant-design/icons": "^4.7.0", + "npm://@babel/core": "^7.19.1", + "npm://@babel/eslint-parser": "^7.19.1", + "npm://@emotion/cache": "^11.10.3", + "npm://@emotion/react": "^11.10.4", + "npm://@emotion/styled": "^11.10.4", + "npm://@esbuild/linux-x64": "0.16.17", + "npm://@mui/lab": "^5.0.0-alpha.100", + "npm://@mui/material": "^5.10.6", + "npm://@reduxjs/toolkit": "^1.8.5", + "npm://@testing-library/jest-dom": "^5.16.5", + "npm://@testing-library/react": "^13.4.0", + "npm://@testing-library/user-event": "^14.4.3", + "npm://@vitejs/plugin-react": "3.1.0", + "npm://apexcharts": "^3.35.5", + "npm://eslint": "^8.23.1", + "npm://eslint-config-airbnb-typescript": "^17.0.0", + "npm://eslint-config-prettier": "^8.5.0", + "npm://eslint-config-react-app": "7.0.1", + "npm://eslint-import-resolver-typescript": "3.5.1", + "npm://eslint-plugin-flowtype": "^8.0.3", + "npm://eslint-plugin-import": "^2.26.0", + "npm://eslint-plugin-jsx-a11y": "6.6.1", + "npm://eslint-plugin-prettier": "^4.2.1", + "npm://eslint-plugin-react": "^7.31.8", + "npm://eslint-plugin-react-hooks": "4.6.0", + "npm://express": "4.18.2", + "npm://express-ws": "5.0.2", + "npm://formik": "^2.2.9", + "npm://framer-motion": "^7.3.6", + "npm://history": "^5.3.0", + "npm://lodash": "^4.17.21", + "npm://prettier": "2.7.1", + "npm://prop-types": "^15.8.1", + "npm://react": "^18.2.0", + "npm://react-apexcharts": "^1.4.0", + "npm://react-copy-to-clipboard": "^5.1.0", + "npm://react-device-detect": "^2.2.2", + "npm://react-dom": "^18.2.0", + "npm://react-draggable": "^4.4.5", + "npm://react-element-to-jsx-string": "^15.0.0", + "npm://react-number-format": "^4.9.4", + "npm://react-perfect-scrollbar": "^1.5.8", + "npm://react-redux": "^8.0.4", + "npm://react-router": "^6.4.1", + "npm://react-router-dom": "^6.4.1", + "npm://react-scripts": "^5.0.1", + "npm://react-syntax-highlighter": "^15.5.0", + "npm://react-window": "^1.8.7", + "npm://redux": "^4.2.0", + "npm://simplebar": "^5.3.8", + "npm://simplebar-react": "^2.4.1", + "npm://typescript": "4.8.3", + "npm://vite": "4.1.4", + "npm://web-vitals": "^3.0.2", + "npm://yup": "^0.32.11" }, "defaultProvider": "go" }, "description": "Cosmos Server", "name": "cosmos-server", - "version": "0.0.1", + "version": "0.0.2", "wrapInstallFolder": "src" } \ No newline at end of file diff --git a/readme.md b/readme.md index aeadf26..a8c3652 100644 --- a/readme.md +++ b/readme.md @@ -10,12 +10,13 @@ Disclaimer: Cosmos is still in early Alpha stage, please be careful when you use Looking for a **secure** and **robust** way to run your **self-hosted applications**? With **Cosmos**, you can take control of your data and privacy without sacrificing security and stability. -Whether you have a **server**, a **NAS**, or a **Raspberry Pi** with applications such as **Plex** or **HomeAssistant**, Cosmos is the perfect solution to secure it all. Simply install Cosmos on your server and connect to your applications through it to enjoy built-in security and robustness for all your services, right out of the box. +Whether you have a **server**, a **NAS**, or a **Raspberry Pi** with applications such as **Plex**, **HomeAssistant** or even a blog, Cosmos is the perfect solution to secure it all. Simply install Cosmos on your server and connect to your applications through it to enjoy built-in security and robustness for all your services, right out of the box. * **Authentication** Connect to all your application with the same account, including strong security and multi-factor authentication * **Automatic HTTPS** certificates provision * **Anti-bot** protections such as Captcha and IP rate limiting * **Anti-DDOS** protections such as variable timeouts/throttling, IP rate limiting and IP blacklisting + * **Proper user management** to invite your friends and family to your applications without awkardly sharing credentials. Let them request a password change with an email rather than having you unlock their account manually! And a **lot more planned features** are coming! diff --git a/src/httpServer.go b/src/httpServer.go index 9215fd9..734d774 100644 --- a/src/httpServer.go +++ b/src/httpServer.go @@ -11,9 +11,11 @@ import ( "regexp" "time" "encoding/json" + "os" "github.com/go-chi/chi/middleware" "github.com/go-chi/httprate" "crypto/tls" + spa "github.com/roberthodgen/spa-server" ) var serverPortHTTP = "" @@ -150,24 +152,25 @@ func StartServer() { utils.Log("Saved new Auth ED25519 certificate") } - router := proxy.BuildFromConfig(config.ProxyConfig) + router := mux.NewRouter().StrictSlash(true) router.Use(middleware.Recoverer) router.Use(middleware.Logger) router.Use(tokenMiddleware) router.Use(utils.SetSecurityHeaders) + + srapi := router.PathPrefix("/cosmos").Subrouter() - srapi := router.PathPrefix("/api").Subrouter() + srapi.HandleFunc("/api/login", user.UserLogin) + srapi.HandleFunc("/api/logout", user.UserLogout) + srapi.HandleFunc("/api/register", user.UserRegister) + srapi.HandleFunc("/api/invite", user.UserResendInviteLink) + srapi.HandleFunc("/api/me", user.Me) - srapi.HandleFunc("/login", user.UserLogin) - srapi.HandleFunc("/logout", user.UserLogout) - srapi.HandleFunc("/register", user.UserRegister) - srapi.HandleFunc("/invite", user.UserResendInviteLink) + srapi.HandleFunc("/api/users/{nickname}", user.UsersIdRoute) + srapi.HandleFunc("/api/users", user.UsersRoute) - srapi.HandleFunc("/users/{nickname}", user.UsersIdRoute) - srapi.HandleFunc("/users", user.UsersRoute) - - srapi.Use(utils.AcceptHeader("application/json")) + // srapi.Use(utils.AcceptHeader("*/*")) srapi.Use(utils.CORSHeader(utils.GetMainConfig().HTTPConfig.Hostname)) srapi.Use(utils.MiddlewareTimeout(5 * time.Second)) srapi.Use(httprate.Limit(20, 1*time.Minute, @@ -179,6 +182,16 @@ func StartServer() { return }), )) + + pwd,_ := os.Getwd() + utils.Log("Starting in " + pwd) + if _, err := os.Stat(pwd + "/static"); os.IsNotExist(err) { + utils.Fatal("Static folder not found at " + pwd + "/static", err) + } + fs := spa.SpaHandler(pwd + "/static", "index.html") + router.PathPrefix("/").Handler(fs) + + router = proxy.BuildFromConfig(router, config.ProxyConfig) if tlsCert != "" && tlsKey != "" { utils.Log("TLS certificate exist, starting HTTPS servers and redirecting HTTP to HTTPS") diff --git a/src/proxy/buildFromConfig.go b/src/proxy/buildFromConfig.go index 0020d1d..8c9659d 100644 --- a/src/proxy/buildFromConfig.go +++ b/src/proxy/buildFromConfig.go @@ -6,8 +6,7 @@ import ( "../utils" ) -func BuildFromConfig(config utils.ProxyConfig) *mux.Router { - router := mux.NewRouter().StrictSlash(true) +func BuildFromConfig(router *mux.Router, config utils.ProxyConfig) *mux.Router { router.HandleFunc("/_health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/src/proxy/routerGen.go b/src/proxy/routerGen.go index 3ef5a0e..57677f8 100644 --- a/src/proxy/routerGen.go +++ b/src/proxy/routerGen.go @@ -21,9 +21,11 @@ func RouterGen(route utils.Route, router *mux.Router, destination *httputil.Reve if(route.UsePathPrefix) { origin = origin.PathPrefix(route.PathPrefix) + } + + if(route.UsePathPrefix && route.StripPathPrefix) { realDestination = http.StripPrefix(route.PathPrefix, destination) } - timeout := route.Timeout if(timeout == 0) { diff --git a/src/user/get.go b/src/user/get.go index 1a2fa35..eef542a 100644 --- a/src/user/get.go +++ b/src/user/get.go @@ -10,6 +10,10 @@ import ( func UserGet(w http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) nickname := utils.Sanitize(vars["nickname"]) + + if nickname == "" && req.Header.Get("x-cosmos-user") != "" { + nickname = req.Header.Get("x-cosmos-user") + } if AdminOrItselfOnly(w, req, nickname) != nil { return diff --git a/src/user/me.go b/src/user/me.go new file mode 100644 index 0000000..f68fb1d --- /dev/null +++ b/src/user/me.go @@ -0,0 +1,17 @@ +package user + +import ( + "net/http" + "../utils" +) + + +func Me(w http.ResponseWriter, req *http.Request) { + if (req.Method == "GET") { + UserGet(w, req) + } else { + utils.Error("UserRoute: Method not allowed" + req.Method, nil) + utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") + return + } +} \ No newline at end of file diff --git a/src/user/token.go b/src/user/token.go index 3697f1e..b144212 100644 --- a/src/user/token.go +++ b/src/user/token.go @@ -165,7 +165,8 @@ func loggedInOnly(w http.ResponseWriter, req *http.Request) error { if !isUserLoggedIn || userNickname == "" { utils.Error("LoggedInOnly: User is not logged in", nil) - http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound) + //http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound) + utils.HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004") return errors.New("User not logged in") } @@ -180,13 +181,14 @@ func AdminOnly(w http.ResponseWriter, req *http.Request) error { if !isUserLoggedIn || userNickname == "" { utils.Error("AdminOnly: User is not logged in", nil) - http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound) + //http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound) + utils.HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004") return errors.New("User not logged in") } if isUserLoggedIn && !isUserAdmin { utils.Error("AdminOnly: User is not admin", nil) - utils.HTTPError(w, "Unauthorized", http.StatusUnauthorized, "HTTP002") + utils.HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005") return errors.New("User not Admin") } @@ -201,13 +203,13 @@ func AdminOrItselfOnly(w http.ResponseWriter, req *http.Request, nickname string if !isUserLoggedIn || userNickname == "" { utils.Error("AdminOrItselfOnly: User is not logged in", nil) - http.Redirect(w, req, "/login?notlogged=1&redirect=" + req.URL.Path, http.StatusFound) + utils.HTTPError(w, "User not logged in", http.StatusUnauthorized, "HTTP004") return errors.New("User not logged in") } if nickname != userNickname && !isUserAdmin { utils.Error("AdminOrItselfOnly: User is not admin", nil) - utils.HTTPError(w, "Unauthorized", http.StatusUnauthorized, "HTTP002") + utils.HTTPError(w, "User unauthorized", http.StatusUnauthorized, "HTTP005") return errors.New("User not Admin") } diff --git a/src/utils/db.go b/src/utils/db.go index 555cbe1..4babe41 100644 --- a/src/utils/db.go +++ b/src/utils/db.go @@ -14,7 +14,7 @@ var client *mongo.Client func DB() { Log("Connecting to the database...") - uri := os.Getenv("MONGODB") + "/?retryWrites=true&w=majority" + uri := MainConfig.MongoDB + "/?retryWrites=true&w=majority" var err error diff --git a/src/utils/log.go b/src/utils/log.go index b5cd2c9..a73906e 100644 --- a/src/utils/log.go +++ b/src/utils/log.go @@ -37,14 +37,22 @@ func Warn(message string) { func Error(message string, err error) { ll := LoggingLevelLabels[GetMainConfig().LoggingLevel] + errStr := "" + if err != nil { + errStr = err.Error() + } if ll <= ERROR { - log.Println(Red + "[ERROR] " + message + " : " + err.Error() + Reset) + log.Println(Red + "[ERROR] " + message + " : " + errStr + Reset) } } func Fatal(message string, err error) { ll := LoggingLevelLabels[GetMainConfig().LoggingLevel] + errStr := "" + if err != nil { + errStr = err.Error() + } if ll <= ERROR { - log.Fatal(Red + "[FATAL] " + message + " : " + err.Error() + Reset) + log.Fatal(Red + "[FATAL] " + message + " : " + errStr + Reset) } } diff --git a/src/utils/types.go b/src/utils/types.go index 71f76ed..1496355 100644 --- a/src/utils/types.go +++ b/src/utils/types.go @@ -51,6 +51,7 @@ type User struct { type Config struct { LoggingLevel LoggingLevel `validate:"oneof=DEBUG INFO WARNING ERROR"` + MongoDB string HTTPConfig HTTPConfig } @@ -85,4 +86,6 @@ type Route struct { ThrottlePerMinute int SPAMode bool CORSOrigin string + StripPathPrefix bool + Static bool } \ No newline at end of file diff --git a/src/utils/utils.go b/src/utils/utils.go index a3b9ac6..255c439 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -114,6 +114,9 @@ func LoadBaseMainConfig(config Config){ if os.Getenv("LOG_LEVEL") != "" { MainConfig.LoggingLevel = (LoggingLevel)(os.Getenv("LOG_LEVEL")) } + if os.Getenv("MONGODB") != "" { + MainConfig.MongoDB = os.Getenv("MONGODB") + } } func GetMainConfig() Config { diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..12c14e3 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + root: 'client', + build: { + outDir: '../static', + }, + server: { + proxy: { + '/cosmos/api': { + target: 'https://localhost:8443', + secure: false, + } + } + } +})