From cfd3b5748efcc0b50e86f1096425b28f671a54ae Mon Sep 17 00:00:00 2001 From: "Embruch, Gerd" <gerd.embruch@uni-hamburg.de> Date: Tue, 2 Jul 2024 08:22:04 +0200 Subject: [PATCH] added authContext, forms, Axios & ErrorHandling --- .env | 4 +- README.md | 5 +- README_tmp.html | 8 +- package-lock.json | 204 +++++++++++++++++++- package.json | 9 +- src/App.jsx | 5 +- src/assets/css/tailwind.presets.min.css | 2 +- src/assets/css/tailwind.presets.min.css.map | 2 +- src/assets/sass/tailwind.presets.scss | 71 ++++++- src/components/boxes/ConfirmBox.jsx | 55 ++++++ src/components/boxes/Infobox.jsx | 42 ++++ src/components/boxes/Tooltip.jsx | 26 +++ src/components/font/Heading.jsx | 54 ++++++ src/components/form/Fieldset.jsx | 37 ++++ src/components/form/File.jsx | 111 +++++++++++ src/components/form/FlatListEdit copy.jsx | 154 +++++++++++++++ src/components/form/FlatListEdit.jsx | 118 +++++++++++ src/components/form/Input.jsx | 62 ++++++ src/components/form/RequiredBadge.jsx | 20 ++ src/components/form/Select.jsx | 71 +++++++ src/components/form/Submit.jsx | 40 ++++ src/components/form/Textarea.jsx | 63 ++++++ src/contexts/Auth/AuthContext.jsx | 5 + src/contexts/Auth/AuthReducer.jsx | 15 ++ src/contexts/Auth/AuthState.jsx | 66 +++++++ src/contexts/Auth/AuthTypes.js | 4 + src/layouts/partials/Header.jsx | 91 +++++---- src/pages/User/Login.jsx | 89 +++++++++ src/routes/Sitemap.jsx | 2 + src/utils.js | 10 + src/utils/AxiosConfig.js | 69 +++++++ src/utils/ErrorHandling.js | 29 +++ tailwind.config.js | 39 +++- 33 files changed, 1526 insertions(+), 56 deletions(-) mode change 100644 => 100755 .env create mode 100644 src/components/boxes/ConfirmBox.jsx create mode 100755 src/components/boxes/Infobox.jsx create mode 100755 src/components/boxes/Tooltip.jsx create mode 100755 src/components/font/Heading.jsx create mode 100755 src/components/form/Fieldset.jsx create mode 100755 src/components/form/File.jsx create mode 100644 src/components/form/FlatListEdit copy.jsx create mode 100644 src/components/form/FlatListEdit.jsx create mode 100755 src/components/form/Input.jsx create mode 100755 src/components/form/RequiredBadge.jsx create mode 100755 src/components/form/Select.jsx create mode 100755 src/components/form/Submit.jsx create mode 100755 src/components/form/Textarea.jsx create mode 100755 src/contexts/Auth/AuthContext.jsx create mode 100755 src/contexts/Auth/AuthReducer.jsx create mode 100755 src/contexts/Auth/AuthState.jsx create mode 100755 src/contexts/Auth/AuthTypes.js create mode 100644 src/pages/User/Login.jsx create mode 100755 src/utils.js create mode 100755 src/utils/AxiosConfig.js create mode 100644 src/utils/ErrorHandling.js diff --git a/.env b/.env old mode 100644 new mode 100755 index f92bf9c..64c8d4b --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -VITE_APP_NAME=ZBH Portal -VITE_PAGE_AFTER_LOGIN=/ +VITE_APP_NAME=ZBH Portal +VITE_PAGE_AFTER_LOGIN=/ diff --git a/README.md b/README.md index 6f4ae14..2eb9443 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,16 @@ It's build to fulfill the style requirements of the University of Hamburg. git clone git@gitlab.rrz.uni-hamburg.de:zbhai/ragchat-api.git cd ragchat-api npm i +cp ./.env.template.local ./.env.development.local +cp ./.env.template.local ./.env.production.local +# fill envs with production and/or devel values ``` + # Sources - [RAGChat-API](https://gitlab.rrz.uni-hamburg.de/zbhai/ragchat-api) - [PM2](https://pm2.keymetrics.io/) # Roadmap -- [ ] create a basic frame - [ ] create forms using rhf - [ ] add form validations via zod \ No newline at end of file diff --git a/README_tmp.html b/README_tmp.html index 5f96792..0c3616e 100644 --- a/README_tmp.html +++ b/README_tmp.html @@ -378,6 +378,9 @@ It's build to fulfill the style requirements of the University of Hamburg.</p> <pre class="hljs"><code><div>git clone git@gitlab.rrz.uni-hamburg.de:zbhai/ragchat-api.git cd ragchat-api npm i +cp ./.env.template.local ./.env.development.local +cp ./.env.template.local ./.env.production.local +# fill envs with production and/or devel values </div></code></pre> <h1 id="sources">Sources</h1> <ul> @@ -386,9 +389,8 @@ npm i </ul> <h1 id="roadmap">Roadmap</h1> <ul> -<li><input type="checkbox" id="checkbox0"><label for="checkbox0">create a basic frame</label></li> -<li><input type="checkbox" id="checkbox1"><label for="checkbox1">create forms using rhf</label></li> -<li><input type="checkbox" id="checkbox2"><label for="checkbox2">add form validations via zod</label></li> +<li><input type="checkbox" id="checkbox0"><label for="checkbox0">create forms using rhf</label></li> +<li><input type="checkbox" id="checkbox1"><label for="checkbox1">add form validations via zod</label></li> </ul> </body> diff --git a/package-lock.json b/package-lock.json index b901065..cf180e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,19 @@ "name": "ragchat-frontend", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^3.6.0", + "axios": "^1.7.2", "hamburger-react": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-helmet-async": "^2.0.5", + "react-hook-form": "^7.52.0", "react-icons": "^5.2.1", - "react-router-dom": "^6.24.0" + "react-router-dom": "^6.24.0", + "react-toastify": "^10.0.5", + "tailwind-merge": "^2.3.0", + "validator": "^13.12.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/react": "^18.3.3", @@ -362,6 +369,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", + "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", @@ -881,6 +900,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.6.0.tgz", + "integrity": "sha512-UBcpyOX3+RR+dNnqBd0lchXpoL8p4xC21XP8H6Meb8uve5Br1GCnmg0PcBoKKqPKgGu9GHQ/oygcmPrQhetwqw==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1904,6 +1932,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.19", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", @@ -1958,6 +1992,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2162,6 +2207,15 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -2179,6 +2233,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2380,6 +2446,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3144,6 +3219,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -3171,6 +3266,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -4231,6 +4340,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4889,6 +5019,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4965,6 +5101,22 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.52.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.0.tgz", + "integrity": "sha512-mJX506Xc6mirzLsmXUJyqlAI3Kj9Ph2RhplYhUVffeOQSnubK2uVqBFOBJmvKikvbFV91pxVXmDiR+QMF19x6A==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", @@ -5023,6 +5175,19 @@ "react-dom": ">=16.8" } }, + "node_modules/react-toastify": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz", + "integrity": "sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5068,6 +5233,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -5688,6 +5859,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.3.0.tgz", + "integrity": "sha512-vkYrLpIP+lgR0tQCG6AP7zZXCTLc1Lnv/CCRT3BqJ9CZ3ui2++GPaGb1x/ILsINIMSYqqvrpqjUFsMNLlW99EA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", @@ -5978,6 +6162,15 @@ "dev": true, "license": "MIT" }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz", @@ -6334,6 +6527,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 723c6e7..c775795 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,19 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^3.6.0", + "axios": "^1.7.2", "hamburger-react": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-helmet-async": "^2.0.5", + "react-hook-form": "^7.52.0", "react-icons": "^5.2.1", - "react-router-dom": "^6.24.0" + "react-router-dom": "^6.24.0", + "react-toastify": "^10.0.5", + "tailwind-merge": "^2.3.0", + "validator": "^13.12.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/react": "^18.3.3", diff --git a/src/App.jsx b/src/App.jsx index 2075f13..3fce18b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { sitemap } from "./routes/Sitemap"; import { HelmetProvider } from 'react-helmet-async'; +import AuthState from "./contexts/Auth/AuthState"; function App() { @@ -8,7 +9,9 @@ function App() { return ( <HelmetProvider> - <RouterProvider router={pages} /> + <AuthState> + <RouterProvider router={pages} /> + </AuthState> </HelmetProvider> ); diff --git a/src/assets/css/tailwind.presets.min.css b/src/assets/css/tailwind.presets.min.css index c630ac1..c647438 100644 --- a/src/assets/css/tailwind.presets.min.css +++ b/src/assets/css/tailwind.presets.min.css @@ -1 +1 @@ -@tailwind base;@tailwind components;@tailwind utilities;@layer base{img,svg,video,canvas,audio,iframe,embed,object{display:inline;vertical-align:middle}*{@apply border-border}body{@apply bg-background text-foreground}}/*# sourceMappingURL=tailwind.presets.min.css.map */ \ No newline at end of file +@tailwind base;@tailwind components;@tailwind utilities;@layer base{.conceal{@apply opacity-0 h-0 w-0 p-0 m-0 overflow-hidden}label,details{@apply block w-full pb-1 relative cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60 lg:min-w-xs lg:w-[calc(1/2*100%-(1*1rem/2))] xl:w-[calc((1/4*100%)-(3*1rem/4))]}img,svg,video,canvas,audio,iframe,embed,object{display:inline;vertical-align:middle}:root{--background: 0 0% 100%;--foreground: 0 0% 3.9%;--card: 0 0% 100%;--card-foreground: 0 0% 3.9%;--popover: 0 0% 100%;--popover-foreground: 0 0% 3.9%;--primary: 204 98% 37%;--primary-foreground: 0 0% 98%;--secondary: 0 0% 96.1%;--secondary-foreground: 0 0% 9%;--muted: 0 0% 96.1%;--muted-foreground: 0 0% 45.1%;--accent: 0 0% 96.1%;--accent-foreground: 0 0% 9%;--destructive: 353 100% 44%;--destructive-foreground: 0 0% 98%;--border: 0 0% 89.8%;--input: 0 0% 89.8%;--ring: 0 0% 3.9%;--radius: 0.5rem}.dark{--background: 0 0% 3.9%;--foreground: 0 0% 98%;--card: 0 0% 3.9%;--card-foreground: 0 0% 98%;--popover: 0 0% 3.9%;--popover-foreground: 0 0% 98%;--primary: 0 0% 98%;--primary-foreground: 0 0% 9%;--secondary: 0 0% 14.9%;--secondary-foreground: 0 0% 98%;--muted: 0 0% 14.9%;--muted-foreground: 0 0% 63.9%;--accent: 0 0% 14.9%;--accent-foreground: 0 0% 98%;--destructive: 0 62.8% 30.6%;--destructive-foreground: 0 0% 98%;--border: 0 0% 14.9%;--input: 0 0% 14.9%;--ring: 0 0% 83.1%}*{@apply border-border}body{@apply bg-background text-foreground}}/*# sourceMappingURL=tailwind.presets.min.css.map */ \ No newline at end of file diff --git a/src/assets/css/tailwind.presets.min.css.map b/src/assets/css/tailwind.presets.min.css.map index 83dac44..a24d0a1 100644 --- a/src/assets/css/tailwind.presets.min.css.map +++ b/src/assets/css/tailwind.presets.min.css.map @@ -1 +1 @@ -{"version":3,"sources":["../sass/tailwind.presets.scss"],"names":[],"mappings":"AAAA,cAAA,CACA,oBAAA,CACA,mBAAA,CAEA,YACE,+CAQE,cAAA,CACA,qBAAA,CAEF,EACE,oBAAA,CAEF,KACE,oCAAA,CAAA","file":"tailwind.presets.min.css"} \ No newline at end of file +{"version":3,"sources":["../sass/tailwind.presets.scss"],"names":[],"mappings":"AAAA,cAAA,CACA,oBAAA,CACA,mBAAA,CACA,YACE,SACE,gDAAA,CAEF,cAEE,6MAAA,CAEF,+CAQE,cAAA,CACA,qBAAA,CAEF,MACE,uBAAA,CACA,uBAAA,CAEA,iBAAA,CACA,4BAAA,CAEA,oBAAA,CACA,+BAAA,CAGA,sBAAA,CACA,8BAAA,CAEA,uBAAA,CACA,+BAAA,CAEA,mBAAA,CACA,8BAAA,CAEA,oBAAA,CACA,4BAAA,CAEA,2BAAA,CACA,kCAAA,CAEA,oBAAA,CACA,mBAAA,CACA,iBAAA,CAEA,gBAAA,CAGF,MACE,uBAAA,CACA,sBAAA,CAEA,iBAAA,CACA,2BAAA,CAEA,oBAAA,CACA,8BAAA,CAEA,mBAAA,CACA,6BAAA,CAEA,uBAAA,CACA,gCAAA,CAEA,mBAAA,CACA,8BAAA,CAEA,oBAAA,CACA,6BAAA,CAEA,4BAAA,CACA,kCAAA,CAEA,oBAAA,CACA,mBAAA,CACA,kBAAA,CAGF,EACE,oBAAA,CAEF,KACE,oCAAA,CAAA","file":"tailwind.presets.min.css"} \ No newline at end of file diff --git a/src/assets/sass/tailwind.presets.scss b/src/assets/sass/tailwind.presets.scss index e360c8f..2e5d3a4 100644 --- a/src/assets/sass/tailwind.presets.scss +++ b/src/assets/sass/tailwind.presets.scss @@ -1,8 +1,14 @@ @tailwind base; @tailwind components; @tailwind utilities; - @layer base { + .conceal { + @apply opacity-0 h-0 w-0 p-0 m-0 overflow-hidden; + } + label, + details { + @apply block w-full pb-1 relative cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60 lg:min-w-xs lg:w-[calc(1/2*100%-(1*1rem/2))] xl:w-[calc((1/4*100%)-(3*1rem/4))]; + } img, svg, video, @@ -14,6 +20,69 @@ display: inline; vertical-align: middle; } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + // --primary: 0 0% 9%; + --primary: 204 98% 37%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 353 100% 44%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } + * { @apply border-border; } diff --git a/src/components/boxes/ConfirmBox.jsx b/src/components/boxes/ConfirmBox.jsx new file mode 100644 index 0000000..1df510d --- /dev/null +++ b/src/components/boxes/ConfirmBox.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from '../ui/button'; +import Heading from '../font/Heading'; + +function ConfirmBox({ confirmDialog, closeDialog, item, handleProceed, ...props }) { + // ################################# + // HOOKS + // ################################# + + // ################################# + // FUNCTIONS + // ################################# + + // ################################# + // OUTPUT + // ################################# + return ( + <Dialog open={confirmDialog.open} onOpenChange={closeDialog}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Remove</DialogTitle> + <DialogDescription> + You are about to remove the following item:<br /> + <span className='text-lg'>{JSON.stringify(confirmDialog.displayName)}</span> + </DialogDescription> + </DialogHeader> + Proceed? + <DialogFooter className="sm:justify-start gap-2"> + <DialogClose asChild> + <Button> + Abort + </Button> + </DialogClose> + <DialogClose asChild> + <Button variant="destructive" onClick={handleProceed}> + remove + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export default React.memo(ConfirmBox); \ No newline at end of file diff --git a/src/components/boxes/Infobox.jsx b/src/components/boxes/Infobox.jsx new file mode 100755 index 0000000..4b25914 --- /dev/null +++ b/src/components/boxes/Infobox.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +function Infobox({ children, className, type, ...props }) { + // ################################# + // HOOKS + // ################################# + + // ################################# + // FUNCTIONS + // ################################# + // ### MERGE CLASSNAMES + // static class names every item should have + let staticClassName = 'box-border border border-UhhBlue p-4 my-4 md:inline-block'; + // dynamic class names depending on the type + const typeClassName = () => { + switch (type) { + case 'warning': { + return 'border-orange-300 bg-orange-100/20'; + } + case 'alert': { + return 'border-UhhRed bg-red-100/20'; + } + default: { + return ''; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, typeClassName(), className); + + // ################################# + // OUTPUT + // ################################# + return ( + <div> + <div className={mergedClassName}>{children}</div> + </div> + ); +} + +export default React.memo(Infobox); \ No newline at end of file diff --git a/src/components/boxes/Tooltip.jsx b/src/components/boxes/Tooltip.jsx new file mode 100755 index 0000000..0d0044c --- /dev/null +++ b/src/components/boxes/Tooltip.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { RiQuestionLine } from 'react-icons/ri'; + +function Tooltip({ children, ...props }) { + // ################################# + // HOOKS + // ################################# + + // ################################# + // FUNCTIONS + // ################################# + + // ################################# + // OUTPUT + // ################################# + return ( + <span className='group ml-2'> + <RiQuestionLine className='text-UhhBlue hover:cursor-help' /> + <div className='absolute invisible group-hover:visible group-hover:z-50 bg-UhhWhite border border-UhhBlue p-2'> + {children} + </div> + </span> + ); +} + +export default React.memo(Tooltip); \ No newline at end of file diff --git a/src/components/font/Heading.jsx b/src/components/font/Heading.jsx new file mode 100755 index 0000000..3516ae1 --- /dev/null +++ b/src/components/font/Heading.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +function Heading({ level = 1, children, className, ...props }) { + // ################################# + // HOOKS + // ################################# + + // ################################# + // FUNCTIONS + // ################################# + const Tag = `h${level}`; + + // ### MERGE CLASSNAMES + // static class names every item should have + const staticClassName = ''; + // dynamic class names depending on the type + const typeClassName = () => { + switch (level) { + case '1': { + return 'font-UhhBC text-4xl my-8'; + } + case '2': { + return 'font-UhhSLC text-3xl leading-7'; + } + case '3': { + return 'font-UhhSLC text-3xl'; + } + case '4': { + return 'font-UhhB text-2xl py-6'; + } + case '5': { + return 'font-UhhSLC text-lg'; + } + case '6': { + return 'font-UhhSLC'; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, typeClassName(), className); + + + // ################################# + // OUTPUT + // ################################# + return ( + <> + <Tag className={mergedClassName}>{children}</Tag> + </> + ); +} + +export default React.memo(Heading); \ No newline at end of file diff --git a/src/components/form/Fieldset.jsx b/src/components/form/Fieldset.jsx new file mode 100755 index 0000000..d5d298e --- /dev/null +++ b/src/components/form/Fieldset.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { twMerge } from 'tailwind-merge'; + +function Fieldset({ title, className, type, children, ...props }) { + // ################################# + // HOOKS + // ################################# + + // ################################# + // FUNCTIONS + // ################################# + // ### MERGE CLASSNAMES + // static class names every item should have + const staticClassName = 'mb-8 p-1 border-t border-UhhLightGray'; + // dynamic class names depending on the type + const typeClassName = () => { + switch (type) { + default: { + return ''; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, typeClassName(), className); + + // ################################# + // OUTPUT + // ################################# + return ( + <fieldset className={mergedClassName}> + <legend className='ml-2 text-xs text-UhhGrey'>{title}</legend> + {children} + </fieldset> + ); +} + +export default React.memo(Fieldset); \ No newline at end of file diff --git a/src/components/form/File.jsx b/src/components/form/File.jsx new file mode 100755 index 0000000..77036a9 --- /dev/null +++ b/src/components/form/File.jsx @@ -0,0 +1,111 @@ +import React, { useEffect, useId, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { RiEdit2Line, RiDeleteBinLine } from 'react-icons/ri'; +import api from '../../utils/AxiosConfig'; +import { mergeBackendValidation } from '../../utils/ErrorHandling'; +import { capitalizeFirstLetter } from '../../utils'; + +function File({ name, currentfile, title, deletefnc, ...props }) { + // ################################# + // HOOKS + // ################################# + // ### Prepare form + const { + register, + control, + setError, + formState: { errors } + } = useFormContext(); + + const id = useId(); + + const watchFile = useWatch({ + control, + name + }); + + // ### PREVIEW + const [mimetype, setMimetype] = useState(); + const [blob, setBlob] = useState(); + + + //### FILE PREVIEW + useEffect(() => { + if (watchFile) { + const reader = new FileReader(); + reader.onloadend = () => { + // set mimetype to decide which media element shall be rendered + setMimetype((watchFile[0].type).split('/')[0]); + // set blob for media element + setBlob(reader.result); + }; + if (watchFile[0]) reader.readAsDataURL(watchFile[0]); + }; + }, [watchFile]); + + // ### GET FILE + useEffect(() => { + const getFile = async () => { + try { + await api.post('/files/download', { path: currentfile.path }, { responseType: 'blob' }).then(result => { + let objectURL = URL.createObjectURL(result.data); + // set mimetype to decide which media element shall be rendered + setMimetype((currentfile?.mimetype).split('/')[0]); + // set blob for media element + setBlob(objectURL); + }); + } catch (error) { + mergeBackendValidation(error.response.status, error.response.data, setError); + } + }; + getFile(); + }, [currentfile]); + + // ################################# + // FUNCTIONS + // ################################# + + + // ################################# + // OUTPUT + // ################################# + return ( + <label htmlFor={id}> + {capitalizeFirstLetter(title)} + + <input {...register(name)} + id={id} + aria-invalid={errors[name] ? 'true' : 'false'} + className='hidden aria-[invalid=true]:border-UhhRed' + autoComplete='off' + {...props} + /> + <div className="relative bg-UhhWhite p-1 max-w-fit border border-UhhGrey"> + {mimetype === 'video' ? + <video controls> + <source src={blob} type="video/mp4" /> + </video> + : + <img src={blob} alt={`file for ${title}`} className="max-h-48 object-cover" title={currentfile.originalname} /> + } + + {!watchFile && + <div className="absolute inset-0 flex justify-evenly group items-center text-UhhWhite "> + <span className="w-full h-full text-center flex justify-center items-center bg-UhhBlue opacity-0 group-hover:opacity-70 hover:!opacity-100 transition-all"> + <RiEdit2Line className="inset-0" title="click to select a new media" /> + </span> + + <span onClick={deletefnc} className="w-full h-full text-center flex justify-center items-center bg-UhhRed opacity-0 group-hover:opacity-70 hover:!opacity-100 transition-all"> + <RiDeleteBinLine className="inset-0 pointer-events-none" title="click to delete this media" /> + </span> + </div> + } + + </div> + {watchFile && <p className="validationNote mt-2 text-UhhBlue text-xs overflow-auto">Preview only. Save to make change permanent.</p>} + <p className="validationNote mt-2 text-UhhRed text-xs overflow-auto"></p> + </label> + ); +} + +export default React.memo(File); \ No newline at end of file diff --git a/src/components/form/FlatListEdit copy.jsx b/src/components/form/FlatListEdit copy.jsx new file mode 100644 index 0000000..f2e35c0 --- /dev/null +++ b/src/components/form/FlatListEdit copy.jsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from 'react'; +import Textarea from './Textarea'; +import { Button } from '../ui/button'; +import ConfirmBox from '../boxes/ConfirmBox'; +import { useFieldArray } from 'react-hook-form'; +import { RiDeleteBin5Line } from 'react-icons/ri'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableRow, +} from "@/components/ui/table"; +import THead from '../../components/table/THead'; +import { useTable } from '@/contexts/Table/TableState'; + +function FlatListEdit({ methods, initialItems, fieldName, keyName, validator, tableCols, ...props }) { + // ################################# + // HOOKS + // ################################# + + // ### DEFINE TABLE + const { tableState, initTable, replaceItems, removeItem } = useTable(); + + useEffect(() => { + // initialize table state + initTable({ tableCols, baseData: initialItems }); + replace(initialItems); + }, [initialItems]); + + + // ### CONFIRM DIALOG + const [confirmDialog, setConfirmDialog] = useState({ open: false, item: {} }); + + // ### USE FIELD ARRAY TO STORE ITEMS + const { fields, remove, replace } = useFieldArray({ + control: methods.control, + name: fieldName, + }); + + + // ################################# + // FUNCTIONS + // ################################# + + // ### ADD ITEMS FROM TEXTAREA + const handleAddItems = (e) => { + // clear errors + methods.clearErrors('addItem'); + // fetch values from textarea and split new lines & csv into array + const addItems = methods.getValues('addItem').split(/[\n,]/); + // return on empty input + if (addItems.length === 1 && addItems[0] === '') return; + + // get valid entries + // trim spaces from each input + addItems.forEach((input, idx) => addItems[idx] = input.trim()); + // filter array for valid mail addresses using zod + const validInputs = addItems.filter((input, idx) => { + const isMail = validator(input); + return isMail.success ?? keyName; + }); + console.log('validInputs', validInputs); + + // handle invalid inputs + // strip off valid entries from input to get remaining invalid inputs + const invalidInputs = addItems.filter(keyName => !validInputs.includes(keyName)); + // set input value to remaining invalid entries + methods.setValue('addItem', invalidInputs.join('\n')); + if (invalidInputs.length > 0) { + // set error message on input + methods.setError('addItem', { message: 'invalid entries remaining' }); + } + + // handle valid inputs + // get existing items from fieldarray + const extistingItems = methods.getValues(fieldName); + // flatten fieldarray to array of strings only to merge with new inputs + let flatArray = extistingItems.map(field => field[keyName]); + // add valid inputs to existing items avoiding douplettes + flatArray = ([... new Set([...flatArray, ...validInputs])]); + // sort & convert flat array back into object + const wholeItemsObject = flatArray.sort().map(input => ({ [keyName]: input })); + // replace whole table data (invisible) + replaceItems(wholeItemsObject); + // replace whole fieldarray, which will be submitted to backend + replace(wholeItemsObject); + // set focus back to input field, which is only used for display + methods.setFocus('addItem'); + }; + + + // ### REMOVE ITEM FROM ARRAY + const handleRemoveItem = (idx) => { + // delete from fieldarray, which will be submitted to backend + remove(idx); + // delete from tableData, which is only used for display + replaceItems(methods.getValues(fieldName)); + }; + + // ################################# + // OUTPUT + // ################################# + if (!tableState.tableData) return <div>loading...</div>; + + + return ( + <> + {JSON.stringify(tableState.orderBy)} + {/* add Owner */} + <Textarea name='addItem' title={fieldName} tooltip={props.tooltip}> + <Button type='button' variant="default" onClick={handleAddItems} className='px-4 h-16 self-center'>add</Button> + </Textarea> + {/* List */} + <Table> + <TableCaption>{tableState.tableData.length} of {tableState.baseData.length}</TableCaption> + <THead actionCol={true} /> + <TableBody> + {tableState.tableData && tableState.tableData.map((item, idx) => { + return <TableRow key={idx}> + <TableCell> + {item[keyName]} + </TableCell > + <TableCell> + <Button + type="button" + onClick={() => setConfirmDialog({ open: true, idToDelete: idx, displayName: item[keyName] })} + variant='destructive' + size='icon' + title='delete from list' + > + <RiDeleteBin5Line /> + </Button> + </TableCell> + </TableRow>; + })} + </TableBody> + </Table> + + {fields && fields.map((item, idx) => { + return <div key={idx}> + <input className='w-full h-full bg-transparent' type='hidden' key={item.id} {...methods.register(`${fieldName}.${idx}.${keyName}`)} disabled={true} /> + </div>; + })} + + + + {/* confirmDialog */} + <ConfirmBox confirmDialog={confirmDialog} closeDialog={() => setConfirmDialog({ ...confirmDialog, open: false })} handleProceed={() => { handleRemoveItem(confirmDialog.idToDelete); }} /> + </> + ); +} + +export default React.memo(FlatListEdit);; \ No newline at end of file diff --git a/src/components/form/FlatListEdit.jsx b/src/components/form/FlatListEdit.jsx new file mode 100644 index 0000000..54a954c --- /dev/null +++ b/src/components/form/FlatListEdit.jsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from 'react'; +import Textarea from './Textarea'; +import { Button } from '../ui/button'; +import ConfirmBox from '../boxes/ConfirmBox'; +import CustomTable from '../table/CustomTable'; +import { useFieldArray } from 'react-hook-form'; + + +function FlatListEdit({ initialItems = [], fieldName = '', tooltip = '', columns = [], methods, validator, keyName, confirmDialog, setConfirmDialog }) { + // ################################# + // HOOKS + // ################################# + + // store current data in state + const [data, setData] = useState(initialItems); + // set first data set + useEffect(() => { + // initialize table state + setData(initialItems); + replace(initialItems); + }, [initialItems]); + + // ### USE FIELD ARRAY TO STORE ITEMS + const { fields, remove, replace } = useFieldArray({ + control: methods.control, + name: fieldName, + }); + + // ################################# + // FUNCTIONS + // ################################# + // ### ADD ITEMS FROM TEXTAREA + const handleAddItems = (e) => { + // clear error on input field + methods.clearErrors('addItem'); + // fetch values from textarea and split new lines & csv into array + const addItems = methods.getValues('addItem').split(/[\n,]/); + // return on empty input + if (addItems.length === 1 && addItems[0] === '') return; + + + // get valid entries + // trim spaces from each input + addItems.forEach((input, idx) => addItems[idx] = input.trim()); + // filter array for valid entries using zod + const validInputs = addItems.filter((input, idx) => { + const isValid = validator(input); + return isValid.success ?? keyName; + }); + // exit if no valid items found + if (validInputs.length === 0) return; + console.log('validInputs', validInputs); + + // handle invalid inputs + // strip off valid entries from input to get remaining invalid inputs + const invalidInputs = addItems.filter(keyName => !validInputs.includes(keyName)); + // set input value to remaining invalid entries + methods.setValue('addItem', invalidInputs.join('\n')); + if (invalidInputs.length > 0) { + // set error message on input + methods.setError('addItem', { message: 'invalid entries remaining' }); + } + console.log('invalidInputs', invalidInputs); + + + // handle valid inputs + // get existing items from fieldarray + const extistingItems = methods.getValues(fieldName) || []; + + // flatten fieldarray to array of strings only to merge with new inputs + let flatArray = extistingItems.map(field => field[keyName]); + // add valid inputs to existing items avoiding douplettes + flatArray = ([... new Set([...flatArray, ...validInputs])]); + // sort & convert flat array back into object + const wholeItemsObject = flatArray.sort().map(input => ({ [keyName]: input })); + // replace whole table data (invisible) + setData(wholeItemsObject); + // replace whole fieldarray, which will be submitted to backend + replace(wholeItemsObject); + // set focus back to input field, which is only used for display + methods.setFocus('addItem'); + }; + + + // ### REMOVE ITEM FROM ARRAY + const handleRemoveItem = (idx) => { + // delete from fieldarray, which will be submitted to backend + remove(idx); + // delete from tableData, which is only used for display + setData(methods.getValues(fieldName)); + }; + + // ################################# + // OUTPUT + // ################################# + + return ( + <> + {/* add Owner */} + <Textarea name='addItem' title={fieldName} tooltip={tooltip}> + <Button type='button' variant="default" onClick={handleAddItems} className='px-4 h-16 self-center'>add</Button> + </Textarea> + {/* List */} + <CustomTable columns={columns} data={data} /> + + {fields && fields.map((item, idx) => { + return <div key={idx}> + <input className='w-full h-full' type='hidden' key={item.id} {...methods.register(`${fieldName}.${idx}.${keyName}`)} disabled={true} /> + </div>; + })} + + {/* confirmDialog */} + <ConfirmBox confirmDialog={confirmDialog} closeDialog={() => setConfirmDialog({ ...confirmDialog, open: false })} handleProceed={() => { handleRemoveItem(confirmDialog.idToDelete); }} /> + </> + ); +} + +export default FlatListEdit; \ No newline at end of file diff --git a/src/components/form/Input.jsx b/src/components/form/Input.jsx new file mode 100755 index 0000000..8d9f8c6 --- /dev/null +++ b/src/components/form/Input.jsx @@ -0,0 +1,62 @@ +import React, { useId } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { twMerge } from 'tailwind-merge'; +import Tooltip from '../boxes/Tooltip'; +import RequiredBadge from './RequiredBadge'; +import { capitalizeFirstLetter } from '../../utils'; + +function Input({ title, name, type, className, tooltip, ...props }) { + // ################################# + // HOOKS + // ################################# + const { + register, + formState: { errors } + } = useFormContext(); + + const id = useId(); + // ################################# + // FUNCTIONS + // ################################# + // ### MERGE CLASSNAMES + // static class names every item should have + const staticClassName = 'focus:outline-none focus:border-UhhBlue block box-border h-8 px-4 border border-UhhGrey bg-UhhLightGrey w-full caret-UhhRed aria-[invalid=true]:border-UhhRed disabled:text-UhhGrey/70 disabled:cursor-not-allowed'; + // dynamic class names depending on the type + const typeClassName = () => { + switch (type) { + case 'file': { + return 'file:text-xs file:py-2 file:border-0 file:bg-UhhBlue file:text-UhhWhite hover:cursor-pointer hover:file:cursor-pointer hover:file:text-UhhBlue hover:file:bg-UhhWhite pl-0'; + } + default: { + return ' '; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, className); + + // ################################# + // OUTPUT + // ################################# + return ( + <> + <label htmlFor={id}> + {props.required && <RequiredBadge />} + {capitalizeFirstLetter(title)} + {tooltip && <Tooltip>{tooltip}</Tooltip>} + + <input {...register(name)} + type={type} + id={id} + aria-invalid={errors[name] ? 'true' : 'false'} + className={mergedClassName} + autoComplete={type === 'password' ? 'off' : 'on'} + {...props} + /> + <p className="validationNote mt-2 text-UhhRed text-xs overflow-auto">{errors[name]?.message}</p> + </label> + </> + ); +}; + +export default React.memo(Input); \ No newline at end of file diff --git a/src/components/form/RequiredBadge.jsx b/src/components/form/RequiredBadge.jsx new file mode 100755 index 0000000..721922a --- /dev/null +++ b/src/components/form/RequiredBadge.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +function RequiredBadge() { + // ################################# + // HOOKS + // ################################# + + // ################################# + // FUNCTIONS + // ################################# + + // ################################# + // OUTPUT + // ################################# + return ( + <span className="text-UhhRed leading-4" title="This field is required">* </span> + ); +} + +export default React.memo(RequiredBadge); \ No newline at end of file diff --git a/src/components/form/Select.jsx b/src/components/form/Select.jsx new file mode 100755 index 0000000..f2fc954 --- /dev/null +++ b/src/components/form/Select.jsx @@ -0,0 +1,71 @@ +import React, { useId } from 'react'; +import { Controller, useFormContext, useFormState } from 'react-hook-form'; +import { twMerge } from 'tailwind-merge'; +import RequiredBadge from './RequiredBadge'; +import Tooltip from '../boxes/Tooltip'; +import { capitalizeFirstLetter } from '../../utils'; + + +function Select({ options, name, title, defaultValue, className, tooltip, type, ...props }) { + // ################################# + // HOOKS + // ################################# + const { + control, + formState: { errors } + } = useFormContext(); + + + + + // GENERATE RANDOM UUID + const id = useId(); + + // ################################# + // FUNCTIONS + // ################################# + // ### MERGE CLASSNAMES + // static class names every item should have + const staticClassName = 'focus:outline-none focus:border-UhhBlue block box-border h-8 px-4 border border-UhhGrey bg-UhhLightGrey w-full aria-[invalid=true]:border-UhhRed'; + // dynamic class names depending on the type + const typeClassName = () => { + switch (type) { + default: { + return ''; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, typeClassName(), className); + + // ################################# + // OUTPUT + // ################################# + return ( + <label htmlFor={id}> + {props.required && <RequiredBadge />} + {capitalizeFirstLetter(title)} + {tooltip && <Tooltip>{tooltip}</Tooltip>} + + <Controller + render={ + ({ field }) => <select className={mergedClassName} {...field} {...props}> + <option key={`option-${id}`} value='' disabled>-- Select --</option> + {options.map(option => ( + <option key={`option-${option._id}`} value={option._id}> + {option.title} + </option> + ))} + </select> + } + control={control} + name={name} + defaultValue={defaultValue} + id={id} + /> + <p className="validationNote mt-2 text-UhhRed text-xs overflow-auto">{errors[name]?.message}</p> + </label> + ); +} + +export default React.memo(Select); \ No newline at end of file diff --git a/src/components/form/Submit.jsx b/src/components/form/Submit.jsx new file mode 100755 index 0000000..f244f1b --- /dev/null +++ b/src/components/form/Submit.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import { twMerge } from 'tailwind-merge'; + +function Submit({ value, type, className, ...props }) { + // ################################# + // HOOKS + // ################################# + const { + formState: { isSubmitting, isValid } + } = useFormContext(); + + // ################################# + // FUNCTIONS + // ################################# + // ### MERGE CLASSNAMES + // static class names every item should have + let staticClassName = 'inline-block w-full h-16 mb-4 box-border border border-UhhBlue leading-[4rem] text-center align-middle bg-UhhBlue border-UhhBlue text-UhhWhite font-UhhBC cursor-pointer disabled:cursor-not-allowed disabled:opacity-60 lg:min-w-xs lg:w-[calc(1/2*100%-(1*1rem/2))] xl:w-[calc((1/4*100%)-(3*1rem/4))] hover:text-UhhBlue hover:bg-UhhWhite'; + // dynamic class names depending on the type + const typeClassName = () => { + switch (type = 'submit') { + default: { + return ''; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, typeClassName(), className); + // ################################# + // OUTPUT + // ################################# + return ( + <> + {/* <input type="submit" className={mergedClassName} value={value} disabled={isSubmitting} /> */} + <input type="submit" className={mergedClassName} value={value} disabled={!isValid || isSubmitting} {...props} /> + </> + ); +} + +export default React.memo(Submit); \ No newline at end of file diff --git a/src/components/form/Textarea.jsx b/src/components/form/Textarea.jsx new file mode 100755 index 0000000..bce7ced --- /dev/null +++ b/src/components/form/Textarea.jsx @@ -0,0 +1,63 @@ +import React, { useId } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { twMerge } from 'tailwind-merge'; +import Tooltip from '../boxes/Tooltip'; +import RequiredBadge from './RequiredBadge'; +import { capitalizeFirstLetter } from '../../utils'; + + +function Textarea({ title, name, className, type, tooltip, children, ...props }) { + // ################################# + // HOOKS + // ################################# + const { + register, + formState: { errors } + } = useFormContext(); + + const id = useId(); + // ################################# + // FUNCTIONS + // ################################# + // ### MERGE CLASSNAMES + // static class names every item should have + const staticClassName = 'h-24 focus:outline-none focus:border-UhhBlue block box-border h-16 py-2 px-4 border border-UhhGrey bg-UhhLightGrey w-full caret-UhhRed aria-[invalid=true]:border-UhhRed text-sm sm:text-base'; + // dynamic class names depending on the type + const typeClassName = () => { + switch (type) { + default: { + return ''; + } + } + }; + // merge static and dynamic class names with className from props + const mergedClassName = twMerge(staticClassName, typeClassName(), className); + + // ################################# + // OUTPUT + // ################################# + return ( + <> + <label htmlFor={id}> + {props.required && <RequiredBadge />} + {capitalizeFirstLetter(title)} + {tooltip && <Tooltip>{tooltip}</Tooltip>} + + <div className="inline-flex w-full"> + <textarea {...register(name)} + id={id} + aria-invalid={errors[name] ? 'true' : 'false'} + className={mergedClassName} + autoComplete='off' + {...props}> + </textarea> + + {children} + </div> + <p className="validationNote mt-2 text-UhhRed text-xs overflow-auto">{errors[name]?.message}</p> + </label> + </> + ); +} + +export default React.memo(Textarea); \ No newline at end of file diff --git a/src/contexts/Auth/AuthContext.jsx b/src/contexts/Auth/AuthContext.jsx new file mode 100755 index 0000000..030c594 --- /dev/null +++ b/src/contexts/Auth/AuthContext.jsx @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +const AuthContext = createContext(); + +export default AuthContext; \ No newline at end of file diff --git a/src/contexts/Auth/AuthReducer.jsx b/src/contexts/Auth/AuthReducer.jsx new file mode 100755 index 0000000..8c5f0af --- /dev/null +++ b/src/contexts/Auth/AuthReducer.jsx @@ -0,0 +1,15 @@ +import { USER_ACTIONS } from "./AuthTypes"; +const authReducer = (state, action) => { + switch (action.type) { + case USER_ACTIONS.SET: + localStorage.setItem("user", JSON.stringify(action.payload)); + return action.payload; + case USER_ACTIONS.DROP: + localStorage.removeItem("user"); + localStorage.removeItem("accessToken"); + return null; + default: + return state; + } +}; +export default authReducer; \ No newline at end of file diff --git a/src/contexts/Auth/AuthState.jsx b/src/contexts/Auth/AuthState.jsx new file mode 100755 index 0000000..c7a904d --- /dev/null +++ b/src/contexts/Auth/AuthState.jsx @@ -0,0 +1,66 @@ +import React, { useContext, useReducer, useState } from 'react'; +import AuthContext from './AuthContext'; +import authReducer from './AuthReducer'; +import { USER_ACTIONS } from './AuthTypes'; +import api from '../../utils/AxiosConfig'; + +// ### EXPORT useContext TO REDUCE NEEDED CODE IN CLIENT FILES +export function useAuth() { + return useContext(AuthContext); +} + +function AuthState({ children }) { + // ################################# + // HOOKS + // ################################# + // ### CURRENTUSER_ACTIONS + // set default user for first page load and hard reloads (<CTRL +> F5) + const initial_currenUser = JSON.parse(localStorage.getItem('user')) || null; + const [currentUser, dispatchCurrentUser] = useReducer(authReducer, initial_currenUser); + // ### ACCESSTOKEN + const [accessToken, setAccessToken] = useState(); + + // ################################# + // FUNCTIONS + // ################################# + + // ### LOGIN + async function login(credentials) { + const result = await api.post( + '/users/login', + credentials, + { withCredentials: true } + ); + // set current user to login and merge accessToken into currentUser + dispatchCurrentUser({ type: USER_ACTIONS.SET, payload: { ...result.data.document } }); + setAccessToken(result.data.accessToken); + // TODO: don't store accessToken in localStorage, keep in memory only + localStorage.setItem("accessToken", JSON.stringify(result.data.accessToken)); + return result; + } + + // ### HANDLE LOGOUT + async function logout() { + dispatchCurrentUser({ type: USER_ACTIONS.DROP }); + const result = await api.delete( + '/users/logout', + { withCredentials: true } + ); + return result; + } + + // ### RETURN + return ( + <AuthContext.Provider + value={{ + login, + logout, + USER_ACTIONS, + dispatchCurrentUser + }}> + {children} + </AuthContext.Provider> + ); +}; + +export default React.memo(AuthState); \ No newline at end of file diff --git a/src/contexts/Auth/AuthTypes.js b/src/contexts/Auth/AuthTypes.js new file mode 100755 index 0000000..3520a8d --- /dev/null +++ b/src/contexts/Auth/AuthTypes.js @@ -0,0 +1,4 @@ +export const USER_ACTIONS = { + SET: 'set', + DROP: 'drop' +}; \ No newline at end of file diff --git a/src/layouts/partials/Header.jsx b/src/layouts/partials/Header.jsx index dcc89fb..4e38ee0 100644 --- a/src/layouts/partials/Header.jsx +++ b/src/layouts/partials/Header.jsx @@ -6,8 +6,7 @@ import { RiFilter2Line } from 'react-icons/ri'; import Hamburger from 'hamburger-react'; - -function Header({ showMobileNav, toggleMobileNav }) { +function Header({ layout, showMobileNav, toggleMobileNav }) { // ################################# // HOOKS // ################################# @@ -19,45 +18,59 @@ function Header({ showMobileNav, toggleMobileNav }) { // ################################# // OUTPUT // ################################# - return ( - <header role="banner" className='row-start-1 col-span-full sm:flex justify-center'> - {/* mobile */} - <div className="flex justify-between items-center bg-UhhBlue text-UhhWhite px-4 py-2 sm:hidden"> - <a className="hidden h-16" href="https://www.uni-hamburg.de" target="_blank" title="UHH [external]"> - <UhhImg /> - </a> - <Link className="flex" to="/"> - <div className="h-8 pr-2"> - <PortalImg alt='Logo of ZBH Portal' /> - </div> - <h2>ZBH-Portal</h2> - </Link> - <span className="h-12 flex items-center gap-x-4"> - <div className="h-8 w-8"> - <RiFilter2Line className='text-3xl' /> - </div> - <div className="h-full w-8"> - <Hamburger label='show navigation' toggled={showMobileNav} onToggle={toggleMobileNav} duration={0.3} /> - </div> - </span> - </div> - {/* desktop */} - <div className="hidden text-UhhWhite px-4 py-2 sm:flex justify-between container"> - <a className="inline-block h-20" href="https://www.uni-hamburg.de" target="_blank" title="UHH [external]"> + + // CLEAN LAYOUT + if (layout === 'clean') { + return ( + <header role="banner" className="px-4 py-2"> + <a className="block h-16" href="https://www.uni-hamburg.de" target="_blank" title="UHH [external]"> <UhhImg /> </a> - <Link className="flex items-center" to="/"> - <div className="text-UhhBlue h-20 pr-2"> - <PortalImg /> - </div> - <h2 className="font-UhhBC"> - <div className="text-UhhGrey">ZBH</div> - <div className="text-UhhBlue">Portal</div> - </h2> - </Link> - </div> - </header> - ); + </header> + ); + } else { + + // MAIN LAYOUT + return ( + <header role="banner" className='row-start-1 col-span-full sm:flex justify-center'> + {/* mobile */} + <div className="flex justify-between items-center bg-UhhBlue text-UhhWhite px-4 py-2 sm:hidden"> + <a className="hidden h-16" href="https://www.uni-hamburg.de" target="_blank" title="UHH [external]"> + <UhhImg /> + </a> + <Link className="flex" to="/"> + <div className="h-8 pr-2"> + <PortalImg alt='Logo of ZBH Portal' /> + </div> + <h2>ZBH-Portal</h2> + </Link> + <span className="h-12 flex items-center gap-x-4"> + <div className="h-8 w-8"> + <RiFilter2Line className='text-3xl' /> + </div> + <div className="h-full w-8"> + <Hamburger label='show navigation' toggled={showMobileNav} onToggle={toggleMobileNav} duration={0.3} /> + </div> + </span> + </div> + {/* desktop */} + <div className="hidden text-UhhWhite px-4 py-2 sm:flex justify-between container"> + <a className="inline-block h-20" href="https://www.uni-hamburg.de" target="_blank" title="UHH [external]"> + <UhhImg /> + </a> + <Link className="flex items-center" to="/"> + <div className="text-UhhBlue h-20 pr-2"> + <PortalImg /> + </div> + <h2 className="font-UhhBC"> + <div className="text-UhhGrey">ZBH</div> + <div className="text-UhhBlue">Portal</div> + </h2> + </Link> + </div> + </header> + ); + } } export default React.memo(Header); \ No newline at end of file diff --git a/src/pages/User/Login.jsx b/src/pages/User/Login.jsx new file mode 100644 index 0000000..d0adc6e --- /dev/null +++ b/src/pages/User/Login.jsx @@ -0,0 +1,89 @@ +import React, { useEffect } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/Auth/AuthState'; +import { FormProvider, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from "zod"; +import { toast } from 'react-toastify'; +import { mergeBackendValidation, setFlashMsg } from '../../utils/ErrorHandling'; +import Input from '../../components/form/Input'; +import Submit from '../../components/form/Submit'; +import { Helmet } from 'react-helmet-async'; +import Heading from '../../components/font/Heading'; + +function Login() { + + // ################################# + // VALIDATION SCHEMA + // ################################# + const schema = z.object({ + email: z.string().min(1), + password: z.string().min(1) + }); + + // ################################# + // HOOKS + // ################################# + // ### CONNECT AUTH CONTEXT + const { login } = useAuth(); + // ### MAKE USE OF NAVIGATION + const redirect = useNavigate(); + + // ### MAKE USE OF location state to fetch former requested page + const { state } = useLocation(); + // ### PREPARE FORM + const methods = useForm({ + resolver: zodResolver(schema), + mode: 'onSubmit', + defaultValues: { + email: '', + password: '' + } + }); + + // ################################# + // FUNCTIONS + // ################################# + // ### HANDLE SUBMITTING LOGIN FORM + async function handleSendForm(record) { + try { + // send data to login function + const result = await login(record); + // if former page request was saved, redirect to this page + // otherwise redirect to default page + (state) ? redirect(`${state.redirectTo.pathname}${state.redirectTo.search}`) : redirect(import.meta.env.VITE_PAGE_AFTER_LOGIN); + // FIX: flash message not dislayed + setFlashMsg(result.data?.message); + + } catch (err) { + console.log(err); + // merge front & backend validation errors + mergeBackendValidation(err.response.status, err.response.data, methods.setError); + } + } + + // ################################# + // OUTPUT + // ################################# + return ( + <> + {/* render page title */} + <Helmet><title>[{import.meta.env.VITE_APP_NAME}]</title></Helmet> + + <Heading level="1">ZBH-Portal LogIn</Heading> + <FormProvider {...methods} > + <form onSubmit={methods.handleSubmit(handleSendForm)}> + <Input name='email' type='mail' title='E-Mail' className='h-16' autoFocus={true} /> + <Input name='password' type='password' title='password' className='h-16' /> + <Submit value='LogIn' /> + </form> + </FormProvider> + + <div className="mt-4"> + <Link to="/forgot_password">Forgot your Password?</Link> + </div> + </> + ); +} + +export default Login; \ No newline at end of file diff --git a/src/routes/Sitemap.jsx b/src/routes/Sitemap.jsx index 0635aed..04299d2 100644 --- a/src/routes/Sitemap.jsx +++ b/src/routes/Sitemap.jsx @@ -28,6 +28,8 @@ export const sitemap = [{ ] }, { title: 'Others', element: <CleanLayout />, children: [ + // USER + { path: '/login', element: loadComponent('User/Login') }, // ERROR { path: '*', element: loadComponent('Err404') } ] diff --git a/src/utils.js b/src/utils.js new file mode 100755 index 0000000..035e086 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,10 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} + +export function capitalizeFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} \ No newline at end of file diff --git a/src/utils/AxiosConfig.js b/src/utils/AxiosConfig.js new file mode 100755 index 0000000..98abfd7 --- /dev/null +++ b/src/utils/AxiosConfig.js @@ -0,0 +1,69 @@ +import axios from "axios"; + +// ### CREATE BASE INSTANCE +// defaults, used by all api requests +const api = axios.create({ + baseURL: `${import.meta.env.VITE_BACKEND_URL}:${import.meta.env.VITE_BACKEND_PORT}/${import.meta.env.VITE_BACKEND_PATH}`, +}); + + +// ### REQUEST INTERCEPTOR +// injects accessToken if exists +api.interceptors.request.use( + (config) => { + // fetch accessToken + // TODO: don't store accessToken in localStorage, keep in memory only + const accessToken = JSON.parse(localStorage.getItem('accessToken')) || null; + // inject if exists + if (accessToken) { + config.headers["Authorization"] = 'Bearer ' + accessToken; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + + +// ### RESPONSE INTERCEPTOR +// refreshes accessToken if needed +api.interceptors.response.use( + (res) => { + return res; + }, + async (err) => { + // console.log(err); + // save original request config + const originalConfig = err.config; + // if access denied and not a retry already + // BUG: Infinit loop because _retry isn't set at runtime + // console.log(originalConfig); + // console.log(JSON.stringify(originalConfig)); + if (originalConfig && err?.response?.status === 403 && originalConfig._retry !== true) { + // patch config to remember it's a retry + originalConfig._retry = true; + console.log('trying to refresh the accessToken and rerun the request'); + // console.log('retry', err.code, originalConfig._retry); + // refresh access token + try { + const result = await api.post( + '/auth/token', + {}, + { withCredentials: true } + ); + // TODO: don't store accessToken in localStorage, keep in memory only + localStorage.setItem("accessToken", JSON.stringify(result.data.accessToken)); + // run retry + return api(originalConfig); + + } catch (error) { + return Promise.reject(error); + } + } + return Promise.reject(err); + + } +); + +export default api; \ No newline at end of file diff --git a/src/utils/ErrorHandling.js b/src/utils/ErrorHandling.js new file mode 100644 index 0000000..93eb819 --- /dev/null +++ b/src/utils/ErrorHandling.js @@ -0,0 +1,29 @@ +import { toast } from 'react-toastify'; + +// ### MERGE BACKEND ERRORS INTO FRONTEND +export function mergeBackendValidation(status, errors, setError) { + // global error + if (errors.message) { + toast.error(errors.message, { + theme: "colored" + }); + if (setError) { + setError('root', { type: status, message: errors.message }); + } + } + + // exit if no validation errors returned + if (!errors.validationErrors) return; + // set single field validation errors + const validationErrors = errors.validationErrors; + Object.keys(validationErrors).forEach(function (field) { + setError(field, { message: validationErrors[field] }); + }); + + return; +} + +// ### DISPLAY FLASH MESSAGES (mostly success from backend) +export function setFlashMsg(message) { + toast.success(message); +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index c9d44c7..d8c39ef 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,11 +1,38 @@ /** @type {import('tailwindcss').Config} */ -export default { +module.exports = { + darkMode: ["class"], content: [ "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", + "./src/**/*.{js,ts,jsx,tsx}" ], theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: 0 }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: 0 }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, colors: { UhhBlue: '#0271bb', UhhLightBlue: 'rgba(128, 184, 219, 0.5)', @@ -61,6 +88,9 @@ export default { 'UhhRC': ['TheSansUHHRegularCaps'], 'UhhI': ['TheSansUHHItalic'], }, + transitionProperty: { + 'width': 'width' + }, borderColor: { DEFAULT: '#3b515b' }, @@ -72,8 +102,7 @@ export default { 'xl': '36rem', '2xl': '42rem', } - }, + } }, plugins: [], -} - +}; -- GitLab