diff --git a/package-lock.json b/package-lock.json index 8f4ff09335aba24929323fbd8d75e01f56641abd..e65e3e0aa77d59956b48493da6a6b3ba25309f78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,8 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.11.0", - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.5", + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", "@fontsource/barlow": "^5.0.13", "@fontsource/dm-sans": "^5.0.21", "@fontsource/inter": "^5.0.18", @@ -29,8 +29,9 @@ "@fullcalendar/timeline": "^6.1.14", "@hookform/resolvers": "^3.6.0", "@iconify/react": "^5.0.1", + "@mui/icons-material": "^5.16.6", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.20", + "@mui/material": "^5.16.6", "@mui/material-nextjs": "^5.15.11", "@mui/x-data-grid": "^7.7.0", "@mui/x-date-pickers": "^7.7.0", @@ -99,7 +100,9 @@ "@types/nprogress": "^0.2.3", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-lazy-load-image-component": "^1.6.4", "@types/stylis": "^4.2.6", + "@types/turndown": "^5.0.5", "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "eslint": "^8.57.0", @@ -3418,12 +3421,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/babel-plugin/node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, "node_modules/@emotion/babel-plugin/node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -3442,18 +3439,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/cache/node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/cache/node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, "node_modules/@emotion/cache/node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -3479,30 +3464,33 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", - "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.0.tgz", + "integrity": "sha512-SHetuSLvJDzuNbOdtPVbq6yMMMlLoW5Q94uDqJZqy50gcmAjxFkVqmzqSGEFq9gT2iMuIeKV1PXVWmvUhuZLlQ==", + "license": "MIT", "dependencies": { - "@emotion/memoize": "^0.8.1" + "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" }, "node_modules/@emotion/react": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", - "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz", + "integrity": "sha512-WkL+bw1REC2VNV1goQyfxjx1GYJkcc23CRQkXX+vZNLINyfI7o+uUn/rTGPt/xJ3bJHd5GcljgnxHf4wRw5VWQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/babel-plugin": "^11.12.0", + "@emotion/cache": "^11.13.0", + "@emotion/serialize": "^1.3.0", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { @@ -3527,12 +3515,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@emotion/serialize/node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, "node_modules/@emotion/sheet": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", @@ -3540,16 +3522,17 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.11.5", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.5.tgz", - "integrity": "sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.0.tgz", + "integrity": "sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.2", - "@emotion/serialize": "^1.1.4", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" + "@emotion/babel-plugin": "^11.12.0", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.0", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.0" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", @@ -3568,9 +3551,10 @@ "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", + "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0" } @@ -3582,9 +3566,10 @@ "license": "MIT" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", @@ -4693,15 +4678,41 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.5.tgz", - "integrity": "sha512-ziFn1oPm6VjvHQcdGcAO+fXvOQEgieIj0BuSqcltFU+JXIxjPdVYNTdn2HU7/Ak5Gabk6k2u7+9PV7oZ6JT5sA==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.6.tgz", + "integrity": "sha512-kytg6LheUG42V8H/o/Ptz3olSO5kUXW9zF0ox18VnblX6bO2yif1FPItgc3ey1t5ansb1+gbe7SatntqusQupg==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.6.tgz", + "integrity": "sha512-ceNGjoXheH9wbIFa1JHmSc9QVjJUvh18KvHrR4/FkJCSi9HXJ+9ee1kUhCOEFfuxNF8UB6WWVrIUOUgRd70t0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/lab": { "version": "5.0.0-alpha.170", "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.170.tgz", @@ -4743,16 +4754,16 @@ } }, "node_modules/@mui/material": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.5.tgz", - "integrity": "sha512-eQrjjg4JeczXvh/+8yvJkxWIiKNHVptB/AqpsKfZBWp5mUD5U3VsjODMuUl1K2BSq0omV3CiO/mQmWSSMKSmaA==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.6.tgz", + "integrity": "sha512-0LUIKBOIjiFfzzFNxXZBRAyr9UQfmTAFzbt6ziOU2FDXhorNN2o3N9/32mNJbCA8zJo2FqFU6d3dtoqUDyIEfA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.16.5", - "@mui/system": "^5.16.5", + "@mui/core-downloads-tracker": "^5.16.6", + "@mui/system": "^5.16.6", "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.5", + "@mui/utils": "^5.16.6", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", @@ -4822,13 +4833,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.5.tgz", - "integrity": "sha512-CSLg0YkpDqg0aXOxtjo3oTMd3XWMxvNb5d0v4AYVqwOltU8q6GvnZjhWyCLjGSCrcgfwm6/VDjaKLPlR14wxIA==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.6.tgz", + "integrity": "sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.16.5", + "@mui/utils": "^5.16.6", "prop-types": "^15.8.1" }, "engines": { @@ -4849,9 +4860,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.16.4", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.4.tgz", - "integrity": "sha512-0+mnkf+UiAmTVB8PZFqOhqf729Yh0Cxq29/5cA3VAyDVTRIUUQ8FXQhiAhUIbijFmM72rY80ahFPXIm4WDbzcA==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.6.tgz", + "integrity": "sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", @@ -4881,16 +4892,16 @@ } }, "node_modules/@mui/system": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.5.tgz", - "integrity": "sha512-uzIUGdrWddUx1HPxW4+B2o4vpgKyRxGe/8BxbfXVDPNPHX75c782TseoCnR/VyfnZJfqX87GcxDmnZEE1c031g==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.6.tgz", + "integrity": "sha512-5xgyJjBIMPw8HIaZpfbGAaFYPwImQn7Nyh+wwKWhvkoIeDosQ1ZMVrbTclefi7G8hNmqhip04duYwYpbBFnBgw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.16.5", - "@mui/styled-engine": "^5.16.4", + "@mui/private-theming": "^5.16.6", + "@mui/styled-engine": "^5.16.6", "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.5", + "@mui/utils": "^5.16.6", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -4935,9 +4946,9 @@ } }, "node_modules/@mui/utils": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.5.tgz", - "integrity": "sha512-CwhcA9y44XwK7k2joL3Y29mRUnoBt+gOZZdGyw7YihbEwEErJYBtDwbZwVgH68zAljGe/b+Kd5bzfl63Gi3R2A==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz", + "integrity": "sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", @@ -7238,6 +7249,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-lazy-load-image-component": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@types/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.4.tgz", + "integrity": "sha512-8pFPeDPF4yVG4lU1/ixZidJEEDZmEOYOTYDvmIu2IAabyuv97Q7n/93nMCocHvQ7vD1czKGiW+op55D9m3MkdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -7252,6 +7274,13 @@ "integrity": "sha512-4nebF2ZJGzQk0ka0O6+FZUWceyFv4vWq/0dXBMmrSeAwzOuOd/GxE5Pa64d/ndeNLG73dXoBsRzvtsVsYUv6Uw==", "dev": true }, + "node_modules/@types/turndown": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", + "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", diff --git a/package.json b/package.json index c935d834889d0ff3f4d1efc12f8fce421e2d01ea..68bd47bfb0dfb5f074f57f5d88a3d9fd42c48efc 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.11.0", - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.5", + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", "@fontsource/barlow": "^5.0.13", "@fontsource/dm-sans": "^5.0.21", "@fontsource/inter": "^5.0.18", @@ -43,8 +43,9 @@ "@fullcalendar/timeline": "^6.1.14", "@hookform/resolvers": "^3.6.0", "@iconify/react": "^5.0.1", + "@mui/icons-material": "^5.16.6", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.20", + "@mui/material": "^5.16.6", "@mui/material-nextjs": "^5.15.11", "@mui/x-data-grid": "^7.7.0", "@mui/x-date-pickers": "^7.7.0", @@ -113,7 +114,9 @@ "@types/nprogress": "^0.2.3", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-lazy-load-image-component": "^1.6.4", "@types/stylis": "^4.2.6", + "@types/turndown": "^5.0.5", "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "eslint": "^8.57.0", diff --git a/src/app/dashboard/job/[id]/edit/page.tsx b/src/app/dashboard/job/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb395d8cbef6e6eabb3f9ed0e650fb4e2b14c220 --- /dev/null +++ b/src/app/dashboard/job/[id]/edit/page.tsx @@ -0,0 +1,41 @@ +import { CONFIG } from 'src/config-global'; +import { _jobs } from 'src/shared/_mock/_job'; + +import { JobEditView } from 'src/shared/sections/job/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Modifier une offre | Dashboard - ${CONFIG.site.name}` }; + +type Props = { + params: { id: string }; +}; + +export default function Page({ params }: Props) { + const { id } = params; + + const currentJob = _jobs.find((job) => job.id === id); + + return <JobEditView job={currentJob} />; +} + +// ---------------------------------------------------------------------- + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; + +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + return _jobs.map((job) => ({ id: job.id })); + } + return []; +} diff --git a/src/app/dashboard/job/[id]/page.tsx b/src/app/dashboard/job/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d750dd27d687a13fb4c5be01e8fd2b9551acf736 --- /dev/null +++ b/src/app/dashboard/job/[id]/page.tsx @@ -0,0 +1,41 @@ +import { CONFIG } from 'src/config-global'; +import { _jobs } from 'src/shared/_mock/_job'; + +import { JobDetailsView } from 'src/shared/sections/job/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Détails | Dashboard - ${CONFIG.site.name}` }; + +type Props = { + params: { id: string }; +}; + +export default function Page({ params }: Props) { + const { id } = params; + + const currentJob = _jobs.find((job) => job.id === id); + + return <JobDetailsView job={currentJob} />; +} + +// ---------------------------------------------------------------------- + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; + +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + return _jobs.map((job) => ({ id: job.id })); + } + return []; +} diff --git a/src/app/dashboard/job/new/page.tsx b/src/app/dashboard/job/new/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ab94f39bb6656041c2617925b9e170dc82fd70f6 --- /dev/null +++ b/src/app/dashboard/job/new/page.tsx @@ -0,0 +1,11 @@ +import { CONFIG } from 'src/config-global'; + +import { JobCreateView } from 'src/shared/sections/job/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Créer une offre | Dashboard - ${CONFIG.site.name}` }; + +export default function Page() { + return <JobCreateView />; +} diff --git a/src/app/dashboard/job/page.tsx b/src/app/dashboard/job/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d704d9a7ae8b6c98705aeefb92909cb9be4fe0d2 --- /dev/null +++ b/src/app/dashboard/job/page.tsx @@ -0,0 +1,11 @@ +import { CONFIG } from 'src/config-global'; + +// import { JobListView } from 'src/shared/sections/job/view'; +import { JobListView } from '../../../shared/sections/job/view/job-list-view'; +// ---------------------------------------------------------------------- + +export const metadata = { title: `Liste des offres | Dashboard - ${CONFIG.site.name}` }; + +export default function Page() { + return <JobListView />; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 738abeeda0f779c8cf18e851e228eb3d8afb3ccf..d96df187695eb004aa4ac542a0958e4d77239a9f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import type { Viewport } from 'next'; import { CONFIG } from 'src/config-global'; import { primary } from 'src/shared/theme/core/palette'; +import { LocalizationProvider } from 'src/shared/locales'; import { ThemeProvider } from 'src/shared/theme/theme-provider'; import { getInitColorSchemeScript } from 'src/shared/theme/color-scheme-script'; @@ -35,21 +36,22 @@ export default async function RootLayout({ children }: Props) { <html lang="en" suppressHydrationWarning> <body> {getInitColorSchemeScript} - - <AuthProvider> - <SettingsProvider - settings={settings} - caches={CONFIG.isStaticExport ? 'localStorage' : 'cookie'} - > - <ThemeProvider> - <MotionLazy> - <ProgressBar /> - <SettingsDrawer /> - {children} - </MotionLazy> - </ThemeProvider> - </SettingsProvider> - </AuthProvider> + <LocalizationProvider> + <AuthProvider> + <SettingsProvider + settings={settings} + caches={CONFIG.isStaticExport ? 'localStorage' : 'cookie'} + > + <ThemeProvider> + <MotionLazy> + <ProgressBar /> + <SettingsDrawer /> + {children} + </MotionLazy> + </ThemeProvider> + </SettingsProvider> + </AuthProvider> + </LocalizationProvider> </body> </html> ); diff --git a/src/routes/hooks/use-tabs.ts b/src/routes/hooks/use-tabs.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb4977ada5b178521b5f1fe9da771ee2f58b08fc --- /dev/null +++ b/src/routes/hooks/use-tabs.ts @@ -0,0 +1,21 @@ +import { useMemo, useState, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseTabsReturn = { + value: string; + setValue: React.Dispatch<React.SetStateAction<string>>; + onChange: (event: React.SyntheticEvent, newValue: string) => void; +}; + +export function useTabs(defaultValue: string): UseTabsReturn { + const [value, setValue] = useState(defaultValue); + + const onChange = useCallback((event: React.SyntheticEvent, newValue: string) => { + setValue(newValue); + }, []); + + const memoizedValue = useMemo(() => ({ value, setValue, onChange }), [onChange, value]); + + return memoizedValue; +} diff --git a/src/routes/paths.ts b/src/routes/paths.ts index 72b82391da34f10a0ab257e8e0abe27ea214a5e9..d99d0d3a9c54c64b891cdb59fa7e7edfc9ccba6f 100644 --- a/src/routes/paths.ts +++ b/src/routes/paths.ts @@ -52,5 +52,11 @@ export const paths = { tokensmartcontract: `${ROOTS.DASHBOARD}/tokensmartcontract`, nftdetails: `${ROOTS.DASHBOARD}/nft-details`, nftcards: `${ROOTS.DASHBOARD}/nft-cards`, + job: { + root: `${ROOTS.DASHBOARD}/job`, + new: `${ROOTS.DASHBOARD}/job/new`, + details: (id: string) => `${ROOTS.DASHBOARD}/job/${id}`, + edit: (id: string) => `${ROOTS.DASHBOARD}/job/${id}/edit`, + }, }, }; diff --git a/src/shared/_mock/_company.ts b/src/shared/_mock/_company.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2996b1505ad085612d21e7cca0e526d4728eb15 --- /dev/null +++ b/src/shared/_mock/_company.ts @@ -0,0 +1,11 @@ +import { _mock } from "./_mock"; + +export const _companies = [...Array(12)].map((_, index) => { + const company = { + name: _mock.companyNames(index), + logo: _mock.image.company(index), + phoneNumber: _mock.phoneNumber(index), + fullAddress: _mock.fullAddress(index), + }; + return company; +}); \ No newline at end of file diff --git a/src/shared/_mock/_job.ts b/src/shared/_mock/_job.ts index debd3bcb1e4846a3f8240eb79463b00011285bb4..9b8838e9000dae6f7290099c0a6680191709eae1 100644 --- a/src/shared/_mock/_job.ts +++ b/src/shared/_mock/_job.ts @@ -1,10 +1,11 @@ import { _mock } from './_mock'; +import { _badges } from './assets'; // ---------------------------------------------------------------------- export const JOB_DETAILS_TABS = [ - { label: 'Job content', value: 'content' }, - { label: 'Candidates', value: 'candidates' }, + { label: 'Contenu', value: 'content' }, + { label: 'Candidats', value: 'candidates' }, ]; export const JOB_SKILL_OPTIONS = [ @@ -14,37 +15,51 @@ export const JOB_SKILL_OPTIONS = [ 'JavaScript', 'TypeScript', 'Communication', - 'Problem Solving', + 'Résolution de Problèmes', 'Leadership', - 'Time Management', - 'Adaptability', + 'Gestion du Temps', + 'Adaptabilité', 'Collaboration', - 'Creativity', - 'Critical Thinking', - 'Technical Skills', - 'Customer Service', - 'Project Management', - 'Problem Diagnosis', + 'Créativité', + 'Pensée Critique', + 'Compétences Techniques', + 'Service Client', + 'Gestion de Projet', + 'Diagnostic de Problèmes', ]; export const JOB_WORKING_SCHEDULE_OPTIONS = [ - 'Monday to Friday', - 'Weekend availability', - 'Day shift', + 'Lundi', + 'Mardi', + 'Mercredi', + 'Jeudi', + 'Vendredi', + 'Samedi', + 'Dimanche', + 'Matin', + 'Après-midi', + 'Soir', + 'Nuit', + 'Temps Plein', + 'Temps Partiel', + 'Flexibilité', + 'Travail à Distance', + 'Présentiel', + 'Travail en Équipe', + 'Individuel', ]; export const JOB_EMPLOYMENT_TYPE_OPTIONS = [ - { label: 'Full-time', value: 'Full-time' }, - { label: 'Part-time', value: 'Part-time' }, - { label: 'On demand', value: 'On demand' }, - { label: 'Negotiable', value: 'Negotiable' }, + { label: 'À temps plein', value: 'À temps plein' }, + { label: 'À temps partiel', value: 'À temps partiel' }, + { label: 'Négociable', value: 'Négociable' }, ]; export const JOB_EXPERIENCE_OPTIONS = [ - { label: 'No experience', value: 'No experience' }, - { label: '1 year exp', value: '1 year exp' }, - { label: '2 year exp', value: '2 year exp' }, - { label: '> 3 year exp', value: '> 3 year exp' }, + { label: `Sans expérience`, value: 'Sans expérience' }, + { label: `1 an d'expérience`, value: `1 an d'expérience` }, + { label: `2 ans d'expérience`, value: `2 ans d'expérience` }, + { label: `> 3 ans d'expérience`, value: `> 3 ans d'expérience` }, ]; export const JOB_BENEFIT_OPTIONS = [ @@ -71,6 +86,13 @@ export const JOB_SORT_OPTIONS = [ { label: 'Oldest', value: 'oldest' }, ]; +export const JOB_CONTRACT_OPTIONS = [ + { label: 'CDI', value: 'CDI' }, + { label: 'CDD', value: 'CDD' }, + { label: 'CDT', value: 'CDT' }, + { label: 'Freelance', value: 'Freelance' }, +]; + const CANDIDATES = [...Array(12)].map((_, index) => ({ id: _mock.id(index), role: _mock.role(index), @@ -79,7 +101,6 @@ const CANDIDATES = [...Array(12)].map((_, index) => ({ })); const CONTENT = ` -<h6>Job description</h6> <p>Occaecati est et illo quibusdam accusamus qui. Incidunt aut et molestiae ut facere aut. Est quidem iusto praesentium excepturi harum nihil tenetur facilis. Ut omnis voluptates nihil accusantium doloribus eaque debitis.</p> @@ -117,7 +138,7 @@ export const _jobs = [...Array(12)].map((_, index) => { negotiable: _mock.boolean(index), }; - const benefits = JOB_BENEFIT_OPTIONS.slice(0, 3).map((option) => option.label); + // const benefits = JOB_BENEFIT_OPTIONS.slice(0, 3).map((option) => option.label); const experience = JOB_EXPERIENCE_OPTIONS.map((option) => option.label)[index] || JOB_EXPERIENCE_OPTIONS[1].label; @@ -133,23 +154,37 @@ export const _jobs = [...Array(12)].map((_, index) => { fullAddress: _mock.fullAddress(index), }; + const accepted = { + object: '', + body: '', + }; + + const rejected = { + object: '', + body: '', + }; + return { id: _mock.id(index), salary, publish, company, - benefits, experience, employmentTypes, + accepted, + rejected, + contract: JOB_CONTRACT_OPTIONS[index % JOB_CONTRACT_OPTIONS.length].label, content: CONTENT, candidates: CANDIDATES, role: _mock.role(index), title: _mock.jobTitle(index), createdAt: _mock.time(index), - expiredDate: _mock.time(index), skills: JOB_SKILL_OPTIONS.slice(0, 3), + requiredBadges: _badges.slice(3,6), + optionalBadges: _badges.slice(3,6), totalViews: _mock.number.nativeL(index), locations: [_mock.countryNames(1), _mock.countryNames(2)], workingSchedule: JOB_WORKING_SCHEDULE_OPTIONS.slice(0, 2), + questions: [], }; }); diff --git a/src/shared/_mock/assets.ts b/src/shared/_mock/assets.ts index 9aa15b3456157b99284c2c80fcea516364f12fd9..a29e5dcc5309ecc1860d45f8418b69e0bf646aa5 100644 --- a/src/shared/_mock/assets.ts +++ b/src/shared/_mock/assets.ts @@ -300,32 +300,33 @@ export const _countryNames = [ // ---------------------------------------------------------------------- export const _roles = [ - `CEO`, + `PDG`, `CTO`, - `Project Coordinator`, - `Team Leader`, - `Software Developer`, - `Marketing Strategist`, - `Data Analyst`, - `Product Owner`, - `Graphic Designer`, - `Operations Manager`, - `Customer Support Specialist`, - `Sales Manager`, - `HR Recruiter`, - `Business Consultant`, - `Financial Planner`, - `Network Engineer`, - `Content Creator`, - `Quality Assurance Tester`, - `Public Relations Officer`, - `IT Administrator`, - `Compliance Officer`, - `Event Planner`, - `Legal Counsel`, - `Training Coordinator`, + `Coordinateur de Projet`, + `Chef d'Équipe`, + `Développeur de Logiciels`, + `Stratège en Marketing`, + `Analyste de Données`, + `Propriétaire du Produit`, + `Designer Graphique`, + `Responsable des Opérations`, + `Spécialiste du Support Client`, + `Responsable des Ventes`, + `Recruteur RH`, + `Consultant en Affaires`, + `Planificateur Financier`, + `Ingénieur Réseau`, + `Créateur de Contenu`, + `Testeur Assurance Qualité`, + `Responsable des Relations Publiques`, + `Administrateur IT`, + `Responsable de la Conformité`, + `Organisateur d'Événements`, + `Conseiller Juridique`, + `Coordinateur de Formation`, ]; + // ---------------------------------------------------------------------- export const _postTitles = [ @@ -671,3 +672,24 @@ export const _descriptions = [ `Ipsam aliquam velit nobis repellendus officiis aut deserunt id et. Nihil sunt aut dolores aut. Dolores est ipsa quia et laborum quidem laborum accusamus id. Facilis odit quod hic laudantium saepe omnis nisi in sint. Sed cupiditate possimus id.`, `Magnam non eveniet optio optio ut aliquid atque. Velit libero aspernatur quis laborum consequatur laudantium. Tempora facere optio fugit accusantium ut. Omnis aspernatur reprehenderit autem esse ut ut enim voluptatibus.`, ]; + +export const _badges = [ + 'UI', + 'UX', + 'Html', + 'JavaScript', + 'TypeScript', + 'Communication', + 'Résolution de Problèmes', + 'Leadership', + 'Gestion du Temps', + 'Adaptabilité', + 'Collaboration', + 'Créativité', + 'Pensée Critique', + 'Compétences Techniques', + 'Service Client', + 'Gestion de Projet', + 'Diagnostic de Problèmes', +]; + diff --git a/src/shared/components/country-select/country-select.tsx b/src/shared/components/country-select/country-select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3552ccdf4b9583de1f034d06ad6f82c79a4476cc --- /dev/null +++ b/src/shared/components/country-select/country-select.tsx @@ -0,0 +1,152 @@ +import type { + AutocompleteProps, + AutocompleteRenderInputParams, + AutocompleteRenderGetTagProps, +} from '@mui/material/Autocomplete'; + +import Chip from '@mui/material/Chip'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; +import InputAdornment from '@mui/material/InputAdornment'; +import { filledInputClasses } from '@mui/material/FilledInput'; + +import { countries } from 'src/shared/assets/data'; + +import { FlagIcon, iconifyClasses } from 'src/shared/components/iconify'; + +import { getCountry, displayValueByCountryCode } from './utils'; + +// ---------------------------------------------------------------------- + +type Value = string; + +export type AutocompleteBaseProps = Omit< + AutocompleteProps<any, boolean, boolean, boolean>, + 'options' | 'renderOption' | 'renderInput' | 'renderTags' | 'getOptionLabel' +>; + +export type CountrySelectProps = AutocompleteBaseProps & { + label?: string; + error?: boolean; + placeholder?: string; + hiddenLabel?: boolean; + getValue?: 'label' | 'code'; + helperText?: React.ReactNode; +}; + +export function CountrySelect({ + id, + label, + error, + multiple, + helperText, + hiddenLabel, + placeholder, + getValue = 'label', + ...other +}: CountrySelectProps) { + const options = countries.map((country) => (getValue === 'label' ? country.label : country.code)); + + const renderOption = (props: React.HTMLAttributes<HTMLLIElement>, option: Value) => { + const country = getCountry(option); + + if (!country.label) { + return null; + } + + return ( + <li {...props} key={country.label}> + <FlagIcon + key={country.label} + code={country.code} + sx={{ mr: 1, width: 22, height: 22, borderRadius: '50%' }} + /> + {country.label} ({country.code}) +{country.phone} + </li> + ); + }; + + const renderInput = (params: AutocompleteRenderInputParams) => { + const country = getCountry(params.inputProps.value as Value); + + const baseField = { + ...params, + label, + placeholder, + helperText, + hiddenLabel, + error: !!error, + inputProps: { + ...params.inputProps, + autoComplete: 'new-password', + }, + }; + + if (multiple) { + return <TextField {...baseField} />; + } + + return ( + <TextField + {...baseField} + InputProps={{ + ...params.InputProps, + startAdornment: ( + <InputAdornment position="start" sx={{ ...(!country.code && { display: 'none' }) }}> + <FlagIcon + key={country.label} + code={country.code} + sx={{ ml: 0.5, mr: -0.5, width: 22, height: 22, borderRadius: '50%' }} + /> + </InputAdornment> + ), + }} + sx={{ + ...(!hiddenLabel && { + [`& .${filledInputClasses.root}`]: { [`& .${iconifyClasses.root}`]: { mt: -2 } }, + }), + }} + /> + ); + }; + + const renderTags = (selected: Value[], getTagProps: AutocompleteRenderGetTagProps) => + selected.map((option, index) => { + const country = getCountry(option); + + return ( + <Chip + {...getTagProps({ index })} + key={country.label} + label={country.label} + size="small" + variant="soft" + icon={ + <FlagIcon + key={country.label} + code={country.code} + sx={{ width: 16, height: 16, borderRadius: '50%' }} + /> + } + /> + ); + }); + + const getOptionLabel = (option: Value) => + getValue === 'label' ? option : displayValueByCountryCode(option); + + return ( + <Autocomplete + id={`country-select-${id}`} + multiple={multiple} + options={options} + autoHighlight={!multiple} + disableCloseOnSelect={multiple} + renderOption={renderOption} + renderInput={renderInput} + renderTags={multiple ? renderTags : undefined} + getOptionLabel={getOptionLabel} + {...other} + /> + ); +} diff --git a/src/shared/components/country-select/index.ts b/src/shared/components/country-select/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1634a4f2c09b90b2f59233a015d20821265ccaf --- /dev/null +++ b/src/shared/components/country-select/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; + +export * from './country-select'; diff --git a/src/shared/components/country-select/utils.ts b/src/shared/components/country-select/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..592b9f436ae70e85eba0ad2e0cbfe283461e0699 --- /dev/null +++ b/src/shared/components/country-select/utils.ts @@ -0,0 +1,19 @@ +import { countries } from 'src/shared/assets/data'; + +// ---------------------------------------------------------------------- + +export function getCountry(inputValue: string) { + const option = countries.filter( + (country) => country.label === inputValue || country.code === inputValue + )[0]; + + return { code: option?.code, label: option?.label, phone: option?.phone }; +} + +// ---------------------------------------------------------------------- + +export function displayValueByCountryCode(inputValue: string) { + const option = countries.filter((country) => country.code === inputValue)[0]; + + return option.label; +} diff --git a/src/shared/components/custom-popover copy/custom-popover.tsx b/src/shared/components/custom-popover copy/custom-popover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e85d75a8e78ef82025ac64dd7d8f87700c8a0e45 --- /dev/null +++ b/src/shared/components/custom-popover copy/custom-popover.tsx @@ -0,0 +1,64 @@ +import type { PaperProps } from '@mui/material/Paper'; + +import Popover from '@mui/material/Popover'; +import { listClasses } from '@mui/material/List'; +import { menuItemClasses } from '@mui/material/MenuItem'; + +import { StyledArrow } from './styles'; +import { calculateAnchorOrigin } from './utils'; + +import type { CustomPopoverProps } from './types'; + +// ---------------------------------------------------------------------- + +export function CustomPopover({ + open, + onClose, + children, + anchorEl, + slotProps, + ...other +}: CustomPopoverProps) { + const arrowPlacement = slotProps?.arrow?.placement ?? 'top-right'; + + const arrowSize = slotProps?.arrow?.size ?? 14; + + const arrowOffset = slotProps?.arrow?.offset ?? 17; + + const { paperStyles, anchorOrigin, transformOrigin } = calculateAnchorOrigin(arrowPlacement); + + return ( + <Popover + open={!!open} + anchorEl={anchorEl} + onClose={onClose} + anchorOrigin={anchorOrigin} + transformOrigin={transformOrigin} + slotProps={{ + ...slotProps, + paper: { + ...slotProps?.paper, + sx: { + ...paperStyles, + overflow: 'inherit', + [`& .${listClasses.root}`]: { minWidth: 140 }, + [`& .${menuItemClasses.root}`]: { gap: 2 }, + ...(slotProps?.paper as PaperProps)?.sx, + }, + }, + }} + {...other} + > + {!slotProps?.arrow?.hide && ( + <StyledArrow + sx={slotProps?.arrow?.sx} + placement={arrowPlacement} + offset={arrowOffset} + size={arrowSize} + /> + )} + + {children} + </Popover> + ); +} diff --git a/src/shared/components/custom-popover copy/index.ts b/src/shared/components/custom-popover copy/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa93d8e07d222f73ef0e5a1ea1647222b562716a --- /dev/null +++ b/src/shared/components/custom-popover copy/index.ts @@ -0,0 +1,5 @@ +export type * from './types'; + +export * from './use-popover'; + +export * from './custom-popover'; diff --git a/src/shared/components/custom-popover copy/styles.tsx b/src/shared/components/custom-popover copy/styles.tsx new file mode 100644 index 0000000000000000000000000000000000000000..921f2554b20e24842d1634ffb78fbed770ce58dc --- /dev/null +++ b/src/shared/components/custom-popover copy/styles.tsx @@ -0,0 +1,115 @@ +import { styled } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha, stylesMode } from 'src/shared/theme/styles'; + +import type { PopoverArrow } from './types'; + +// ---------------------------------------------------------------------- + +export const StyledArrow = styled('span', { + shouldForwardProp: (prop) => prop !== 'size' && prop !== 'placement' && prop !== 'offset', +})<PopoverArrow>(({ placement, offset = 0, size = 0, theme }) => { + const POSITION = -(size / 2) + 0.5; + + const alignmentStyles = { + top: { top: POSITION, transform: 'rotate(135deg)' }, + bottom: { bottom: POSITION, transform: 'rotate(-45deg)' }, + left: { left: POSITION, transform: 'rotate(45deg)' }, + right: { right: POSITION, transform: 'rotate(-135deg)' }, + hCenter: { left: 0, right: 0, margin: 'auto' }, + vCenter: { top: 0, bottom: 0, margin: 'auto' }, + }; + + const backgroundStyles = (color: 'cyan' | 'red') => ({ + backgroundRepeat: 'no-repeat', + backgroundSize: `${size * 3}px ${size * 3}px`, + backgroundImage: `url(${CONFIG.site.basePath}/assets/${color}-blur.png)`, + ...(color === 'cyan' && { + backgroundPosition: 'top right', + }), + ...(color === 'red' && { + backgroundPosition: 'bottom left', + }), + }); + + return { + width: size, + height: size, + position: 'absolute', + backdropFilter: '6px', + borderBottomLeftRadius: size / 4, + clipPath: 'polygon(0% 0%, 100% 100%, 0% 100%)', + backgroundColor: theme.vars.palette.background.paper, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + [stylesMode.dark]: { + border: `solid 1px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + }, + /** + * Top + */ + ...(placement === 'top-left' && { + ...alignmentStyles.top, + left: offset, + }), + ...(placement === 'top-center' && { + ...alignmentStyles.top, + ...alignmentStyles.hCenter, + }), + ...(placement === 'top-right' && { + ...backgroundStyles('cyan'), + ...alignmentStyles.top, + right: offset, + }), + /** + * Bottom + */ + ...(placement === 'bottom-left' && { + ...backgroundStyles('red'), + ...alignmentStyles.bottom, + left: offset, + }), + ...(placement === 'bottom-center' && { + ...alignmentStyles.bottom, + ...alignmentStyles.hCenter, + }), + ...(placement === 'bottom-right' && { + ...alignmentStyles.bottom, + right: offset, + }), + /** + * Left + */ + ...(placement === 'left-top' && { + ...alignmentStyles.left, + top: offset, + }), + ...(placement === 'left-center' && { + ...backgroundStyles('red'), + ...alignmentStyles.left, + ...alignmentStyles.vCenter, + }), + ...(placement === 'left-bottom' && { + ...backgroundStyles('red'), + ...alignmentStyles.left, + bottom: offset, + }), + /** + * Right + */ + ...(placement === 'right-top' && { + ...backgroundStyles('cyan'), + ...alignmentStyles.right, + top: offset, + }), + ...(placement === 'right-center' && { + ...backgroundStyles('cyan'), + ...alignmentStyles.right, + ...alignmentStyles.vCenter, + }), + ...(placement === 'right-bottom' && { + ...alignmentStyles.right, + bottom: offset, + }), + }; +}); diff --git a/src/shared/components/custom-popover copy/types.ts b/src/shared/components/custom-popover copy/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e75487cebed5860c68c2844cde0aec3c2663d2e4 --- /dev/null +++ b/src/shared/components/custom-popover copy/types.ts @@ -0,0 +1,38 @@ +import type { PopoverProps } from '@mui/material/Popover'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type PopoverArrow = { + hide?: boolean; + size?: number; + offset?: number; + sx?: SxProps<Theme>; + placement?: + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' + | 'left-top' + | 'left-center' + | 'left-bottom' + | 'right-top' + | 'right-center' + | 'right-bottom'; +}; + +export type UsePopoverReturn = { + open: PopoverProps['open']; + anchorEl: PopoverProps['anchorEl']; + onClose: () => void; + onOpen: (event: React.MouseEvent<HTMLElement>) => void; + setAnchorEl: React.Dispatch<React.SetStateAction<PopoverProps['anchorEl']>>; +}; + +export type CustomPopoverProps = PopoverProps & { + slotProps?: PopoverProps['slotProps'] & { + arrow?: PopoverArrow; + }; +}; diff --git a/src/shared/components/custom-popover copy/use-popover.ts b/src/shared/components/custom-popover copy/use-popover.ts new file mode 100644 index 0000000000000000000000000000000000000000..31de5f6bb3a477a854c81c0bab63666f27fa5e17 --- /dev/null +++ b/src/shared/components/custom-popover copy/use-popover.ts @@ -0,0 +1,27 @@ +import type { PopoverProps } from '@mui/material/Popover'; + +import { useState, useCallback } from 'react'; + +import type { UsePopoverReturn } from './types'; + +// ---------------------------------------------------------------------- + +export function usePopover(): UsePopoverReturn { + const [anchorEl, setAnchorEl] = useState<PopoverProps['anchorEl']>(null); + + const onOpen = useCallback((event: React.MouseEvent<PopoverProps['anchorEl']>) => { + setAnchorEl(event.currentTarget); + }, []); + + const onClose = useCallback(() => { + setAnchorEl(null); + }, []); + + return { + open: !!anchorEl, + anchorEl, + onOpen, + onClose, + setAnchorEl, + }; +} diff --git a/src/shared/components/custom-popover copy/utils.ts b/src/shared/components/custom-popover copy/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca4eefd0cb03e8c0692f59d5339511532012a095 --- /dev/null +++ b/src/shared/components/custom-popover copy/utils.ts @@ -0,0 +1,129 @@ +import type { CSSObject } from '@mui/material/styles'; +import type { PopoverOrigin } from '@mui/material/Popover'; + +import type { PopoverArrow } from './types'; + +// ---------------------------------------------------------------------- + +const POPOVER_DISTANCE = 0.75; + +export type CalculateAnchorOriginProps = { + paperStyles?: CSSObject; + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; +}; + +export function calculateAnchorOrigin( + arrow: PopoverArrow['placement'] +): CalculateAnchorOriginProps { + let props: CalculateAnchorOriginProps; + + switch (arrow) { + /** + * top-* + */ + case 'top-left': + props = { + paperStyles: { ml: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, + transformOrigin: { vertical: 'top', horizontal: 'left' }, + }; + break; + case 'top-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'bottom', horizontal: 'center' }, + transformOrigin: { vertical: 'top', horizontal: 'center' }, + }; + break; + case 'top-right': + props = { + paperStyles: { ml: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + transformOrigin: { vertical: 'top', horizontal: 'right' }, + }; + break; + /** + * bottom-* + */ + case 'bottom-left': + props = { + paperStyles: { ml: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'left' }, + transformOrigin: { vertical: 'bottom', horizontal: 'left' }, + }; + break; + case 'bottom-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'top', horizontal: 'center' }, + transformOrigin: { vertical: 'bottom', horizontal: 'center' }, + }; + break; + case 'bottom-right': + props = { + paperStyles: { ml: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + transformOrigin: { vertical: 'bottom', horizontal: 'right' }, + }; + break; + /** + * left-* + */ + case 'left-top': + props = { + paperStyles: { mt: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + transformOrigin: { vertical: 'top', horizontal: 'left' }, + }; + break; + case 'left-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'center', horizontal: 'right' }, + transformOrigin: { vertical: 'center', horizontal: 'left' }, + }; + break; + case 'left-bottom': + props = { + paperStyles: { mt: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + transformOrigin: { vertical: 'bottom', horizontal: 'left' }, + }; + break; + /** + * right-* + */ + case 'right-top': + props = { + paperStyles: { mt: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'left' }, + transformOrigin: { vertical: 'top', horizontal: 'right' }, + }; + break; + case 'right-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'center', horizontal: 'left' }, + transformOrigin: { vertical: 'center', horizontal: 'right' }, + }; + break; + case 'right-bottom': + props = { + paperStyles: { mt: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, + transformOrigin: { vertical: 'bottom', horizontal: 'right' }, + }; + break; + + // top-right + default: + props = { + paperStyles: { ml: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + transformOrigin: { vertical: 'top', horizontal: 'right' }, + }; + } + + return props; +} diff --git a/src/shared/components/editor/classes.ts b/src/shared/components/editor/classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff6f8bb00a20bec360be731298a733bd1c4af52f --- /dev/null +++ b/src/shared/components/editor/classes.ts @@ -0,0 +1,44 @@ +// ---------------------------------------------------------------------- + +export const editorClasses = { + root: 'nml__editor__root', + toolbar: { + hr: 'nml__editor__toolbar__hr', + root: 'nml__editor__toolbar__root', + bold: 'nml__editor__toolbar__bold', + code: 'nml__editor__toolbar__code', + undo: 'nml__editor__toolbar__undo', + redo: 'nml__editor__toolbar__redo', + link: 'nml__editor__toolbar__link', + clear: 'nml__editor__toolbar__clear', + image: 'nml__editor__toolbar__image', + italic: 'nml__editor__toolbar__italic', + strike: 'nml__editor__toolbar__strike', + hardbreak: 'nml__editor__toolbar__hardbreak', + unsetlink: 'nml__editor__toolbar__unsetlink', + codeBlock: 'nml__editor__toolbar__code__block', + alignLeft: 'nml__editor__toolbar__align__left', + fullscreen: 'nml__editor__toolbar__fullscreen', + blockquote: 'nml__editor__toolbar__blockquote', + bulletList: 'nml__editor__toolbar__bullet__list', + alignRight: 'nml__editor__toolbar__align__right', + orderedList: 'nml__editor__toolbar__ordered__list', + alignCenter: 'nml__editor__toolbar__align__center', + alignJustify: 'nml__editor__toolbar__align__justify', + }, + content: { + hr: 'nml__editor__content__hr', + root: 'nml__editor__content__root', + link: 'nml__editor__content__link', + image: 'nml__editor__content__image', + codeInline: 'nml__editor__content__code', + heading: 'nml__editor__content__heading', + listItem: 'nml__editor__content__listItem', + codeBlock: 'nml__editor__content__code__block', + blockquote: 'nml__editor__content__blockquote', + langSelect: 'nml__editor__content__lang__select', + placeholder: 'nml__editor__content__placeholder', + bulletList: 'nml__editor__content__bullet__list', + orderedList: 'nml__editor__content__ordered__list', + }, +}; diff --git a/src/shared/components/editor/components/code-highlight-block.css b/src/shared/components/editor/components/code-highlight-block.css new file mode 100644 index 0000000000000000000000000000000000000000..82f6af8108b65587e62ed51e43e620ffa90b612e --- /dev/null +++ b/src/shared/components/editor/components/code-highlight-block.css @@ -0,0 +1,82 @@ +pre { + code[as='code'] { + .hljs-comment { + color: #999; + } + .hljs-tag { + color: #b4b7b4; + } + .hljs-operator, + .hljs-punctuation, + .hljs-subst { + color: #ccc; + } + .hljs-operator { + opacity: 0.7; + } + .hljs-bullet, + .hljs-deletion, + .hljs-name, + .hljs-selector-tag, + .hljs-template-variable, + .hljs-variable { + color: #f2777a; + } + .hljs-attr, + .hljs-link, + .hljs-literal, + .hljs-number, + .hljs-symbol, + .hljs-variable.constant_ { + color: #f99157; + } + .hljs-class .hljs-title, + .hljs-title, + .hljs-title.class_ { + color: #fc6; + } + .hljs-strong { + font-weight: 700; + color: #fc6; + } + .hljs-addition, + .hljs-code, + .hljs-string, + .hljs-title.class_.inherited__ { + color: #9c9; + } + .hljs-built_in, + .hljs-doctag, + .hljs-keyword.hljs-atrule, + .hljs-quote, + .hljs-regexp { + color: #6cc; + } + .hljs-attribute, + .hljs-function .hljs-title, + .hljs-section, + .hljs-title.function_, + .ruby .hljs-property { + color: #69c; + } + .diff .hljs-meta, + .hljs-keyword, + .hljs-template-tag, + .hljs-type { + color: #c9c; + } + .hljs-emphasis { + color: #c9c; + font-style: italic; + } + .hljs-meta, + .hljs-meta .hljs-keyword, + .hljs-meta .hljs-string { + color: #a3685a; + } + .hljs-meta .hljs-keyword, + .hljs-meta-keyword { + font-weight: 700; + } + } +} diff --git a/src/shared/components/editor/components/code-highlight-block.tsx b/src/shared/components/editor/components/code-highlight-block.tsx new file mode 100644 index 0000000000000000000000000000000000000000..828d2f47824e65a77361f4e4a084903327a70e6f --- /dev/null +++ b/src/shared/components/editor/components/code-highlight-block.tsx @@ -0,0 +1,41 @@ +import './code-highlight-block.css'; + +import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'; + +import { editorClasses } from '../classes'; + +import type { EditorCodeHighlightBlockProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CodeHighlightBlock({ + node: { + attrs: { language: defaultLanguage }, + }, + extension, + updateAttributes, +}: EditorCodeHighlightBlockProps) { + return ( + <NodeViewWrapper className={editorClasses.content.codeBlock}> + <select + name="language" + contentEditable={false} + defaultValue={defaultLanguage} + onChange={(event) => updateAttributes({ language: event.target.value })} + className={editorClasses.content.langSelect} + > + <option value="null">auto</option> + <option disabled>—</option> + {extension.options.lowlight.listLanguages().map((lang: string) => ( + <option key={lang} value={lang}> + {lang} + </option> + ))} + </select> + + <pre> + <NodeViewContent as="code" /> + </pre> + </NodeViewWrapper> + ); +} diff --git a/src/shared/components/editor/components/heading-block.tsx b/src/shared/components/editor/components/heading-block.tsx new file mode 100644 index 0000000000000000000000000000000000000000..699631e973833058a868ef04f5543aeaa9e6e8c5 --- /dev/null +++ b/src/shared/components/editor/components/heading-block.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; + +import Menu from '@mui/material/Menu'; +import { listClasses } from '@mui/material/List'; +import ButtonBase, { buttonBaseClasses } from '@mui/material/ButtonBase'; + +import { varAlpha } from 'src/shared/theme/styles'; + +import { Iconify } from '../../iconify'; +import { ToolbarItem } from './toolbar-item'; + +import type { EditorToolbarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const HEADING_OPTIONS = [ + 'Heading 1', + 'Heading 2', + 'Heading 3', + 'Heading 4', + 'Heading 5', + 'Heading 6', +]; + +export function HeadingBlock({ editor }: Pick<EditorToolbarProps, 'editor'>) { + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + if (!editor) { + return null; + } + + return ( + <> + <ButtonBase + id="heading-menu-button" + aria-label="Heading menu button" + aria-controls={anchorEl ? 'heading-menu-button' : undefined} + aria-haspopup="true" + aria-expanded={anchorEl ? 'true' : undefined} + onClick={handleClick} + sx={{ + px: 1, + width: 120, + height: 32, + borderRadius: 0.75, + typography: 'body2', + justifyContent: 'space-between', + border: (theme) => `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + }} + > + {(editor.isActive('heading', { level: 1 }) && 'Heading 1') || + (editor.isActive('heading', { level: 2 }) && 'Heading 2') || + (editor.isActive('heading', { level: 3 }) && 'Heading 3') || + (editor.isActive('heading', { level: 4 }) && 'Heading 4') || + (editor.isActive('heading', { level: 5 }) && 'Heading 5') || + (editor.isActive('heading', { level: 6 }) && 'Heading 6') || + 'Paragraph'} + + <Iconify + width={16} + icon={anchorEl ? 'eva:arrow-ios-upward-fill' : 'eva:arrow-ios-downward-fill'} + /> + </ButtonBase> + + <Menu + id="heading-menu" + anchorEl={anchorEl} + open={!!anchorEl} + onClose={handleClose} + MenuListProps={{ 'aria-labelledby': 'heading-button' }} + slotProps={{ + paper: { + sx: { + width: 120, + [`& .${listClasses.root}`]: { gap: 0.5, display: 'flex', flexDirection: 'column' }, + [`& .${buttonBaseClasses.root}`]: { + px: 1, + width: 1, + height: 34, + borderRadius: 0.75, + justifyContent: 'flex-start', + '&:hover': { backgroundColor: 'action.hover' }, + }, + }, + }, + }} + > + <ToolbarItem + component="li" + label="Paragraph" + active={editor.isActive('paragraph')} + onClick={() => { + handleClose(); + editor.chain().focus().setParagraph().run(); + }} + /> + + {HEADING_OPTIONS.map((heading, index) => { + const level = (index + 1) as HeadingLevel; + + return ( + <ToolbarItem + aria-label={heading} + component="li" + key={heading} + label={heading} + active={editor.isActive('heading', { level })} + onClick={() => { + handleClose(); + editor.chain().focus().toggleHeading({ level }).run(); + }} + sx={{ + ...(heading !== 'Paragraph' && { + fontSize: 18 - index, + fontWeight: 'fontWeightBold', + }), + }} + /> + ); + })} + </Menu> + </> + ); +} diff --git a/src/shared/components/editor/components/image-block.tsx b/src/shared/components/editor/components/image-block.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6a16042cac99d7f01f9af41fad8b750a4cff06f --- /dev/null +++ b/src/shared/components/editor/components/image-block.tsx @@ -0,0 +1,81 @@ +import { useState, useCallback } from 'react'; + +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Popover from '@mui/material/Popover'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { editorClasses } from '../classes'; +import { ToolbarItem } from './toolbar-item'; + +import type { EditorToolbarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function ImageBlock({ editor }: Pick<EditorToolbarProps, 'editor'>) { + const [url, setUrl] = useState(''); + + const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); + + const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget); + }; + + const handleClosePopover = () => { + setAnchorEl(null); + }; + + const handleUpdateUrl = useCallback(() => { + handleClosePopover(); + + if (anchorEl) { + editor?.chain().focus().setImage({ src: url }).run(); + } + }, [anchorEl, editor, url]); + + if (!editor) { + return null; + } + + return ( + <> + <ToolbarItem + aria-label="Image" + className={editorClasses.toolbar.image} + onClick={handleOpenPopover} + icon={ + <path d="M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z" /> + } + /> + + <Popover + id={anchorEl ? 'simple-popover' : undefined} + open={!!anchorEl} + anchorEl={anchorEl} + onClose={handleClosePopover} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { p: 2.5 } } }} + > + <Typography variant="subtitle2" sx={{ mb: 1 }}> + URL + </Typography> + + <Stack direction="row" alignItems="center" spacing={1}> + <TextField + size="small" + placeholder="Enter URL here..." + value={url} + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + setUrl(event.target.value); + }} + sx={{ width: 240 }} + /> + <Button variant="contained" onClick={handleUpdateUrl}> + Apply + </Button> + </Stack> + </Popover> + </> + ); +} diff --git a/src/shared/components/editor/components/link-block.tsx b/src/shared/components/editor/components/link-block.tsx new file mode 100644 index 0000000000000000000000000000000000000000..254c25c7042131b1cde7f3872527a5ea01af9596 --- /dev/null +++ b/src/shared/components/editor/components/link-block.tsx @@ -0,0 +1,101 @@ +import { useState, useCallback } from 'react'; + +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Popover from '@mui/material/Popover'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { editorClasses } from '../classes'; +import { ToolbarItem } from './toolbar-item'; + +import type { EditorToolbarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function LinkBlock({ editor }: Pick<EditorToolbarProps, 'editor'>) { + const [url, setUrl] = useState(''); + + const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); + + const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { + const previousUrl = editor?.getAttributes('link').href; + + setAnchorEl(event.currentTarget); + + if (previousUrl) { + setUrl(previousUrl); + } else { + setUrl(''); + } + }; + + const handleClosePopover = () => { + setAnchorEl(null); + }; + + const handleUpdateUrl = useCallback(() => { + handleClosePopover(); + + if (!url) { + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); + } else { + editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + } + }, [editor, url]); + + if (!editor) { + return null; + } + + return ( + <> + <ToolbarItem + aria-label="Link" + active={editor.isActive('link')} + className={editorClasses.toolbar.link} + onClick={handleOpenPopover} + icon={ + <path d="M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z" /> + } + /> + <ToolbarItem + aria-label="Unset link" + disabled={!editor.isActive('link')} + className={editorClasses.toolbar.unsetlink} + onClick={() => editor.chain().focus().unsetLink().run()} + icon={ + <path d="M17.657 14.8284L16.2428 13.4142L17.657 12C19.2191 10.4379 19.2191 7.90526 17.657 6.34316C16.0949 4.78106 13.5622 4.78106 12.0001 6.34316L10.5859 7.75737L9.17171 6.34316L10.5859 4.92895C12.9291 2.5858 16.7281 2.5858 19.0712 4.92895C21.4143 7.27209 21.4143 11.0711 19.0712 13.4142L17.657 14.8284ZM14.8286 17.6569L13.4143 19.0711C11.0712 21.4142 7.27221 21.4142 4.92907 19.0711C2.58592 16.7279 2.58592 12.9289 4.92907 10.5858L6.34328 9.17159L7.75749 10.5858L6.34328 12C4.78118 13.5621 4.78118 16.0948 6.34328 17.6569C7.90538 19.219 10.438 19.219 12.0001 17.6569L13.4143 16.2427L14.8286 17.6569ZM14.8286 7.75737L16.2428 9.17159L9.17171 16.2427L7.75749 14.8284L14.8286 7.75737ZM5.77539 2.29291L7.70724 1.77527L8.74252 5.63897L6.81067 6.15661L5.77539 2.29291ZM15.2578 18.3611L17.1896 17.8434L18.2249 21.7071L16.293 22.2248L15.2578 18.3611ZM2.29303 5.77527L6.15673 6.81054L5.63909 8.7424L1.77539 7.70712L2.29303 5.77527ZM18.3612 15.2576L22.2249 16.2929L21.7072 18.2248L17.8435 17.1895L18.3612 15.2576Z" /> + } + /> + + <Popover + id={anchorEl ? 'simple-popover' : undefined} + open={!!anchorEl} + anchorEl={anchorEl} + onClose={handleClosePopover} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { p: 2.5 } } }} + > + <Typography variant="subtitle2" sx={{ mb: 1 }}> + URL + </Typography> + + <Stack direction="row" alignItems="center" spacing={1}> + <TextField + size="small" + placeholder="Enter URL here..." + value={url} + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + setUrl(event.target.value); + }} + sx={{ width: 240 }} + /> + <Button variant="contained" onClick={handleUpdateUrl}> + Apply + </Button> + </Stack> + </Popover> + </> + ); +} diff --git a/src/shared/components/editor/components/toolbar-item.tsx b/src/shared/components/editor/components/toolbar-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c84d086f26b0d6e4c85485c93e31aa6dad9c9b46 --- /dev/null +++ b/src/shared/components/editor/components/toolbar-item.tsx @@ -0,0 +1,38 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import SvgIcon from '@mui/material/SvgIcon'; +import ButtonBase from '@mui/material/ButtonBase'; + +import type { EditorToolbarItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function ToolbarItem({ + sx, + icon, + label, + active, + disabled, + ...other +}: ButtonBaseProps & EditorToolbarItemProps) { + return ( + <ButtonBase + sx={{ + px: 0.75, + width: 28, + height: 28, + borderRadius: 0.75, + typography: 'body2', + '&:hover': { bgcolor: 'action.hover' }, + ...(active && { bgcolor: 'action.selected' }), + ...(disabled && { pointerEvents: 'none', cursor: 'not-allowed', opacity: 0.48 }), + ...sx, + }} + {...other} + > + {icon && <SvgIcon sx={{ fontSize: 18 }}>{icon}</SvgIcon>} + + {label && label} + </ButtonBase> + ); +} diff --git a/src/shared/components/editor/editor.tsx b/src/shared/components/editor/editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2f1d94201e4c82a6f3a634553d0f1d0f267097e3 --- /dev/null +++ b/src/shared/components/editor/editor.tsx @@ -0,0 +1,149 @@ +import { common, createLowlight } from 'lowlight'; +import LinkExtension from '@tiptap/extension-link'; +import ImageExtension from '@tiptap/extension-image'; +import StarterKitExtension from '@tiptap/starter-kit'; +import TextAlignExtension from '@tiptap/extension-text-align'; +import PlaceholderExtension from '@tiptap/extension-placeholder'; +import { useState, useEffect, forwardRef, useCallback } from 'react'; +import CodeBlockLowlightExtension from '@tiptap/extension-code-block-lowlight'; +import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'; + +import Stack from '@mui/material/Stack'; +import Portal from '@mui/material/Portal'; +import Backdrop from '@mui/material/Backdrop'; +import FormHelperText from '@mui/material/FormHelperText'; + +import { Toolbar } from './toolbar'; +import { StyledRoot } from './styles'; +import { editorClasses } from './classes'; +import { CodeHighlightBlock } from './components/code-highlight-block'; + +import type { EditorProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Editor = forwardRef<HTMLDivElement, EditorProps>( + ( + { + sx, + error, + onChange, + slotProps, + helperText, + resetValue, + editable = true, + fullItem = false, + value: content = '', + placeholder = 'Write something awesome...', + ...other + }, + ref + ) => { + const [fullScreen, setFullScreen] = useState(false); + + const handleToggleFullScreen = useCallback(() => { + setFullScreen((prev) => !prev); + }, []); + + const lowlight = createLowlight(common); + + const editor = useEditor({ + content, + editable, + extensions: [ + StarterKitExtension.configure({ + codeBlock: false, + code: { HTMLAttributes: { class: editorClasses.content.codeInline } }, + heading: { HTMLAttributes: { class: editorClasses.content.heading } }, + horizontalRule: { HTMLAttributes: { class: editorClasses.content.hr } }, + listItem: { HTMLAttributes: { class: editorClasses.content.listItem } }, + blockquote: { HTMLAttributes: { class: editorClasses.content.blockquote } }, + bulletList: { HTMLAttributes: { class: editorClasses.content.bulletList } }, + orderedList: { HTMLAttributes: { class: editorClasses.content.orderedList } }, + }), + PlaceholderExtension.configure({ + placeholder, + emptyEditorClass: editorClasses.content.placeholder, + }), + ImageExtension.configure({ HTMLAttributes: { class: editorClasses.content.image } }), + TextAlignExtension.configure({ types: ['heading', 'paragraph'] }), + LinkExtension.configure({ + autolink: true, + openOnClick: false, + HTMLAttributes: { class: editorClasses.content.link }, + }), + CodeBlockLowlightExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeHighlightBlock); + }, + }).configure({ lowlight, HTMLAttributes: { class: editorClasses.content.codeBlock } }), + ], + onUpdate({ editor: _editor }) { + const html = _editor.getHTML(); + onChange?.(html); + }, + ...other, + }); + + useEffect(() => { + const timer = setTimeout(() => { + if (editor?.isEmpty && content !== '<p></p>') { + editor.commands.setContent(content); + } + }, 100); + return () => clearTimeout(timer); + }, [content, editor]); + + useEffect(() => { + if (resetValue && !content) { + editor?.commands.clearContent(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content]); + + useEffect(() => { + if (fullScreen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + }, [fullScreen]); + + return ( + <Portal disablePortal={!fullScreen}> + {fullScreen && <Backdrop open sx={{ zIndex: (theme) => theme.zIndex.modal - 1 }} />} + + <Stack sx={{ ...(!editable && { cursor: 'not-allowed' }), ...slotProps?.wrap }}> + <StyledRoot + error={!!error} + disabled={!editable} + fullScreen={fullScreen} + className={editorClasses.root} + sx={sx} + > + <Toolbar + editor={editor} + fullItem={fullItem} + fullScreen={fullScreen} + onToggleFullScreen={handleToggleFullScreen} + /> + <EditorContent + ref={ref} + spellCheck="false" + autoComplete="off" + autoCapitalize="off" + editor={editor} + className={editorClasses.content.root} + /> + </StyledRoot> + + {helperText && ( + <FormHelperText error={!!error} sx={{ px: 2 }}> + {helperText} + </FormHelperText> + )} + </Stack> + </Portal> + ); + } +); diff --git a/src/shared/components/editor/index.ts b/src/shared/components/editor/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..79f9fdf76ce2b463ade9ed58a4181e557c8c6ee4 --- /dev/null +++ b/src/shared/components/editor/index.ts @@ -0,0 +1,5 @@ +export * from './editor'; + +export * from './classes'; + +export type * from './types'; diff --git a/src/shared/components/editor/styles.tsx b/src/shared/components/editor/styles.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5c46591f567b7f02bbf3f616b7776683f5831ec --- /dev/null +++ b/src/shared/components/editor/styles.tsx @@ -0,0 +1,212 @@ +import type { StackProps } from '@mui/material/Stack'; + +import Stack from '@mui/material/Stack'; +import { styled } from '@mui/material/styles'; + +import { varAlpha } from 'src/shared/theme/styles'; + +import { editorClasses } from './classes'; + +// ---------------------------------------------------------------------- + +const MARGIN = '0.75em'; + +type StyledRootProps = StackProps & { + error?: boolean; + disabled?: boolean; + fullScreen?: boolean; +}; +export const StyledRoot = styled(Stack, { + shouldForwardProp: (prop) => prop !== 'error' && prop !== 'disabled' && prop !== 'fullScreen', +})<StyledRootProps>(({ error, disabled, fullScreen, theme }) => ({ + minHeight: 240, + borderRadius: theme.shape.borderRadius, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + scrollbarWidth: 'thin', + scrollbarColor: `${varAlpha(theme.vars.palette.text.disabledChannel, 0.4)} ${varAlpha(theme.vars.palette.text.disabledChannel, 0.08)}`, + /** + * State: error + */ + ...(error && { + border: `solid 1px ${theme.vars.palette.error.main}`, + }), + /** + * State: disabled + */ + ...(disabled && { + opacity: 0.48, + pointerEvents: 'none', + }), + /** + * State: fullScreen + */ + ...(fullScreen && { + top: 16, + left: 16, + position: 'fixed', + zIndex: theme.zIndex.modal, + maxHeight: 'unset !important', + width: `calc(100% - ${32}px)`, + height: `calc(100% - ${32}px)`, + backgroundColor: theme.vars.palette.background.default, + }), + /** + * Placeholder + */ + [`& .${editorClasses.content.placeholder}`]: { + '&:first-of-type::before': { + ...theme.typography.body2, + height: 0, + float: 'left', + pointerEvents: 'none', + content: 'attr(data-placeholder)', + color: theme.vars.palette.text.disabled, + }, + }, + /** + * Content + */ + [`& .${editorClasses.content.root}`]: { + display: 'flex', + flex: '1 1 auto', + overflowY: 'auto', + flexDirection: 'column', + borderBottomLeftRadius: 'inherit', + borderBottomRightRadius: 'inherit', + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + ...(error && { + backgroundColor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), + }), + '& .tiptap': { + '> * + *': { + marginTop: 0, + marginBottom: MARGIN, + }, + '&.ProseMirror': { + flex: '1 1 auto', + outline: 'none', + padding: theme.spacing(0, 2), + }, + /** + * Heading & Paragraph + */ + h1: { ...theme.typography.h1, marginTop: 40, marginBottom: 8 }, + h2: { ...theme.typography.h2, marginTop: 40, marginBottom: 8 }, + h3: { ...theme.typography.h3, marginTop: 24, marginBottom: 8 }, + h4: { ...theme.typography.h4, marginTop: 24, marginBottom: 8 }, + h5: { ...theme.typography.h5, marginTop: 24, marginBottom: 8 }, + h6: { ...theme.typography.h6, marginTop: 24, marginBottom: 8 }, + p: { ...theme.typography.body1, marginBottom: '1.25rem' }, + [`& .${editorClasses.content.heading}`]: {}, + /** + * Link + */ + [`& .${editorClasses.content.link}`]: { + color: theme.vars.palette.primary.main, + }, + /** + * Hr Divider + */ + [`& .${editorClasses.content.hr}`]: { + flexShrink: 0, + borderWidth: 0, + margin: '2em 0', + msFlexNegative: 0, + WebkitFlexShrink: 0, + borderStyle: 'solid', + borderBottomWidth: 'thin', + borderColor: theme.vars.palette.divider, + }, + /** + * Image + */ [`& .${editorClasses.content.image}`]: { + width: '100%', + height: 'auto', + maxWidth: '100%', + margin: 'auto auto 1.25em', + }, + /** + * List + */ [`& .${editorClasses.content.bulletList}`]: { + paddingLeft: 16, + listStyleType: 'disc', + }, + [`& .${editorClasses.content.orderedList}`]: { + paddingLeft: 16, + }, + [`& .${editorClasses.content.listItem}`]: { + lineHeight: 2, + '& > p': { margin: 0, display: 'inline-block' }, + }, + /** + * Blockquote + */ + [`& .${editorClasses.content.blockquote}`]: { + lineHeight: 1.5, + fontSize: '1.5em', + margin: '24px auto', + position: 'relative', + fontFamily: 'Georgia, serif', + padding: theme.spacing(3, 3, 3, 8), + color: theme.vars.palette.text.secondary, + borderLeft: `solid 8px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + [theme.breakpoints.up('md')]: { + width: '100%', + maxWidth: 640, + }, + '& p': { + margin: 0, + fontSize: 'inherit', + fontFamily: 'inherit', + }, + '&::before': { + left: 16, + top: -8, + display: 'block', + fontSize: '3em', + content: '"\\201C"', + position: 'absolute', + color: theme.vars.palette.text.disabled, + }, + }, + /** + * Code inline + */ + [`& .${editorClasses.content.codeInline}`]: { + padding: theme.spacing(0.25, 0.5), + color: theme.vars.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + borderRadius: theme.shape.borderRadius / 2, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + }, + /** + * Code block + */ + [`& .${editorClasses.content.codeBlock}`]: { + position: 'relative', + '& pre': { + overflowX: 'auto', + color: theme.vars.palette.common.white, + padding: theme.spacing(5, 3, 3, 3), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.vars.palette.grey[900], + fontFamily: "'JetBrainsMono', monospace", + '& code': { fontSize: theme.typography.body2.fontSize }, + }, + [`& .${editorClasses.content.langSelect}`]: { + top: 8, + right: 8, + zIndex: 1, + padding: 4, + outline: 'none', + borderRadius: 4, + position: 'absolute', + color: theme.vars.palette.common.white, + fontWeight: theme.typography.fontWeightMedium, + borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }, + }, + }, + }, +})); diff --git a/src/shared/components/editor/toolbar.tsx b/src/shared/components/editor/toolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..47f11d44cb6f16f24769206e5644b62941ca7034 --- /dev/null +++ b/src/shared/components/editor/toolbar.tsx @@ -0,0 +1,238 @@ +import Stack from '@mui/material/Stack'; +import Divider from '@mui/material/Divider'; + +import { varAlpha } from 'src/shared/theme/styles'; + +import { editorClasses } from './classes'; +import { LinkBlock } from './components/link-block'; +import { ImageBlock } from './components/image-block'; +import { ToolbarItem } from './components/toolbar-item'; +import { HeadingBlock } from './components/heading-block'; + +import type { EditorToolbarProps } from './types'; + +// ---------------------------------------------------------------------- + +/** + * https://remixicon.com + */ + +export function Toolbar({ editor, fullItem, fullScreen, onToggleFullScreen }: EditorToolbarProps) { + if (!editor) { + return null; + } + + return ( + <Stack + spacing={1} + direction="row" + flexWrap="wrap" + alignItems="center" + divider={<Divider orientation="vertical" flexItem sx={{ height: 16, my: 'auto' }} />} + className={editorClasses.toolbar.root} + sx={{ + p: 1.25, + bgcolor: 'background.paper', + borderTopRightRadius: 'inherit', + borderTopLeftRadius: 'inherit', + borderBottom: (theme) => + `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + }} + > + <HeadingBlock editor={editor} /> + + {/* Text style */} + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Bold" + active={editor.isActive('bold')} + className={editorClasses.toolbar.bold} + onClick={() => editor.chain().focus().toggleBold().run()} + icon={ + <path d="M8 11H12.5C13.8807 11 15 9.88071 15 8.5C15 7.11929 13.8807 6 12.5 6H8V11ZM18 15.5C18 17.9853 15.9853 20 13.5 20H6V4H12.5C14.9853 4 17 6.01472 17 8.5C17 9.70431 16.5269 10.7981 15.7564 11.6058C17.0979 12.3847 18 13.837 18 15.5ZM8 13V18H13.5C14.8807 18 16 16.8807 16 15.5C16 14.1193 14.8807 13 13.5 13H8Z" /> + } + /> + <ToolbarItem + aria-label="Italic" + active={editor.isActive('italic')} + className={editorClasses.toolbar.italic} + onClick={() => editor.chain().focus().toggleItalic().run()} + icon={<path d="M15 20H7V18H9.92661L12.0425 6H9V4H17V6H14.0734L11.9575 18H15V20Z" />} + /> + <ToolbarItem + aria-label="Strike" + active={editor.isActive('strike')} + className={editorClasses.toolbar.italic} + onClick={() => editor.chain().focus().toggleStrike().run()} + icon={ + <path d="M17.1538 14C17.3846 14.5161 17.5 15.0893 17.5 15.7196C17.5 17.0625 16.9762 18.1116 15.9286 18.867C14.8809 19.6223 13.4335 20 11.5862 20C9.94674 20 8.32335 19.6185 6.71592 18.8555V16.6009C8.23538 17.4783 9.7908 17.917 11.3822 17.917C13.9333 17.917 15.2128 17.1846 15.2208 15.7196C15.2208 15.0939 15.0049 14.5598 14.5731 14.1173C14.5339 14.0772 14.4939 14.0381 14.4531 14H3V12H21V14H17.1538ZM13.076 11H7.62908C7.4566 10.8433 7.29616 10.6692 7.14776 10.4778C6.71592 9.92084 6.5 9.24559 6.5 8.45207C6.5 7.21602 6.96583 6.165 7.89749 5.299C8.82916 4.43299 10.2706 4 12.2219 4C13.6934 4 15.1009 4.32808 16.4444 4.98426V7.13591C15.2448 6.44921 13.9293 6.10587 12.4978 6.10587C10.0187 6.10587 8.77917 6.88793 8.77917 8.45207C8.77917 8.87172 8.99709 9.23796 9.43293 9.55079C9.86878 9.86362 10.4066 10.1135 11.0463 10.3004C11.6665 10.4816 12.3431 10.7148 13.076 11H13.076Z" /> + } + /> + </Stack> + + {/* List */} + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Bullet list" + active={editor.isActive('bulletList')} + className={editorClasses.toolbar.bulletList} + onClick={() => editor.chain().focus().toggleBulletList().run()} + icon={ + <path d="M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z" /> + } + /> + <ToolbarItem + aria-label="Ordered list" + active={editor.isActive('orderedList')} + className={editorClasses.toolbar.orderedList} + onClick={() => editor.chain().focus().toggleOrderedList().run()} + icon={ + <path d="M8 4H21V6H8V4ZM5 3V6H6V7H3V6H4V4H3V3H5ZM3 14V11.5H5V11H3V10H6V12.5H4V13H6V14H3ZM5 19.5H3V18.5H5V18H3V17H6V21H3V20H5V19.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z" /> + } + /> + </Stack> + + {/* Text align */} + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Align left" + active={editor.isActive({ textAlign: 'left' })} + className={editorClasses.toolbar.alignLeft} + onClick={() => editor.chain().focus().setTextAlign('left').run()} + icon={<path d="M3 4H21V6H3V4ZM3 19H17V21H3V19ZM3 14H21V16H3V14ZM3 9H17V11H3V9Z" />} + /> + <ToolbarItem + aria-label="Align center" + active={editor.isActive({ textAlign: 'center' })} + className={editorClasses.toolbar.alignCenter} + onClick={() => editor.chain().focus().setTextAlign('center').run()} + icon={<path d="M3 4H21V6H3V4ZM5 19H19V21H5V19ZM3 14H21V16H3V14ZM5 9H19V11H5V9Z" />} + /> + <ToolbarItem + aria-label="Align right" + active={editor.isActive({ textAlign: 'right' })} + className={editorClasses.toolbar.alignRight} + onClick={() => editor.chain().focus().setTextAlign('right').run()} + icon={<path d="M3 4H21V6H3V4ZM7 19H21V21H7V19ZM3 14H21V16H3V14ZM7 9H21V11H7V9Z" />} + /> + <ToolbarItem + aria-label="Align justify" + active={editor.isActive({ textAlign: 'justify' })} + className={editorClasses.toolbar.alignJustify} + onClick={() => editor.chain().focus().setTextAlign('justify').run()} + icon={<path d="M3 4H21V6H3V4ZM3 19H21V21H3V19ZM3 14H21V16H3V14ZM3 9H21V11H3V9Z" />} + /> + </Stack> + + {/* Code - Code block */} + {fullItem && ( + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Align justify" + active={editor.isActive('code')} + className={editorClasses.toolbar.code} + onClick={() => editor.chain().focus().toggleCode().run()} + icon={ + <path d="M16.95 8.46448L18.3642 7.05026L23.3139 12L18.3642 16.9498L16.95 15.5355L20.4855 12L16.95 8.46448ZM7.05048 8.46448L3.51495 12L7.05048 15.5355L5.63627 16.9498L0.686523 12L5.63627 7.05026L7.05048 8.46448Z" /> + } + /> + <ToolbarItem + aria-label="Align justify" + active={editor.isActive('codeBlock')} + className={editorClasses.toolbar.codeBlock} + onClick={() => editor.chain().focus().toggleCodeBlock().run()} + icon={ + <path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z" /> + } + /> + </Stack> + )} + + {/* Blockquote - Hr line */} + {fullItem && ( + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Blockquote" + active={editor.isActive('blockquote')} + className={editorClasses.toolbar.blockquote} + onClick={() => editor.chain().focus().toggleBlockquote().run()} + icon={ + <path d="M4.58341 17.3211C3.55316 16.2274 3 15 3 13.0103C3 9.51086 5.45651 6.37366 9.03059 4.82318L9.92328 6.20079C6.58804 8.00539 5.93618 10.346 5.67564 11.822C6.21263 11.5443 6.91558 11.4466 7.60471 11.5105C9.40908 11.6778 10.8312 13.159 10.8312 15C10.8312 16.933 9.26416 18.5 7.33116 18.5C6.2581 18.5 5.23196 18.0095 4.58341 17.3211ZM14.5834 17.3211C13.5532 16.2274 13 15 13 13.0103C13 9.51086 15.4565 6.37366 19.0306 4.82318L19.9233 6.20079C16.588 8.00539 15.9362 10.346 15.6756 11.822C16.2126 11.5443 16.9156 11.4466 17.6047 11.5105C19.4091 11.6778 20.8312 13.159 20.8312 15C20.8312 16.933 19.2642 18.5 17.3312 18.5C16.2581 18.5 15.232 18.0095 14.5834 17.3211Z" /> + } + /> + <ToolbarItem + aria-label="Horizontal" + className={editorClasses.toolbar.hr} + onClick={() => editor.chain().focus().setHorizontalRule().run()} + icon={<path d="M2 11H4V13H2V11ZM6 11H18V13H6V11ZM20 11H22V13H20V11Z" />} + /> + </Stack> + )} + + {/* Link - Image */} + <Stack direction="row" spacing={0.5}> + <LinkBlock editor={editor} /> + <ImageBlock editor={editor} /> + </Stack> + + {/* HardBreak - Clear */} + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="HardBreak" + onClick={() => editor.chain().focus().setHardBreak().run()} + className={editorClasses.toolbar.hardbreak} + icon={ + <path d="M15 18H16.5C17.8807 18 19 16.8807 19 15.5C19 14.1193 17.8807 13 16.5 13H3V11H16.5C18.9853 11 21 13.0147 21 15.5C21 17.9853 18.9853 20 16.5 20H15V22L11 19L15 16V18ZM3 4H21V6H3V4ZM9 18V20H3V18H9Z" /> + } + /> + <ToolbarItem + aria-label="Clear" + className={editorClasses.toolbar.clear} + onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()} + icon={ + <path d="M12.6512 14.0654L11.6047 20H9.57389L10.9247 12.339L3.51465 4.92892L4.92886 3.51471L20.4852 19.0711L19.071 20.4853L12.6512 14.0654ZM11.7727 7.53009L12.0425 5.99999H10.2426L8.24257 3.99999H19.9999V5.99999H14.0733L13.4991 9.25652L11.7727 7.53009Z" /> + } + /> + </Stack> + + {/* Undo - Redo */} + {fullItem && ( + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Undo" + className={editorClasses.toolbar.undo} + disabled={!editor.can().chain().focus().undo().run()} + onClick={() => editor.chain().focus().undo().run()} + icon={ + <path d="M8 7V11L2 6L8 1V5H13C17.4183 5 21 8.58172 21 13C21 17.4183 17.4183 21 13 21H4V19H13C16.3137 19 19 16.3137 19 13C19 9.68629 16.3137 7 13 7H8Z" /> + } + /> + <ToolbarItem + aria-label="Redo" + className={editorClasses.toolbar.redo} + disabled={!editor.can().chain().focus().redo().run()} + onClick={() => editor.chain().focus().redo().run()} + icon={ + <path d="M16 7H11C7.68629 7 5 9.68629 5 13C5 16.3137 7.68629 19 11 19H20V21H11C6.58172 21 3 17.4183 3 13C3 8.58172 6.58172 5 11 5H16V1L22 6L16 11V7Z" /> + } + /> + </Stack> + )} + + <Stack direction="row" spacing={0.5}> + <ToolbarItem + aria-label="Fullscreen" + className={editorClasses.toolbar.fullscreen} + onClick={onToggleFullScreen} + icon={ + fullScreen ? ( + <path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z" /> + ) : ( + <path d="M16 3H22V9H20V5H16V3ZM2 3H8V5H4V9H2V3ZM20 19V15H22V21H16V19H20ZM4 19H8V21H2V15H4V19Z" /> + ) + } + /> + </Stack> + </Stack> + ); +} diff --git a/src/shared/components/editor/types.ts b/src/shared/components/editor/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3598e39cfbb7973fc1cdb38b7d4d19496691d379 --- /dev/null +++ b/src/shared/components/editor/types.ts @@ -0,0 +1,42 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { Editor, Extension, EditorOptions } from '@tiptap/react'; + +// ---------------------------------------------------------------------- + +export type EditorProps = Partial<EditorOptions> & { + value?: string; + error?: boolean; + fullItem?: boolean; + resetValue?: boolean; + sx?: SxProps<Theme>; + placeholder?: string; + helperText?: React.ReactNode; + onChange?: (value: string) => void; + slotProps?: { + wrap: SxProps<Theme>; + }; +}; + +export type EditorToolbarProps = { + fullScreen: boolean; + editor: Editor | null; + onToggleFullScreen: () => void; + fullItem?: EditorProps['fullItem']; +}; + +export type EditorToolbarItemProps = { + icon?: React.ReactNode; + label?: string; + active?: boolean; + disabled?: boolean; +}; + +export type EditorCodeHighlightBlockProps = { + extension: Extension; + updateAttributes: (attributes: Record<string, any>) => void; + node: { + attrs: { + language: string; + }; + }; +}; diff --git a/src/shared/components/empty-content/empty-content.tsx b/src/shared/components/empty-content/empty-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e011ad521de6689a9a38613e8c630056a456bf29 --- /dev/null +++ b/src/shared/components/empty-content/empty-content.tsx @@ -0,0 +1,82 @@ +import type { StackProps } from '@mui/material/Stack'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha } from 'src/shared/theme/styles'; + +// ---------------------------------------------------------------------- + +export type EmptyContentProps = StackProps & { + title?: string; + imgUrl?: string; + filled?: boolean; + description?: string; + action?: React.ReactNode; + slotProps?: { + img?: SxProps<Theme>; + title?: SxProps<Theme>; + description?: SxProps<Theme>; + }; +}; + +export function EmptyContent({ + sx, + imgUrl, + action, + filled, + slotProps, + description, + title = 'No data', + ...other +}: EmptyContentProps) { + return ( + <Stack + flexGrow={1} + alignItems="center" + justifyContent="center" + sx={{ + px: 3, + height: 1, + ...(filled && { + borderRadius: 2, + bgcolor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.04), + border: (theme) => `dashed 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), + ...sx, + }} + {...other} + > + <Box + component="img" + alt="empty content" + src={imgUrl ?? `${CONFIG.site.basePath}/assets/icons/empty/ic-content.svg`} + sx={{ width: 1, maxWidth: 160, ...slotProps?.img }} + /> + + {title && ( + <Typography + variant="h6" + component="span" + sx={{ mt: 1, textAlign: 'center', ...slotProps?.title, color: 'text.disabled' }} + > + {title} + </Typography> + )} + + {description && ( + <Typography + variant="caption" + sx={{ mt: 1, textAlign: 'center', color: 'text.disabled', ...slotProps?.description }} + > + {description} + </Typography> + )} + + {action && action} + </Stack> + ); +} diff --git a/src/shared/components/empty-content/index.ts b/src/shared/components/empty-content/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..026ff327ca584589cf72aab7476965cd100f7d05 --- /dev/null +++ b/src/shared/components/empty-content/index.ts @@ -0,0 +1 @@ +export * from './empty-content'; diff --git a/src/shared/components/filters-result/filters-block.tsx b/src/shared/components/filters-result/filters-block.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6cb2ae1ea832c9327da2080c996a27d3d57b9d2e --- /dev/null +++ b/src/shared/components/filters-result/filters-block.tsx @@ -0,0 +1,47 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +export type FilterBlockProps = { + label: string; + isShow: boolean; + sx?: SxProps<Theme>; + children: React.ReactNode; +}; + +export function FiltersBlock({ label, children, isShow, sx }: FilterBlockProps) { + if (!isShow) { + return null; + } + + return ( + <Box + gap={1} + display="flex" + sx={{ + p: 1, + borderRadius: 1, + overflow: 'hidden', + border: (theme) => `dashed 1px ${theme.vars.palette.divider}`, + ...sx, + }} + > + <Box + component="span" + sx={{ + height: 24, + lineHeight: '24px', + fontSize: (theme) => theme.typography.subtitle2.fontSize, + fontWeight: (theme) => theme.typography.subtitle2.fontWeight, + }} + > + {label} + </Box> + <Box gap={1} display="flex" flexWrap="wrap"> + {children} + </Box> + </Box> + ); +} diff --git a/src/shared/components/filters-result/filters-result.tsx b/src/shared/components/filters-result/filters-result.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3b349f65dbeee9eab4130d31ae39cfc6c38d38d0 --- /dev/null +++ b/src/shared/components/filters-result/filters-result.tsx @@ -0,0 +1,46 @@ +import type { ChipProps } from '@mui/material/Chip'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; + +import { Iconify } from 'src/shared/components/iconify'; + +// ---------------------------------------------------------------------- + +export const chipProps: ChipProps = { + size: 'small', + variant: 'soft', +}; + +type FiltersResultProps = { + totalResults: number; + onReset: () => void; + sx?: SxProps<Theme>; + children: React.ReactNode; +}; + +export function FiltersResult({ totalResults, onReset, sx, children }: FiltersResultProps) { + return ( + <Box sx={sx}> + <Box sx={{ mb: 1.5, typography: 'body2' }}> + <strong>{totalResults}</strong> + <Box component="span" sx={{ color: 'text.secondary', ml: 0.25 }}> + results found + </Box> + </Box> + + <Box flexGrow={1} gap={1} display="flex" flexWrap="wrap" alignItems="center"> + {children} + + <Button + color="error" + onClick={onReset} + startIcon={<Iconify icon="solar:trash-bin-trash-bold" />} + > + Clear + </Button> + </Box> + </Box> + ); +} diff --git a/src/shared/components/filters-result/index.ts b/src/shared/components/filters-result/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..edbcbbe60142e64a6cdd2683ac92011a6b111241 --- /dev/null +++ b/src/shared/components/filters-result/index.ts @@ -0,0 +1,3 @@ +export * from './filters-block'; + +export * from './filters-result'; diff --git a/src/shared/components/hook-form/fields.tsx b/src/shared/components/hook-form/fields.tsx index 360f763daca93e77aebfa085eb9ba5476b2fc70a..36ce3592e0a90c6ff43713795132766683067c0f 100644 --- a/src/shared/components/hook-form/fields.tsx +++ b/src/shared/components/hook-form/fields.tsx @@ -1,7 +1,22 @@ +import { RHFEditor } from './rhf-editor'; +import { RHFSwitch } from './rhf-switch'; import { RHFTextField } from './rhf-text-field'; +import { RHFMultiCheckbox } from './rhf-checkbox'; +import { RHFRadioGroup } from './rhf-radio-group'; +import { RHFDatePicker } from './rhf-date-picker'; +import { RHFAutocomplete } from './rhf-autocomplete'; +import { RHFCountrySelect } from './rhf-country-select'; + // ---------------------------------------------------------------------- export const Field = { Text: RHFTextField, + Editor: RHFEditor, + MultiCheckbox: RHFMultiCheckbox, + RadioGroup: RHFRadioGroup, + Autocomplete: RHFAutocomplete, + CountrySelect: RHFCountrySelect, + DatePicker: RHFDatePicker, + Switch: RHFSwitch, }; diff --git a/src/shared/components/hook-form/index.ts b/src/shared/components/hook-form/index.ts index 6f27fc34a7b8ca92528b21ec8059c01d884d4cb5..32a83a703fea2672103e8f822edbc555b3b8ce20 100644 --- a/src/shared/components/hook-form/index.ts +++ b/src/shared/components/hook-form/index.ts @@ -2,4 +2,7 @@ export * from './fields'; export * from './form-provider'; +export * from './schema-helper'; + export * from './rhf-text-field'; + diff --git a/src/shared/components/hook-form/rhf-autocomplete.tsx b/src/shared/components/hook-form/rhf-autocomplete.tsx new file mode 100644 index 0000000000000000000000000000000000000000..adc9da455e88b9821a152616e2abfe4186e86038 --- /dev/null +++ b/src/shared/components/hook-form/rhf-autocomplete.tsx @@ -0,0 +1,57 @@ +import type { AutocompleteProps } from '@mui/material/Autocomplete'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; + +// ---------------------------------------------------------------------- + +export type AutocompleteBaseProps = Omit< + AutocompleteProps<any, boolean, boolean, boolean>, + 'renderInput' +>; + +export type RHFAutocompleteProps = AutocompleteBaseProps & { + name: string; + label?: string; + placeholder?: string; + hiddenLabel?: boolean; + helperText?: React.ReactNode; +}; + +export function RHFAutocomplete({ + name, + label, + helperText, + hiddenLabel, + placeholder, + ...other +}: RHFAutocompleteProps) { + const { control, setValue } = useFormContext(); + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <Autocomplete + {...field} + id={`rhf-autocomplete-${name}`} + onChange={(event, newValue) => setValue(name, newValue, { shouldValidate: true })} + renderInput={(params) => ( + <TextField + {...params} + label={label} + placeholder={placeholder} + error={!!error} + helperText={error ? error?.message : helperText} + inputProps={{ ...params.inputProps, autoComplete: 'new-password' }} + /> + )} + {...other} + /> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/rhf-checkbox.tsx b/src/shared/components/hook-form/rhf-checkbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1838e6fdf88f4f47e82bcaeec7464d2019b1f099 --- /dev/null +++ b/src/shared/components/hook-form/rhf-checkbox.tsx @@ -0,0 +1,150 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { CheckboxProps } from '@mui/material/Checkbox'; +import type { FormGroupProps } from '@mui/material/FormGroup'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; +import type { FormControlLabelProps } from '@mui/material/FormControlLabel'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +type RHFCheckboxProps = Omit<FormControlLabelProps, 'control'> & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps<Theme>; + checkbox?: CheckboxProps; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFCheckbox({ name, helperText, label, slotProps, ...other }: RHFCheckboxProps) { + const { control } = useFormContext(); + + const ariaLabel = `Checkbox ${name}`; + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <Box sx={slotProps?.wrap}> + <FormControlLabel + control={ + <Checkbox + {...field} + checked={field.value} + {...slotProps?.checkbox} + inputProps={{ + ...(!label && { 'aria-label': ariaLabel }), + ...slotProps?.checkbox?.inputProps, + }} + /> + } + label={label} + {...other} + /> + + {(!!error || helperText) && ( + <FormHelperText error={!!error} {...slotProps?.formHelperText}> + {error ? error?.message : helperText} + </FormHelperText> + )} + </Box> + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiCheckboxProps = FormGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps<Theme>; + checkbox?: CheckboxProps; + formLabel?: FormLabelProps; + formHelperText?: FormHelperTextProps; + }; + options: { + label: string; + value: string; + }[]; +}; + +export function RHFMultiCheckbox({ + name, + label, + options, + slotProps, + helperText, + ...other +}: RHFMultiCheckboxProps) { + const { control } = useFormContext(); + + const getSelected = (selectedItems: string[], item: string) => + selectedItems.includes(item) + ? selectedItems.filter((value) => value !== item) + : [...selectedItems, item]; + + const accessibility = (val: string) => val; + const ariaLabel = (val: string) => `Checkbox ${val}`; + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <FormControl component="fieldset" sx={slotProps?.wrap}> + {label && ( + <FormLabel + component="legend" + {...slotProps?.formLabel} + sx={{ mb: 1, typography: 'body2', ...slotProps?.formLabel?.sx }} + > + {label} + </FormLabel> + )} + + <FormGroup {...other}> + {options.map((option) => ( + <FormControlLabel + key={option.value} + control={ + <Checkbox + checked={field.value.includes(option.value)} + onChange={() => field.onChange(getSelected(field.value, option.value))} + name={accessibility(option.label)} + {...slotProps?.checkbox} + inputProps={{ + ...(!option.label && { 'aria-label': ariaLabel(option.label) }), + ...slotProps?.checkbox?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + </FormGroup> + + {(!!error || helperText) && ( + <FormHelperText error={!!error} sx={{ mx: 0 }} {...slotProps?.formHelperText}> + {error ? error?.message : helperText} + </FormHelperText> + )} + </FormControl> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/rhf-country-select.tsx b/src/shared/components/hook-form/rhf-country-select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a33368b3c0c7eddbb5f680c978418363bea2616f --- /dev/null +++ b/src/shared/components/hook-form/rhf-country-select.tsx @@ -0,0 +1,34 @@ +import type { CountrySelectProps } from 'src/shared/components/country-select'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import { CountrySelect } from 'src/shared/components/country-select'; + +// ---------------------------------------------------------------------- + +export function RHFCountrySelect({ + name, + helperText, + ...other +}: CountrySelectProps & { + name: string; +}) { + const { control, setValue } = useFormContext(); + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <CountrySelect + id={`rhf-country-select-${name}`} + value={field.value} + onChange={(event, newValue) => setValue(name, newValue, { shouldValidate: true })} + error={!!error} + helperText={error?.message ?? helperText} + {...other} + /> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/rhf-date-picker.tsx b/src/shared/components/hook-form/rhf-date-picker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4de27562162c67b7696281f5eebab7f507964401 --- /dev/null +++ b/src/shared/components/hook-form/rhf-date-picker.tsx @@ -0,0 +1,86 @@ +import type { Dayjs } from 'dayjs'; +import type { TextFieldProps } from '@mui/material/TextField'; +import type { DatePickerProps } from '@mui/x-date-pickers/DatePicker'; +import type { MobileDateTimePickerProps } from '@mui/x-date-pickers/MobileDateTimePicker'; + +import dayjs from 'dayjs'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { MobileDateTimePicker } from '@mui/x-date-pickers/MobileDateTimePicker'; + +import { formatStr } from 'src/utils/format-time'; + +//---------------------------------------------------------------------- + +type RHFDatePickerProps = DatePickerProps<Dayjs> & { + name: string; +}; + +export function RHFDatePicker({ name, slotProps, ...other }: RHFDatePickerProps) { + const { control } = useFormContext(); + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <DatePicker + {...field} + value={dayjs(field.value)} + onChange={(newValue) => field.onChange(dayjs(newValue).format())} + format={formatStr.split.date} + slotProps={{ + textField: { + fullWidth: true, + error: !!error, + helperText: error?.message ?? (slotProps?.textField as TextFieldProps)?.helperText, + ...slotProps?.textField, + }, + ...slotProps, + }} + {...other} + /> + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMobileDateTimePickerProps = MobileDateTimePickerProps<Dayjs> & { + name: string; +}; + +export function RHFMobileDateTimePicker({ + name, + slotProps, + ...other +}: RHFMobileDateTimePickerProps) { + const { control } = useFormContext(); + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <MobileDateTimePicker + {...field} + value={dayjs(field.value)} + onChange={(newValue) => field.onChange(dayjs(newValue).format())} + format={formatStr.split.dateTime} + slotProps={{ + textField: { + fullWidth: true, + error: !!error, + helperText: error?.message ?? (slotProps?.textField as TextFieldProps)?.helperText, + ...slotProps?.textField, + }, + ...slotProps, + }} + {...other} + /> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/rhf-editor.tsx b/src/shared/components/hook-form/rhf-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da697b664ed7e72e326f4fd3f5ba6fe27786938a --- /dev/null +++ b/src/shared/components/hook-form/rhf-editor.tsx @@ -0,0 +1,34 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { Editor } from '../editor'; + +import type { EditorProps } from '../editor'; + +// ---------------------------------------------------------------------- + +type Props = EditorProps & { + name: string; +}; + +export function RHFEditor({ name, helperText, ...other }: Props) { + const { + control, + formState: { isSubmitSuccessful }, + } = useFormContext(); + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <Editor + {...field} + error={!!error} + helperText={error?.message ?? helperText} + resetValue={isSubmitSuccessful} + {...other} + /> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/rhf-radio-group.tsx b/src/shared/components/hook-form/rhf-radio-group.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0044464faa85ecb84543e5f839af6917350d852 --- /dev/null +++ b/src/shared/components/hook-form/rhf-radio-group.tsx @@ -0,0 +1,85 @@ +import type { RadioProps } from '@mui/material/Radio'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { RadioGroupProps } from '@mui/material/RadioGroup'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Radio from '@mui/material/Radio'; +import FormLabel from '@mui/material/FormLabel'; +import RadioGroup from '@mui/material/RadioGroup'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +type Props = RadioGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps<Theme>; + radio: RadioProps; + formLabel: FormLabelProps; + formHelperText: FormHelperTextProps; + }; + options: { + label: string; + value: string; + }[]; +}; + +export function RHFRadioGroup({ name, label, options, helperText, slotProps, ...other }: Props) { + const { control } = useFormContext(); + + const labelledby = `${name}-radio-buttons-group-label`; + const ariaLabel = (val: string) => `Radio ${val}`; + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <FormControl component="fieldset" sx={slotProps?.wrap}> + {label && ( + <FormLabel + id={labelledby} + component="legend" + {...slotProps?.formLabel} + sx={{ mb: 1, typography: 'body2', ...slotProps?.formLabel.sx }} + > + {label} + </FormLabel> + )} + + <RadioGroup {...field} aria-labelledby={labelledby} {...other}> + {options.map((option) => ( + <FormControlLabel + key={option.value} + value={option.value} + control={ + <Radio + {...slotProps?.radio} + inputProps={{ + ...(!option.label && { 'aria-label': ariaLabel(option.label) }), + ...slotProps?.radio?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + </RadioGroup> + + {(!!error || helperText) && ( + <FormHelperText error={!!error} sx={{ mx: 0 }} {...slotProps?.formHelperText}> + {error ? error?.message : helperText} + </FormHelperText> + )} + </FormControl> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/rhf-switch.tsx b/src/shared/components/hook-form/rhf-switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03f0f3eb6ed53cdd0644f5603681539ad0e0cbdb --- /dev/null +++ b/src/shared/components/hook-form/rhf-switch.tsx @@ -0,0 +1,154 @@ +import type { SwitchProps } from '@mui/material/Switch'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { FormGroupProps } from '@mui/material/FormGroup'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; +import type { FormControlLabelProps } from '@mui/material/FormControlLabel'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Switch from '@mui/material/Switch'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +export type RHFSwitchProps = Omit<FormControlLabelProps, 'control'> & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps<Theme>; + switch: SwitchProps; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFSwitch({ name, helperText, label, slotProps, ...other }: RHFSwitchProps) { + const { control } = useFormContext(); + + const ariaLabel = `Switch ${name}`; + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <Box sx={slotProps?.wrap}> + <FormControlLabel + control={ + <Switch + {...field} + checked={field.value} + {...slotProps?.switch} + inputProps={{ + ...(!label && { 'aria-label': ariaLabel }), + ...slotProps?.switch?.inputProps, + }} + /> + } + label={label} + {...other} + /> + + {(!!error || helperText) && ( + <FormHelperText + error={!!error} + {...slotProps?.formHelperText} + sx={slotProps?.formHelperText?.sx} + > + {error ? error?.message : helperText} + </FormHelperText> + )} + </Box> + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiSwitchProps = FormGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + options: { + label: string; + value: string; + }[]; + slotProps?: { + wrap?: SxProps<Theme>; + switch: SwitchProps; + formLabel?: FormLabelProps; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFMultiSwitch({ + name, + label, + options, + helperText, + slotProps, + ...other +}: RHFMultiSwitchProps) { + const { control } = useFormContext(); + + const getSelected = (selectedItems: string[], item: string) => + selectedItems.includes(item) + ? selectedItems.filter((value) => value !== item) + : [...selectedItems, item]; + + const accessibility = (val: string) => val; + const ariaLabel = (val: string) => `Switch ${val}`; + + return ( + <Controller + name={name} + control={control} + render={({ field, fieldState: { error } }) => ( + <FormControl component="fieldset" sx={slotProps?.wrap}> + {label && ( + <FormLabel + component="legend" + {...slotProps?.formLabel} + sx={{ mb: 1, typography: 'body2', ...slotProps?.formLabel?.sx }} + > + {label} + </FormLabel> + )} + + <FormGroup {...other}> + {options.map((option) => ( + <FormControlLabel + key={option.value} + control={ + <Switch + checked={field.value.includes(option.value)} + onChange={() => field.onChange(getSelected(field.value, option.value))} + name={accessibility(option.label)} + {...slotProps?.switch} + inputProps={{ + ...(!option.label && { 'aria-label': ariaLabel(option.label) }), + ...slotProps?.switch?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + </FormGroup> + + {(!!error || helperText) && ( + <FormHelperText error={!!error} sx={{ mx: 0 }} {...slotProps?.formHelperText}> + {error ? error?.message : helperText} + </FormHelperText> + )} + </FormControl> + )} + /> + ); +} diff --git a/src/shared/components/hook-form/schema-helper.ts b/src/shared/components/hook-form/schema-helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..d58cba193913552842916a9526b1b486cc99cc5a --- /dev/null +++ b/src/shared/components/hook-form/schema-helper.ts @@ -0,0 +1,127 @@ +import dayjs from 'dayjs'; +import { z as zod } from 'zod'; + +// ---------------------------------------------------------------------- + +// const isSsr = typeof window === 'undefined'; + +type InputProps = { + message?: { + required_error?: string; + invalid_type_error?: string; + }; + minFiles?: number; + isValidPhoneNumber?: (text: string) => boolean; +}; + +export const schemaHelper = { + /** + * Phone number + * defaultValue === null + */ + phoneNumber: (props?: InputProps) => + zod + .string() + .min(1, { message: props?.message?.required_error ?? 'Phone number is required!' }) + .refine((data) => props?.isValidPhoneNumber?.(data), { + message: props?.message?.invalid_type_error ?? 'Invalid phone number!', + }), + /** + * date + * defaultValue === null + */ + date: (props?: InputProps) => + zod.coerce + .date() + .nullable() + .transform((dateString, ctx) => { + const date = dayjs(dateString).format(); + + const stringToDate = zod.string().pipe(zod.coerce.date()); + + if (!dateString) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message?.required_error ?? 'Date is required!', + }); + return null; + } + + if (!stringToDate.safeParse(date).success) { + ctx.addIssue({ + code: zod.ZodIssueCode.invalid_date, + message: props?.message?.invalid_type_error ?? 'Invalid Date!!', + }); + } + + return date; + }) + .pipe(zod.union([zod.number(), zod.string(), zod.date(), zod.null()])), + /** + * editor + * defaultValue === '' | <p></p> + */ + editor: (props?: InputProps) => + zod.string().min(8, { message: props?.message?.required_error ?? 'Editor is required!' }), + /** + * object + * defaultValue === null + */ + objectOrNull: <T>(props?: InputProps) => + zod + .custom<T>() + .refine((data) => data !== null, { + message: props?.message?.required_error ?? 'Field is required!', + }) + .refine((data) => data !== '', { + message: props?.message?.required_error ?? 'Field is required!', + }), + /** + * boolean + * defaultValue === false + */ + boolean: (props?: InputProps) => + zod.coerce.boolean().refine((bool) => bool === true, { + message: props?.message?.required_error ?? 'Switch is required!', + }), + /** + * file + * defaultValue === '' || null + */ + file: (props?: InputProps) => + zod.custom<File | string | null>().transform((data, ctx) => { + const hasFile = data instanceof File || (typeof data === 'string' && !!data.length); + + if (!hasFile) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message?.required_error ?? 'File is required!', + }); + return null; + } + + return data; + }), + /** + * files + * defaultValue === [] + */ + files: (props?: InputProps) => + zod.array(zod.custom<File | string>()).transform((data, ctx) => { + const minFiles = props?.minFiles ?? 2; + + if (!data.length) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message?.required_error ?? 'Files is required!', + }); + } else if (data.length < minFiles) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Must have at least ${minFiles} items!`, + }); + } + + return data; + }), +}; diff --git a/src/shared/components/image/classes.ts b/src/shared/components/image/classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..76a605988cb6242a6377d3838de30b0a9fd37f82 --- /dev/null +++ b/src/shared/components/image/classes.ts @@ -0,0 +1,7 @@ +// ---------------------------------------------------------------------- + +export const imageClasses = { + root: 'mnl__image__root', + wrapper: 'mnl__image__wrapper', + overlay: 'mnl__image__overlay', +}; diff --git a/src/shared/components/image/image.tsx b/src/shared/components/image/image.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d8c7700bf720ea18b98335227717151dd3ac9e5 --- /dev/null +++ b/src/shared/components/image/image.tsx @@ -0,0 +1,110 @@ +import { forwardRef } from 'react'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; + +import { imageClasses } from './classes'; + +import type { ImageProps } from './types'; + +// ---------------------------------------------------------------------- + +const ImageWrapper = styled(Box)({ + overflow: 'hidden', + position: 'relative', + verticalAlign: 'bottom', + display: 'inline-block', + [`& .${imageClasses.wrapper}`]: { + width: '100%', + height: '100%', + verticalAlign: 'bottom', + backgroundSize: 'cover !important', + }, +}); + +const Overlay = styled('span')({ + top: 0, + left: 0, + zIndex: 1, + width: '100%', + height: '100%', + position: 'absolute', +}); + +// ---------------------------------------------------------------------- + +export const Image = forwardRef<HTMLSpanElement, ImageProps>( + ( + { + ratio, + disabledEffect = false, + // + alt, + src, + delayTime, + threshold, + beforeLoad, + delayMethod, + placeholder, + wrapperProps, + scrollPosition, + effect = 'blur', + visibleByDefault, + wrapperClassName, + useIntersectionObserver, + // + slotProps, + sx, + ...other + }, + ref + ) => { + const content = ( + <Box + component={LazyLoadImage} + alt={alt} + src={src} + delayTime={delayTime} + threshold={threshold} + beforeLoad={beforeLoad} + delayMethod={delayMethod} + placeholder={placeholder} + wrapperProps={wrapperProps} + scrollPosition={scrollPosition} + visibleByDefault={visibleByDefault} + effect={visibleByDefault || disabledEffect ? undefined : effect} + useIntersectionObserver={useIntersectionObserver} + wrapperClassName={wrapperClassName || imageClasses.wrapper} + placeholderSrc={ + visibleByDefault || disabledEffect + ? `${CONFIG.site.basePath}/assets/transparent.png` + : `${CONFIG.site.basePath}/assets/placeholder.svg` + } + sx={{ + width: 1, + height: 1, + objectFit: 'cover', + verticalAlign: 'bottom', + aspectRatio: ratio, + }} + /> + ); + + return ( + <ImageWrapper + ref={ref} + component="span" + className={imageClasses.root} + sx={{ ...(!!ratio && { width: 1 }), ...sx }} + {...other} + > + {slotProps?.overlay && <Overlay className={imageClasses.overlay} sx={slotProps?.overlay} />} + + {content} + </ImageWrapper> + ); + } +); diff --git a/src/shared/components/image/index.ts b/src/shared/components/image/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e7e67e8b8e81a08444064a7cf5919a502d618ee --- /dev/null +++ b/src/shared/components/image/index.ts @@ -0,0 +1,5 @@ +export * from './image'; + +export * from './classes'; + +export type * from './types'; diff --git a/src/shared/components/image/styles.css b/src/shared/components/image/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..07362102bf22c803289d7be496962ddc2927165a --- /dev/null +++ b/src/shared/components/image/styles.css @@ -0,0 +1 @@ +@import 'react-lazy-load-image-component/src/effects/blur.css'; diff --git a/src/shared/components/image/types.ts b/src/shared/components/image/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4244c89ca9e049cdf30fd286faa256686e49f43 --- /dev/null +++ b/src/shared/components/image/types.ts @@ -0,0 +1,30 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { LazyLoadImageProps } from 'react-lazy-load-image-component'; + +// ---------------------------------------------------------------------- + +type BaseRatioType = + | '2/3' + | '3/2' + | '4/3' + | '3/4' + | '6/4' + | '4/6' + | '16/9' + | '9/16' + | '21/9' + | '9/21' + | '1/1' + | string; + +export type ImageRatioType = BaseRatioType | { [key: string]: string }; + +export type ImageProps = BoxProps & + LazyLoadImageProps & { + ratio?: ImageRatioType; + disabledEffect?: boolean; + slotProps?: { + overlay: SxProps<Theme>; + }; + }; diff --git a/src/shared/components/markdown/classes.ts b/src/shared/components/markdown/classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..0739ea7d85d810b7c128b884301b6f180ad3f891 --- /dev/null +++ b/src/shared/components/markdown/classes.ts @@ -0,0 +1,12 @@ +// ---------------------------------------------------------------------- + +export const markdownClasses = { + root: 'nml__markdown__root', + content: { + pre: 'nml__editor__content__pre', + codeInline: 'nml__editor__content__codeInline', + codeBlock: 'nml__editor__content__codeBlock', + image: 'nml__editor__content__image', + link: 'nml__editor__content__link', + }, +}; diff --git a/src/shared/components/markdown/code-highlight-block.css b/src/shared/components/markdown/code-highlight-block.css new file mode 100644 index 0000000000000000000000000000000000000000..8d8cf379ac58cf5c96c8ed7628748b7b87dfbb28 --- /dev/null +++ b/src/shared/components/markdown/code-highlight-block.css @@ -0,0 +1,82 @@ +pre { + code { + .hljs-comment { + color: #999; + } + .hljs-tag { + color: #b4b7b4; + } + .hljs-operator, + .hljs-punctuation, + .hljs-subst { + color: #ccc; + } + .hljs-operator { + opacity: 0.7; + } + .hljs-bullet, + .hljs-deletion, + .hljs-name, + .hljs-selector-tag, + .hljs-template-variable, + .hljs-variable { + color: #f2777a; + } + .hljs-attr, + .hljs-link, + .hljs-literal, + .hljs-number, + .hljs-symbol, + .hljs-variable.constant_ { + color: #f99157; + } + .hljs-class .hljs-title, + .hljs-title, + .hljs-title.class_ { + color: #fc6; + } + .hljs-strong { + font-weight: 700; + color: #fc6; + } + .hljs-addition, + .hljs-code, + .hljs-string, + .hljs-title.class_.inherited__ { + color: #9c9; + } + .hljs-built_in, + .hljs-doctag, + .hljs-keyword.hljs-atrule, + .hljs-quote, + .hljs-regexp { + color: #6cc; + } + .hljs-attribute, + .hljs-function .hljs-title, + .hljs-section, + .hljs-title.function_, + .ruby .hljs-property { + color: #69c; + } + .diff .hljs-meta, + .hljs-keyword, + .hljs-template-tag, + .hljs-type { + color: #c9c; + } + .hljs-emphasis { + color: #c9c; + font-style: italic; + } + .hljs-meta, + .hljs-meta .hljs-keyword, + .hljs-meta .hljs-string { + color: #a3685a; + } + .hljs-meta .hljs-keyword, + .hljs-meta-keyword { + font-weight: 700; + } + } +} diff --git a/src/shared/components/markdown/html-tags.ts b/src/shared/components/markdown/html-tags.ts new file mode 100644 index 0000000000000000000000000000000000000000..44c86bcb8fc222b46d8b16e1ff94c02753a6dd77 --- /dev/null +++ b/src/shared/components/markdown/html-tags.ts @@ -0,0 +1,172 @@ +/** All html tags + * https://github.com/harrysolovay/all-html-tags + */ + +export const htmlTags = [ + 'a', + 'abbr', + 'acronym', + 'address', + 'applet', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'basefont', + 'bdi', + 'bdo', + 'bgsound', + 'big', + 'blink', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'center', + 'circle', + 'cite', + 'clipPath', + 'code', + 'col', + 'colgroup', + 'command', + 'content', + 'data', + 'datalist', + 'dd', + 'defs', + 'del', + 'details', + 'dfn', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'element', + 'ellipse', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'font', + 'footer', + 'foreignObject', + 'form', + 'frame', + 'frameset', + 'g', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'image', + 'img', + 'input', + 'ins', + 'isindex', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'line', + 'linearGradient', + 'link', + 'listing', + 'main', + 'map', + 'mark', + 'marquee', + 'mask', + 'math', + 'menu', + 'menuitem', + 'meta', + 'meter', + 'multicol', + 'nav', + 'nextid', + 'nobr', + 'noembed', + 'noframes', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'param', + 'path', + 'pattern', + 'picture', + 'plaintext', + 'polygon', + 'polyline', + 'pre', + 'progress', + 'q', + 'radialGradient', + 'rb', + 'rbc', + 'rect', + 'rp', + 'rt', + 'rtc', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'shadow', + 'slot', + 'small', + 'source', + 'spacer', + 'span', + 'stop', + 'strike', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'svg', + 'table', + 'tbody', + 'td', + 'template', + 'text', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'tspan', + 'tt', + 'u', + 'ul', + 'var', + 'video', + 'wbr', + 'xmp', +]; diff --git a/src/shared/components/markdown/html-to-markdown.ts b/src/shared/components/markdown/html-to-markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..17fe0a87cba78fb609f37e8d46ce9312a1582ac4 --- /dev/null +++ b/src/shared/components/markdown/html-to-markdown.ts @@ -0,0 +1,62 @@ +import type { Node, Filter } from 'turndown'; + +import TurndownService from 'turndown'; + +import { htmlTags } from './html-tags'; + +// ---------------------------------------------------------------------- + +type INode = HTMLElement & { + isBlock: boolean; +}; + +const excludeTags = ['pre', 'code']; + +const turndownService = new TurndownService({ codeBlockStyle: 'fenced', fence: '```' }); + +const filterTags = htmlTags.filter((item) => !excludeTags.includes(item)) as Filter; + +/** + * Custom rule + * https://github.com/mixmark-io/turndown/issues/241#issuecomment-400591362 + */ +turndownService.addRule('keep', { + filter: filterTags, + replacement(content: string, node: Node) { + const { isBlock, outerHTML } = node as INode; + + return node && isBlock ? `\n\n${outerHTML}\n\n` : outerHTML; + }, +}); + +// ---------------------------------------------------------------------- + +export function htmlToMarkdown(html: string) { + return turndownService.turndown(html); +} +// ---------------------------------------------------------------------- + +export function isMarkdownContent(content: string) { + // Checking if the content contains Markdown-specific patterns + const markdownPatterns = [ + /* Heading */ + /^#+\s/, + /* List item */ + /^(\*|-|\d+\.)\s/, + /* Code block */ + /^```/, + /* Table */ + /^\|/, + /* Unordered list */ + /^(\s*)[*+-] [^\r\n]+/, + /* Ordered list */ + /^(\s*)\d+\. [^\r\n]+/, + /* Image */ + /!\[.*?\]\(.*?\)/, + /* Link */ + /\[.*?\]\(.*?\)/, + ]; + + // Checking if any of the patterns match + return markdownPatterns.some((pattern) => pattern.test(content)); +} diff --git a/src/shared/components/markdown/index.ts b/src/shared/components/markdown/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..12b07fd586c57329f5b63dbc880e24ae4cbcb1e8 --- /dev/null +++ b/src/shared/components/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './markdown'; + +export type * from './types'; diff --git a/src/shared/components/markdown/markdown.tsx b/src/shared/components/markdown/markdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f1d35db3feb551e7b715f7d13f0f36a73dc372e6 --- /dev/null +++ b/src/shared/components/markdown/markdown.tsx @@ -0,0 +1,94 @@ +import './code-highlight-block.css'; + +import type { Options } from 'react-markdown'; + +import { useMemo } from 'react'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeHighlight from 'rehype-highlight'; + +import Link from '@mui/material/Link'; + +import { isExternalLink } from 'src/routes/utils'; +import { RouterLink } from 'src/routes/components'; + +import { Image } from '../image'; +import { StyledRoot } from './styles'; +import { markdownClasses } from './classes'; +import { htmlToMarkdown, isMarkdownContent } from './html-to-markdown'; + +import type { MarkdownProps } from './types'; + +// ---------------------------------------------------------------------- + +export function Markdown({ children, sx, ...other }: MarkdownProps) { + const content = useMemo(() => { + if (isMarkdownContent(`${children}`)) { + return children; + } + return htmlToMarkdown(`${children}`.trim()); + }, [children]); + + return ( + <StyledRoot + children={content} + components={components as Options['components']} + rehypePlugins={rehypePlugins as Options['rehypePlugins']} + /* base64-encoded images + * https://github.com/remarkjs/react-markdown/issues/774 + * urlTransform={(value: string) => value} + */ + className={markdownClasses.root} + sx={sx} + {...other} + /> + ); +} + +// ---------------------------------------------------------------------- + +type ComponentTag = { + [key: string]: any; +}; + +const rehypePlugins = [rehypeRaw, rehypeHighlight, [remarkGfm, { singleTilde: false }]]; + +const components = { + img: ({ node, ...other }: ComponentTag) => ( + <Image + ratio="16/9" + className={markdownClasses.content.image} + sx={{ borderRadius: 2 }} + {...other} + /> + ), + a: ({ href, children, node, ...other }: ComponentTag) => { + const linkProps = isExternalLink(href) + ? { target: '_blank', rel: 'noopener' } + : { component: RouterLink }; + + return ( + <Link {...linkProps} href={href} className={markdownClasses.content.link} {...other}> + {children} + </Link> + ); + }, + pre: ({ children }: ComponentTag) => ( + <div className={markdownClasses.content.codeBlock}> + <pre>{children}</pre> + </div> + ), + code({ className, children, node, ...other }: ComponentTag) { + const language = /language-(\w+)/.exec(className || ''); + + return language ? ( + <code {...other} className={className}> + {children} + </code> + ) : ( + <code {...other} className={markdownClasses.content.codeInline}> + {children} + </code> + ); + }, +}; diff --git a/src/shared/components/markdown/styles.ts b/src/shared/components/markdown/styles.ts new file mode 100644 index 0000000000000000000000000000000000000000..994811db03d85e5287e6e9a6a47ff242ea4de3b4 --- /dev/null +++ b/src/shared/components/markdown/styles.ts @@ -0,0 +1,165 @@ +import ReactMarkdown from 'react-markdown'; + +import { styled } from '@mui/material/styles'; + +import { varAlpha, stylesMode } from 'src/shared/theme/styles'; + +import { markdownClasses } from './classes'; + +// ---------------------------------------------------------------------- + +const MARGIN = '0.75em'; + +export const StyledRoot = styled(ReactMarkdown)(({ theme }) => ({ + '> * + *': { + marginTop: 0, + marginBottom: MARGIN, + }, + /** + * Heading & Paragraph + */ + h1: { ...theme.typography.h1, marginTop: 40, marginBottom: 8 }, + h2: { ...theme.typography.h2, marginTop: 40, marginBottom: 8 }, + h3: { ...theme.typography.h3, marginTop: 24, marginBottom: 8 }, + h4: { ...theme.typography.h4, marginTop: 24, marginBottom: 8 }, + h5: { ...theme.typography.h5, marginTop: 24, marginBottom: 8 }, + h6: { ...theme.typography.h6, marginTop: 24, marginBottom: 8 }, + p: { ...theme.typography.body1, marginBottom: '1.25rem' }, + /** + * Hr Divider + */ + hr: { + flexShrink: 0, + borderWidth: 0, + margin: '2em 0', + msFlexNegative: 0, + WebkitFlexShrink: 0, + borderStyle: 'solid', + borderBottomWidth: 'thin', + borderColor: theme.vars.palette.divider, + }, + /** + * Image + */ + [`& .${markdownClasses.content.image}`]: { + width: '100%', + height: 'auto', + maxWidth: '100%', + margin: 'auto auto 1.25em', + }, + /** + * List + */ + '& ul': { + listStyleType: 'disc', + }, + '& ul, & ol': { + paddingLeft: 16, + '& > li': { + lineHeight: 2, + '& > p': { margin: 0, display: 'inline-block' }, + }, + }, + /** + * Blockquote + */ + '& blockquote': { + lineHeight: 1.5, + fontSize: '1.5em', + margin: '24px auto', + position: 'relative', + fontFamily: 'Georgia, serif', + padding: theme.spacing(3, 3, 3, 8), + color: theme.vars.palette.text.secondary, + borderLeft: `solid 8px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + [theme.breakpoints.up('md')]: { + width: '100%', + maxWidth: 640, + }, + '& p': { + margin: 0, + fontSize: 'inherit', + fontFamily: 'inherit', + }, + '&::before': { + left: 16, + top: -8, + display: 'block', + fontSize: '3em', + content: '"\\201C"', + position: 'absolute', + color: theme.vars.palette.text.disabled, + }, + }, + /** + * Code inline + */ + [`& .${markdownClasses.content.codeInline}`]: { + padding: theme.spacing(0.25, 0.5), + color: theme.vars.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + borderRadius: theme.shape.borderRadius / 2, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + }, + /** + * Code Block + */ + [`& .${markdownClasses.content.codeBlock}`]: { + position: 'relative', + '& pre': { + overflowX: 'auto', + padding: theme.spacing(3), + color: theme.vars.palette.common.white, + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.vars.palette.grey[900], + fontFamily: "'JetBrainsMono', monospace", + '& code': { fontSize: theme.typography.body2.fontSize }, + }, + }, + /** + * Table + */ + table: { + width: '100%', + borderCollapse: 'collapse', + border: `1px solid ${theme.vars.palette.divider}`, + 'th, td': { padding: theme.spacing(1), border: `1px solid ${theme.vars.palette.divider}` }, + 'tbody tr:nth-of-type(odd)': { backgroundColor: theme.vars.palette.background.neutral }, + }, + /** + * Checkbox + */ + input: { + '&[type=checkbox]': { + position: 'relative', + cursor: 'pointer', + '&:before': { + content: '""', + top: -2, + left: -2, + width: 17, + height: 17, + borderRadius: 3, + position: 'absolute', + backgroundColor: theme.vars.palette.grey[300], + [stylesMode.dark]: { backgroundColor: theme.vars.palette.grey[700] }, + }, + '&:checked': { + '&:before': { backgroundColor: theme.vars.palette.primary.main }, + '&:after': { + content: '""', + top: 1, + left: 5, + width: 4, + height: 9, + position: 'absolute', + transform: 'rotate(45deg)', + msTransform: 'rotate(45deg)', + WebkitTransform: 'rotate(45deg)', + border: `solid ${theme.vars.palette.common.white}`, + borderWidth: '0 2px 2px 0', + }, + }, + }, + }, +})); diff --git a/src/shared/components/markdown/types.ts b/src/shared/components/markdown/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ab8d83996acb76e80c109493a49ec7d47be37b5 --- /dev/null +++ b/src/shared/components/markdown/types.ts @@ -0,0 +1,9 @@ +import type { Options } from 'react-markdown'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export interface MarkdownProps extends Options { + asHtml?: boolean; + sx?: SxProps<Theme>; +} diff --git a/src/shared/layouts/config-nav-dashboard.tsx b/src/shared/layouts/config-nav-dashboard.tsx index e1203704853a386b44c71c6262dc14de8bb1e641..9c6630457980c9c47c6da4e7bfec981cfbc405cd 100644 --- a/src/shared/layouts/config-nav-dashboard.tsx +++ b/src/shared/layouts/config-nav-dashboard.tsx @@ -16,6 +16,7 @@ const ICONS = { dashboard: icon('ic-dashboard'), parameter: icon('ic-parameter'), blank: icon('ic-blank'), + job: icon('ic-job'), }; // ---------------------------------------------------------------------- @@ -32,6 +33,15 @@ export const navData = [ path: paths.dashboard.blank, icon: ICONS.blank, }, + { + title: `Offres d'emlpoi`, + path: paths.dashboard.job.root, + icon: ICONS.job, + children: [ + { title: 'Liste', path: paths.dashboard.job.root }, + { title: 'Créer', path: paths.dashboard.job.new }, + ], + }, ], }, /** diff --git a/src/shared/sections/job/job-details-candidates.tsx b/src/shared/sections/job/job-details-candidates.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8385b3f57bdb2f2b4d619945a79ec73fa42988ea --- /dev/null +++ b/src/shared/sections/job/job-details-candidates.tsx @@ -0,0 +1,118 @@ +import type { IJobCandidate } from 'src/types/job'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import Tooltip from '@mui/material/Tooltip'; +import Pagination from '@mui/material/Pagination'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; + +import { varAlpha } from 'src/shared/theme/styles'; + +import { Iconify } from 'src/shared/components/iconify'; + +// ---------------------------------------------------------------------- + +type Props = { + candidates: IJobCandidate[]; +}; + +export function JobDetailsCandidates({ candidates }: Props) { + return ( + <> + <Box + gap={3} + display="grid" + gridTemplateColumns={{ xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' }} + > + {candidates.map((candidate) => ( + <Card key={candidate.id} sx={{ p: 3, gap: 2, display: 'flex' }}> + <IconButton sx={{ position: 'absolute', top: 8, right: 8 }}> + <Iconify icon="eva:more-vertical-fill" /> + </IconButton> + + <Avatar alt={candidate.name} src={candidate.avatarUrl} sx={{ width: 48, height: 48 }} /> + + <Stack spacing={2}> + <ListItemText + primary={candidate.name} + secondary={candidate.role} + secondaryTypographyProps={{ + mt: 0.5, + component: 'span', + typography: 'caption', + color: 'text.disabled', + }} + /> + + <Stack spacing={1} direction="row"> + <IconButton + size="small" + color="error" + sx={{ + borderRadius: 1, + bgcolor: (theme) => varAlpha(theme.vars.palette.error.mainChannel, 0.08), + '&:hover': { + bgcolor: (theme) => varAlpha(theme.vars.palette.error.mainChannel, 0.16), + }, + }} + > + <Iconify width={18} icon="solar:phone-bold" /> + </IconButton> + + <IconButton + size="small" + color="info" + sx={{ + borderRadius: 1, + bgcolor: (theme) => varAlpha(theme.vars.palette.info.mainChannel, 0.08), + '&:hover': { + bgcolor: (theme) => varAlpha(theme.vars.palette.info.mainChannel, 0.16), + }, + }} + > + <Iconify width={18} icon="solar:chat-round-dots-bold" /> + </IconButton> + + <IconButton + size="small" + color="primary" + sx={{ + borderRadius: 1, + bgcolor: (theme) => varAlpha(theme.vars.palette.primary.mainChannel, 0.08), + '&:hover': { + bgcolor: (theme) => varAlpha(theme.vars.palette.primary.mainChannel, 0.16), + }, + }} + > + <Iconify width={18} icon="fluent:mail-24-filled" /> + </IconButton> + + <Tooltip title="Download CV"> + <IconButton + size="small" + color="secondary" + sx={{ + borderRadius: 1, + bgcolor: (theme) => varAlpha(theme.vars.palette.secondary.mainChannel, 0.08), + '&:hover': { + bgcolor: (theme) => + varAlpha(theme.vars.palette.secondary.mainChannel, 0.16), + }, + }} + > + <Iconify width={18} icon="eva:cloud-download-fill" /> + </IconButton> + </Tooltip> + </Stack> + </Stack> + </Card> + ))} + </Box> + + <Pagination count={10} sx={{ mt: { xs: 5, md: 8 }, mx: 'auto' }} /> + </> + ); +} diff --git a/src/shared/sections/job/job-details-content.tsx b/src/shared/sections/job/job-details-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..90fbe157f98ab29226438d766bfa7400dc8a6d5e --- /dev/null +++ b/src/shared/sections/job/job-details-content.tsx @@ -0,0 +1,173 @@ +import type { IJobCandidate, IJobItem } from 'src/types/job'; + +import Chip from '@mui/material/Chip'; +import Card from '@mui/material/Card'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import Grid from '@mui/material/Unstable_Grid2'; +import Typography from '@mui/material/Typography'; +import ListItemText from '@mui/material/ListItemText'; + +import { fDate } from 'src/utils/format-time'; +import { fCurrency } from 'src/utils/format-number'; + +import { Iconify } from 'src/shared/components/iconify'; +import { Markdown } from 'src/shared/components/markdown'; +import { Button, Divider } from '@mui/material'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { toast } from 'src/shared/components/snackbar'; + +// ---------------------------------------------------------------------- + +type Props = { + job?: IJobItem; +}; + +export function JobDetailsContent({ job }: Props) { + + const candidate: IJobCandidate = { + id: '1', + name: 'marouane', + role: 'Software Engineer', + avatarUrl: '', + } + + const handleApply = () => { + job?.candidates.unshift(candidate); + toast('candidate sucessfully inserted'); + } + + const renderContent = ( + <Card sx={{ p: 3, gap: 3, display: 'flex', flexDirection: 'column' }}> + <Typography variant="h4">{job?.title}</Typography> + <Divider orientation="horizontal" flexItem sx={{ borderBottomWidth: 3, }}/> + <Typography variant="h4">Description</Typography> + + <Markdown children={job?.content} /> + + <Stack spacing={2}> + <Typography variant="h6">Compétences</Typography> + <Stack direction="row" alignItems="center" spacing={1}> + {job?.skills.map((skill) => <Chip key={skill} label={skill} variant="soft" />)} + </Stack> + </Stack> + + <Stack spacing={2}> + <Typography variant="h6"> + Badges + <Chip + variant="outlined" + color="success" + label="HIRE-3" + size="medium" + sx={{ + marginInline: '5px', + borderRadius: '5px', + borderWidth: '2px', + + }} + /> + </Typography> + <Paper elevation={0} sx={{ p: 1}}> + <Stack direction="column" alignItems="start" spacing={1}> + <Typography variant="subtitle2">Requis</Typography> + <Stack direction="row" alignItems="center" spacing={1}> + {job?.requiredBadges.map((badge, index) => <Chip key={badge} label={badge} variant="outlined" color={index%2 === 0? "success" : "error" } component="a" href="/dashboard" clickable/>)} + </Stack> + <Typography variant="subtitle2">Optionnels</Typography> + <Stack direction="row" alignItems="center" spacing={1}> + {job?.optionalBadges.map((badge) => <Chip key={badge} label={badge} variant="outlined" component="a" href="/dashboard" clickable/>)} + </Stack> + </Stack> + </Paper> + </Stack> + + <Button variant="contained" onClick={handleApply}>Postuler</Button> + + </Card> + ); + + const renderOverview = ( + <Card sx={{ p: 3, gap: 2, display: 'flex', flexDirection: 'column' }}> + {[ + { + label: 'Date de publication', + value: fDate(job?.createdAt), + icon: <Iconify icon="solar:calendar-date-bold" />, + }, + { + label: `Contrat`, + value: job?.contract, + icon: <Iconify icon="material-symbols:contract" />, + }, + { + label: `Type d'emploi`, + value: job?.employmentTypes.join(', '), + icon: <Iconify icon="solar:clock-circle-bold" />, + }, + { + label: 'Salaire', + value: job?.salary.negotiable ? 'Négociable' : fCurrency(job?.salary.price), + icon: <Iconify icon="solar:dollar-bold" />, + }, + { + label: 'Expérience', + value: job?.experience, + icon: <Iconify icon="carbon:skill-level-basic" />, + }, + { + label: 'Mobilités', + value: job?.locations.join(', '), + icon: <Iconify icon="material-symbols:file-map" />, + }, + ].map((item) => ( + <Stack key={item.label} spacing={1.5} direction="row"> + {item.icon} + <ListItemText + primary={item.label} + secondary={item.value} + primaryTypographyProps={{ typography: 'body2', color: 'text.secondary', mb: 0.5 }} + secondaryTypographyProps={{ + component: 'span', + color: 'text.primary', + typography: 'subtitle2', + }} + /> + </Stack> + ))} + </Card> + ); + + const renderCompany = ( + <Paper variant="outlined" sx={{ p: 3, mt: 3, gap: 2, borderRadius: 2, display: 'flex' }}> + <Avatar + alt={job?.company.name} + src={job?.company.logo} + variant="rounded" + sx={{ width: 64, height: 64 }} + /> + + <Stack spacing={1}> + <Typography variant="subtitle1">{job?.company.name}</Typography> + <Typography variant="body2">{job?.company.fullAddress}</Typography> + <Typography variant="body2">{job?.company.phoneNumber}</Typography> + </Stack> + </Paper> + ); + + return ( + <Grid container spacing={3}> + <Grid xs={12} md={8}> + {renderContent} + </Grid> + + <Grid xs={12} md={4}> + {renderOverview} + + {renderCompany} + + </Grid> + </Grid> + ); +} diff --git a/src/shared/sections/job/job-details-toolbar.tsx b/src/shared/sections/job/job-details-toolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..26598364b40989f0f5a4f4c77a5f9568fe21a3be --- /dev/null +++ b/src/shared/sections/job/job-details-toolbar.tsx @@ -0,0 +1,103 @@ +import type { StackProps } from '@mui/material/Stack'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/shared/components/iconify'; +import { usePopover, CustomPopover } from 'src/shared/components/custom-popover'; + +// ---------------------------------------------------------------------- + +type Props = StackProps & { + backLink: string; + editLink: string; + liveLink: string; + publish: string; + onChangePublish: (newValue: string) => void; + publishOptions: { + value: string; + label: string; + }[]; +}; + +export function JobDetailsToolbar({ + publish, + backLink, + editLink, + liveLink, + publishOptions, + onChangePublish, + sx, + ...other +}: Props) { + const popover = usePopover(); + + return ( + <> + <Stack spacing={1.5} direction="row" sx={{ mb: { xs: 3, md: 5 }, ...sx }} {...other}> + <Button + component={RouterLink} + href={backLink} + startIcon={<Iconify icon="eva:arrow-ios-back-fill" width={16} />} + > + Back + </Button> + + <Box sx={{ flexGrow: 1 }} /> + + {publish === 'published' && ( + <Tooltip title="Go Live"> + <IconButton component={RouterLink} href={liveLink}> + <Iconify icon="eva:external-link-fill" /> + </IconButton> + </Tooltip> + )} + + <Tooltip title="Edit"> + <IconButton component={RouterLink} href={editLink}> + <Iconify icon="solar:pen-bold" /> + </IconButton> + </Tooltip> + + <LoadingButton + color="inherit" + variant="contained" + loading={!publish} + loadingIndicator="Loading…" + endIcon={<Iconify icon="eva:arrow-ios-downward-fill" />} + onClick={popover.onOpen} + sx={{ textTransform: 'capitalize' }} + > + {publish} + </LoadingButton> + </Stack> + + <CustomPopover open={popover.open} anchorEl={popover.anchorEl} onClose={popover.onClose}> + <MenuList> + {publishOptions.map((option) => ( + <MenuItem + key={option.value} + selected={option.value === publish} + onClick={() => { + popover.onClose(); + onChangePublish(option.value); + }} + > + {option.value === 'published' && <Iconify icon="eva:cloud-upload-fill" />} + {option.value === 'draft' && <Iconify icon="solar:file-text-bold" />} + {option.label} + </MenuItem> + ))} + </MenuList> + </CustomPopover> + </> + ); +} diff --git a/src/shared/sections/job/job-filters-result.tsx b/src/shared/sections/job/job-filters-result.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f94fdeeab2dd65f0165eb117f03f15ea815365f9 --- /dev/null +++ b/src/shared/sections/job/job-filters-result.tsx @@ -0,0 +1,88 @@ +import type { IJobFilters } from 'src/types/job'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { UseSetStateReturn } from 'src/hooks/use-set-state'; + +import Chip from '@mui/material/Chip'; + +import { chipProps, FiltersBlock, FiltersResult } from 'src/shared/components/filters-result'; + +// ---------------------------------------------------------------------- + +type Props = { + totalResults: number; + sx?: SxProps<Theme>; + filters: UseSetStateReturn<IJobFilters>; +}; + +export function JobFiltersResult({ filters, totalResults, sx }: Props) { + const handleRemoveEmploymentTypes = (inputValue: string) => { + const newValue = filters.state.employmentTypes.filter((item) => item !== inputValue); + filters.setState({ employmentTypes: newValue }); + }; + + const handleRemoveExperience = () => { + filters.setState({ experience: 'all' }); + }; + + const handleRemoveRoles = (inputValue: string) => { + const newValue = filters.state.roles.filter((item) => item !== inputValue); + filters.setState({ roles: newValue }); + }; + + const handleRemoveLocations = (inputValue: string) => { + const newValue = filters.state.locations.filter((item) => item !== inputValue); + filters.setState({ locations: newValue }); + }; + + const handleRemoveBenefits = (inputValue: string) => { + const newValue = filters.state.benefits.filter((item) => item !== inputValue); + filters.setState({ benefits: newValue }); + }; + + return ( + <FiltersResult totalResults={totalResults} onReset={filters.onResetState} sx={sx}> + <FiltersBlock label="Employment types:" isShow={!!filters.state.employmentTypes.length}> + {filters.state.employmentTypes.map((item) => ( + <Chip + {...chipProps} + key={item} + label={item} + onDelete={() => handleRemoveEmploymentTypes(item)} + /> + ))} + </FiltersBlock> + + <FiltersBlock label="Experience:" isShow={filters.state.experience !== 'all'}> + <Chip {...chipProps} label={filters.state.experience} onDelete={handleRemoveExperience} /> + </FiltersBlock> + + <FiltersBlock label="Roles:" isShow={!!filters.state.roles.length}> + {filters.state.roles.map((item) => ( + <Chip {...chipProps} key={item} label={item} onDelete={() => handleRemoveRoles(item)} /> + ))} + </FiltersBlock> + + <FiltersBlock label="Locations:" isShow={!!filters.state.locations.length}> + {filters.state.locations.map((item) => ( + <Chip + {...chipProps} + key={item} + label={item} + onDelete={() => handleRemoveLocations(item)} + /> + ))} + </FiltersBlock> + + <FiltersBlock label="Benefits:" isShow={!!filters.state.benefits.length}> + {filters.state.benefits.map((item) => ( + <Chip + {...chipProps} + key={item} + label={item} + onDelete={() => handleRemoveBenefits(item)} + /> + ))} + </FiltersBlock> + </FiltersResult> + ); +} diff --git a/src/shared/sections/job/job-filters.tsx b/src/shared/sections/job/job-filters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ddaa646f9b56732592d607b4dcda520981138e25 --- /dev/null +++ b/src/shared/sections/job/job-filters.tsx @@ -0,0 +1,257 @@ +import type { IJobFilters } from 'src/types/job'; +import type { UseSetStateReturn } from 'src/hooks/use-set-state'; + +import { useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Radio from '@mui/material/Radio'; +import Stack from '@mui/material/Stack'; +import Badge from '@mui/material/Badge'; +import Drawer from '@mui/material/Drawer'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import Tooltip from '@mui/material/Tooltip'; +import Checkbox from '@mui/material/Checkbox'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import Autocomplete from '@mui/material/Autocomplete'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +import { Iconify } from 'src/shared/components/iconify'; +import { Scrollbar } from 'src/shared/components/scrollbar'; +import { CountrySelect } from 'src/shared/components/country-select'; + +// ---------------------------------------------------------------------- + +type Props = { + open: boolean; + canReset: boolean; + onOpen: () => void; + onClose: () => void; + filters: UseSetStateReturn<IJobFilters>; + options: { + roles: string[]; + benefits: string[]; + experiences: string[]; + employmentTypes: string[]; + }; +}; + +export function JobFilters({ open, canReset, onOpen, onClose, filters, options }: Props) { + const handleFilterEmploymentTypes = useCallback( + (newValue: string) => { + const checked = filters.state.employmentTypes.includes(newValue) + ? filters.state.employmentTypes.filter((value) => value !== newValue) + : [...filters.state.employmentTypes, newValue]; + + filters.setState({ employmentTypes: checked }); + }, + [filters] + ); + + const handleFilterExperience = useCallback( + (newValue: string) => { + filters.setState({ experience: newValue }); + }, + [filters] + ); + + const handleFilterRoles = useCallback( + (newValue: string[]) => { + filters.setState({ roles: newValue }); + }, + [filters] + ); + + const handleFilterLocations = useCallback( + (newValue: string[]) => { + filters.setState({ locations: newValue }); + }, + [filters] + ); + + const handleFilterBenefits = useCallback( + (newValue: string) => { + const checked = filters.state.benefits.includes(newValue) + ? filters.state.benefits.filter((value) => value !== newValue) + : [...filters.state.benefits, newValue]; + + filters.setState({ benefits: checked }); + }, + [filters] + ); + + const renderHead = ( + <> + <Box display="flex" alignItems="center" sx={{ py: 2, pr: 1, pl: 2.5 }}> + <Typography variant="h6" sx={{ flexGrow: 1 }}> + Filters + </Typography> + + <Tooltip title="Reset"> + <IconButton onClick={filters.onResetState}> + <Badge color="error" variant="dot" invisible={!canReset}> + <Iconify icon="solar:restart-bold" /> + </Badge> + </IconButton> + </Tooltip> + + <IconButton onClick={onClose}> + <Iconify icon="mingcute:close-line" /> + </IconButton> + </Box> + + <Divider sx={{ borderStyle: 'dashed' }} /> + </> + ); + + const renderEmploymentTypes = ( + <Box display="flex" flexDirection="column"> + <Typography variant="subtitle2" sx={{ mb: 1 }}> + Employment types + </Typography> + {options.employmentTypes.map((option) => ( + <FormControlLabel + key={option} + control={ + <Checkbox + checked={filters.state.employmentTypes.includes(option)} + onClick={() => handleFilterEmploymentTypes(option)} + /> + } + label={option} + /> + ))} + </Box> + ); + + const renderExperience = ( + <Box display="flex" flexDirection="column"> + <Typography variant="subtitle2" sx={{ mb: 1 }}> + Experience + </Typography> + {options.experiences.map((option) => ( + <FormControlLabel + key={option} + control={ + <Radio + checked={option === filters.state.experience} + onClick={() => handleFilterExperience(option)} + /> + } + label={option} + sx={{ ...(option === 'all' && { textTransform: 'capitalize' }) }} + /> + ))} + </Box> + ); + + const renderRoles = ( + <Box display="flex" flexDirection="column"> + <Typography variant="subtitle2" sx={{ mb: 1.5 }}> + Roles + </Typography> + <Autocomplete + multiple + disableCloseOnSelect + options={options.roles.map((option) => option)} + getOptionLabel={(option) => option} + value={filters.state.roles} + onChange={(event, newValue) => handleFilterRoles(newValue)} + renderInput={(params) => <TextField placeholder="Select Roles" {...params} />} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + <Chip + {...getTagProps({ index })} + key={option} + label={option} + size="small" + variant="soft" + /> + )) + } + /> + </Box> + ); + + const renderLocations = ( + <Box display="flex" flexDirection="column"> + <Typography variant="subtitle2" sx={{ mb: 1.5 }}> + Locations + </Typography> + + <CountrySelect + id="multiple-locations" + multiple + fullWidth + placeholder={filters.state.locations.length ? '+ Locations' : 'Select Locations'} + value={filters.state.locations} + onChange={(event, newValue) => handleFilterLocations(newValue)} + /> + </Box> + ); + + const renderBenefits = ( + <Box display="flex" flexDirection="column"> + <Typography variant="subtitle2" sx={{ mb: 1 }}> + Benefits + </Typography> + {options.benefits.map((option) => ( + <FormControlLabel + key={option} + control={ + <Checkbox + checked={filters.state.benefits.includes(option)} + onClick={() => handleFilterBenefits(option)} + /> + } + label={option} + /> + ))} + </Box> + ); + + return ( + <> + <Button + disableRipple + color="inherit" + endIcon={ + <Badge color="error" variant="dot" invisible={!canReset}> + <Iconify icon="ic:round-filter-list" /> + </Badge> + } + onClick={onOpen} + > + Filters + </Button> + + <Drawer + anchor="right" + open={open} + onClose={onClose} + slotProps={{ backdrop: { invisible: true } }} + PaperProps={{ sx: { width: 320 } }} + > + {renderHead} + + <Scrollbar sx={{ px: 2.5, py: 3 }}> + <Stack spacing={3}> + {renderEmploymentTypes} + {renderExperience} + {renderRoles} + {renderLocations} + {renderBenefits} + </Stack> + </Scrollbar> + </Drawer> + </> + ); +} diff --git a/src/shared/sections/job/job-item.tsx b/src/shared/sections/job/job-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7abc983e4673d9e2e62ae53a9e90b785d3c7f92 --- /dev/null +++ b/src/shared/sections/job/job-item.tsx @@ -0,0 +1,162 @@ +import type { IJobItem } from 'src/types/job'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Card from '@mui/material/Card'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import Divider from '@mui/material/Divider'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import ListItemText from '@mui/material/ListItemText'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { fDate } from 'src/utils/format-time'; +import { fCurrency } from 'src/utils/format-number'; + +import { Iconify } from 'src/shared/components/iconify'; +import { usePopover, CustomPopover } from 'src/shared/components/custom-popover'; + +// ---------------------------------------------------------------------- + +type Props = { + job: IJobItem; + onView: () => void; + onEdit: () => void; + onDelete: () => void; +}; + +export function JobItem({ job, onView, onEdit, onDelete }: Props) { + const popover = usePopover(); + + return ( + <> + <Card> + <IconButton onClick={popover.onOpen} sx={{ position: 'absolute', top: 8, right: 8 }}> + <Iconify icon="eva:more-vertical-fill" /> + </IconButton> + + <Stack sx={{ p: 3, pb: 2 }}> + <Avatar + alt={job.company.name} + src={job.company.logo} + variant="rounded" + sx={{ width: 48, height: 48, mb: 2 }} + /> + + <ListItemText + sx={{ mb: 1 }} + primary={ + <Link + component={RouterLink} + href={paths.dashboard.job.details(job.id)} + color="inherit" + > + {job.title} + </Link> + } + secondary={`Posted date: ${fDate(job.createdAt)}`} + primaryTypographyProps={{ typography: 'subtitle1' }} + secondaryTypographyProps={{ + mt: 1, + component: 'span', + typography: 'caption', + color: 'text.disabled', + }} + /> + + <Stack + spacing={0.5} + direction="row" + alignItems="center" + sx={{ color: 'primary.main', typography: 'caption' }} + > + <Iconify width={16} icon="solar:users-group-rounded-bold" /> + {job.candidates.length} candidates + </Stack> + </Stack> + + <Divider sx={{ borderStyle: 'dashed' }} /> + + <Box rowGap={1.5} display="grid" gridTemplateColumns="repeat(2, 1fr)" sx={{ p: 3 }}> + {[ + { + label: job.experience, + icon: <Iconify width={16} icon="carbon:skill-level-basic" sx={{ flexShrink: 0 }} />, + }, + { + label: job.employmentTypes.join(', '), + icon: <Iconify width={16} icon="solar:clock-circle-bold" sx={{ flexShrink: 0 }} />, + }, + { + label: job.salary.negotiable ? 'Negotiable' : fCurrency(job.salary.price), + icon: <Iconify width={16} icon="solar:wad-of-money-bold" sx={{ flexShrink: 0 }} />, + }, + { + label: job.role, + icon: <Iconify width={16} icon="solar:user-rounded-bold" sx={{ flexShrink: 0 }} />, + }, + ].map((item) => ( + <Stack + key={item.label} + spacing={0.5} + flexShrink={0} + direction="row" + alignItems="center" + sx={{ color: 'text.disabled', minWidth: 0 }} + > + {item.icon} + <Typography variant="caption" noWrap> + {item.label} + </Typography> + </Stack> + ))} + </Box> + </Card> + + <CustomPopover + open={popover.open} + anchorEl={popover.anchorEl} + onClose={popover.onClose} + slotProps={{ arrow: { placement: 'right-top' } }} + > + <MenuList> + <MenuItem + onClick={() => { + popover.onClose(); + onView(); + }} + > + <Iconify icon="solar:eye-bold" /> + View + </MenuItem> + + <MenuItem + onClick={() => { + popover.onClose(); + onEdit(); + }} + > + <Iconify icon="solar:pen-bold" /> + Edit + </MenuItem> + + <MenuItem + onClick={() => { + popover.onClose(); + onDelete(); + }} + sx={{ color: 'error.main' }} + > + <Iconify icon="solar:trash-bin-trash-bold" /> + Delete + </MenuItem> + </MenuList> + </CustomPopover> + </> + ); +} diff --git a/src/shared/sections/job/job-list.tsx b/src/shared/sections/job/job-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af3f173d4a406c6b0db644a13152bbb43fde8309 --- /dev/null +++ b/src/shared/sections/job/job-list.tsx @@ -0,0 +1,69 @@ +import type { IJobItem } from 'src/types/job'; + +import { useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Pagination, { paginationClasses } from '@mui/material/Pagination'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { JobItem } from './job-item'; + +// ---------------------------------------------------------------------- + +type Props = { + jobs: IJobItem[]; +}; + +export function JobList({ jobs }: Props) { + const router = useRouter(); + + const handleView = useCallback( + (id: string) => { + router.push(paths.dashboard.job.details(id)); + }, + [router] + ); + + const handleEdit = useCallback( + (id: string) => { + router.push(paths.dashboard.job.edit(id)); + }, + [router] + ); + + const handleDelete = useCallback((id: string) => { + console.info('DELETE', id); + }, []); + + return ( + <> + <Box + gap={3} + display="grid" + gridTemplateColumns={{ xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' }} + > + {jobs.map((job) => ( + <JobItem + key={job.id} + job={job} + onView={() => handleView(job.id)} + onEdit={() => handleEdit(job.id)} + onDelete={() => handleDelete(job.id)} + /> + ))} + </Box> + + {jobs.length > 8 && ( + <Pagination + count={8} + sx={{ + mt: { xs: 8, md: 8 }, + [`& .${paginationClasses.ul}`]: { justifyContent: 'center' }, + }} + /> + )} + </> + ); +} diff --git a/src/shared/sections/job/job-new-edit-form.tsx b/src/shared/sections/job/job-new-edit-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4a6d280f7e68d5b450af26deb386299dfdc81082 --- /dev/null +++ b/src/shared/sections/job/job-new-edit-form.tsx @@ -0,0 +1,555 @@ +import type { IJobItem } from 'src/types/job'; + +import { z as zod } from 'zod'; +import { useMemo, useEffect } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm, Controller, useFieldArray } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Card from '@mui/material/Card'; +import Stack from '@mui/material/Stack'; +import Paper from '@mui/material/Paper'; +import Switch from '@mui/material/Switch'; +import Divider from '@mui/material/Divider'; +import ButtonBase from '@mui/material/ButtonBase'; +import CardHeader from '@mui/material/CardHeader'; +import Typography from '@mui/material/Typography'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { + _roles, + _badges, + JOB_SKILL_OPTIONS, + JOB_CONTRACT_OPTIONS, + JOB_EXPERIENCE_OPTIONS, + JOB_EMPLOYMENT_TYPE_OPTIONS, + JOB_WORKING_SCHEDULE_OPTIONS, +} from 'src/shared/_mock'; + +import { toast } from 'src/shared/components/snackbar'; +import { Iconify } from 'src/shared/components/iconify'; +import { Form, Field, schemaHelper } from 'src/shared/components/hook-form'; +import CloseIcon from '@mui/icons-material/Close'; +import { Button, IconButton } from '@mui/material'; + +// ---------------------------------------------------------------------- + + +export const NewJobSchema = zod.object({ + title: zod.string().min(1, { message: 'Le titre est requis!' }), + content: zod.string().min(50, { message: 'Le Contenu est requis!' }), + employmentTypes: zod.string().array().nonempty({ message: 'Choisissez au moins une option!' }), + contract : zod.string().min(1, { message: 'Choisissez au moins une option!' }), + role: schemaHelper.objectOrNull<string | null>({ + message: { required_error: 'Le rôle est requis!' }, + }), + skills: zod.string().array().nonempty({ message: 'Choisissez au moins une option!' }), + optionalBadges: zod.string().array(), + requiredBadges: zod.string().array().nonempty({ message: 'Choisissez au moins une option!' }), + // workingSchedule: zod.string().array().nonempty({ message: 'Choisissez au moins une option!' }), + locations: zod.string().array().nonempty({ message: 'Choisissez au moins une option!' }), + salary: zod.object({ + price: zod.number(), + // Not required + type: zod.string(), + negotiable: zod.boolean(), + }), + questions: zod.array(zod.object({ + question: zod.string().min(1, { message: 'Veuillez saisir la question!' }), + answer: zod.string().min(1, { message: 'Veuillez saisir la reponse!' }) + })), + acceptedObject: zod.string(), + acceptedBody: zod.string(), + refusedObject: zod.string(), + refusedBody: zod.string(), + // // Not required + experience: zod.string(), +}); + +export type NewJobSchemaType = zod.infer<typeof NewJobSchema>; +// ---------------------------------------------------------------------- + +type Props = { + currentJob?: IJobItem; +}; + +export function JobNewEditForm({ currentJob }: Props) { + const router = useRouter(); + + const defaultValues = useMemo( + () => ({ + title: currentJob?.title || '', + content: currentJob?.content || '', + employmentTypes: currentJob?.employmentTypes || [], + experience: currentJob?.experience || '1 year exp', + contract: currentJob?.contract || '', + role: currentJob?.role || _roles[1], + skills: currentJob?.skills || [], + optionalBadges: currentJob?.optionalBadges || [], + requiredBadges: currentJob?.requiredBadges || [], + // workingSchedule: currentJob?.workingSchedule || [], + locations: currentJob?.locations || [], + salary: currentJob?.salary || { type: 'Hourly', price: 0, negotiable: false }, + acceptedObject: currentJob?.accepted.object || '', + acceptedBody: currentJob?.accepted.body || '', + refusedObject: currentJob?.rejected.object || '', + refusedBody: currentJob?.rejected.body || '', + questions: currentJob?.questions || [], + }), + [currentJob] + ); + + const methods = useForm<NewJobSchemaType>({ + mode: 'all', + resolver: zodResolver(NewJobSchema), + defaultValues, + }); + + const { + reset, + control, + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const { fields: questions, append, remove} = useFieldArray({ + control, + name: 'questions', + }); + + + + useEffect(() => { + if (currentJob) { + reset(defaultValues); + } + }, [currentJob, defaultValues, reset]); + + const onSubmit = handleSubmit(async (data) => { + try { + + // await new Promise((resolve) => setTimeout(resolve, 500)); + + // reset(); + // toast.success(currentJob ? 'Mise à jour avesc succès!' : 'Création avec succès!'); + // router.push(paths.dashboard.job.root); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + + + const renderDetails = ( + <Card> + <CardHeader title="Détails" subheader="Titre, description, image..." sx={{ mb: 3 }} /> + + <Divider /> + + <Stack spacing={3} sx={{ p: 3 }}> + <Stack spacing={1.5}> + <Typography variant="subtitle2">Titre</Typography> + <Field.Text name="title" placeholder="Ex: Ingénieur logiciel..." /> + </Stack> + + <Stack spacing={1.5}> + <Typography variant="subtitle2">Description</Typography> + <Field.Editor name="content" sx={{ maxHeight: 480 }} /> + </Stack> + + {/* <Stack spacing={1.5}> + <Typography variant="subtitle2">Company</Typography> + <Field.Autocomplete + name="company" + autoHighlight + defaultValue={_companyNames[0]} + options={_companyNames.map((option) => option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + /> + </Stack> */} + </Stack> + </Card> + ); + + const renderProperties = ( + <Card> + <CardHeader + title="Information supplémentaire" + subheader="Fonctions et attributs supplémentaires..." + sx={{ mb: 3 }} + /> + + <Divider /> + + <Stack spacing={3} sx={{ p: 3 }}> + <Stack spacing={1}> + <Typography variant="subtitle2">Type d'emploi</Typography> + <Field.MultiCheckbox + row + name="employmentTypes" + options={JOB_EMPLOYMENT_TYPE_OPTIONS} + sx={{ gap: 4 }} + /> + </Stack> + + <Stack spacing={1}> + <Typography variant="subtitle2">Expérience</Typography> + <Field.RadioGroup + row + name="experience" + options={JOB_EXPERIENCE_OPTIONS} + sx={{ gap: 4 }} + /> + </Stack> + <Stack spacing={1}> + <Typography variant="subtitle2">Type de contrat</Typography> + <Field.RadioGroup + row + name="contract" + options={JOB_CONTRACT_OPTIONS} + sx={{ gap: 4 }} + /> + </Stack> + + <Stack spacing={1.5}> + <Typography variant="subtitle2">Poste</Typography> + <Field.Autocomplete + name="role" + placeholder="Poste" + value="" + autoHighlight + freeSolo + options={_roles.map((option) => option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + /> + </Stack> + + <Stack spacing={1.5}> + <Typography variant="subtitle2">Compétences</Typography> + <Field.Autocomplete + name="skills" + placeholder="+ Compétence" + multiple + disableCloseOnSelect + freeSolo + options={JOB_SKILL_OPTIONS.map((option) => option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + <Chip + {...getTagProps({ index })} + key={option} + label={option} + size="small" + color="info" + variant="soft" + /> + )) + } + /> + </Stack> + + <Stack spacing={1.5}> + <Typography variant="subtitle2"> + Badges + <Chip + variant="outlined" + color="success" + label="HIRE-3" + size="small" + sx={{ + marginInline: '5px', + borderRadius: '5px', + borderWidth: '2px', + + }} + /> + </Typography> + <Paper elevation={1} sx={{p:2}}> + <Typography variant="subtitle2">Requis</Typography> + <Field.Autocomplete + name="requiredBadges" + placeholder="+ Badge" + multiple + disableCloseOnSelect + options={_badges.map((option) => option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + <Chip + {...getTagProps({ index })} + key={option} + label={option} + size="small" + color="info" + variant="soft" + /> + )) + } + /> + <Typography variant="subtitle2">Optionnels</Typography> + <Field.Autocomplete + name="optionalBadges" + placeholder="+ Badge" + multiple + disableCloseOnSelect + options={_badges.map((option) => option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + <Chip + {...getTagProps({ index })} + key={option} + label={option} + size="small" + color="info" + variant="soft" + /> + )) + } + /> + </Paper> + </Stack> + + {/* <Stack spacing={1.5}> + <Typography variant="subtitle2">Plan de travail</Typography> + <Field.Autocomplete + name="workingSchedule" + placeholder="+ Plan" + multiple + disableCloseOnSelect + options={JOB_WORKING_SCHEDULE_OPTIONS.map((option) => option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( + <li {...props} key={option}> + {option} + </li> + )} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + <Chip + {...getTagProps({ index })} + key={option} + label={option} + size="small" + color="info" + variant="soft" + /> + )) + } + /> + </Stack> */} + + <Stack spacing={1.5}> + <Typography variant="subtitle2">Mobilités</Typography> + <Field.CountrySelect multiple name="locations" placeholder="+ Mobilité" /> + </Stack> + + + + <Stack spacing={2}> + <Typography variant="subtitle2">Salaire</Typography> + + <Controller + name="salary.type" + control={control} + render={({ field }) => ( + <Box gap={2} display="grid" gridTemplateColumns="repeat(2, 1fr)"> + {[ + { + label: 'Mensuel', + icon: <Iconify icon="solar:calendar-bold" width={32} sx={{ mb: 2 }} />, + }, + { + label: 'Fixe', + icon: <Iconify icon="solar:dollar-bold" width={32} sx={{ mb: 2 }} />, + }, + ].map((item) => ( + <Paper + component={ButtonBase} + variant="outlined" + key={item.label} + onClick={() => field.onChange(item.label)} + sx={{ + p: 2.5, + borderRadius: 1, + typography: 'subtitle2', + flexDirection: 'column', + ...(item.label === field.value && { + borderWidth: 2, + borderColor: 'text.primary', + }), + }} + > + {item.icon} + {item.label} + </Paper> + ))} + </Box> + )} + /> + + <Field.Text + name="salary.price" + placeholder="0.00" + type="number" + InputProps={{ + startAdornment: ( + <InputAdornment position="start"> + <Box sx={{ typography: 'subtitle2', color: 'text.disabled' }}>$</Box> + </InputAdornment> + ), + }} + /> + <Field.Switch name="salary.negotiable" label="Négociable" /> + </Stack> + + {/* <Stack spacing={1}> + <Typography variant="subtitle2">Benefits</Typography> + <Field.MultiCheckbox + name="benefits" + options={JOB_BENEFIT_OPTIONS} + sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)' }} + /> + </Stack> */} + </Stack> + </Card> + ); + + const renderContact = ( + <Card> + <CardHeader title="Contact" sx={{ mb: 3 }} /> + + <Divider /> + + <Stack spacing={3} sx={{ p: 3 }}> + + + <Stack spacing={1.5}> + <Typography variant="h6">Mail d'acceptation par défaut</Typography> + <Typography variant="subtitle2">Objet</Typography> + <Field.Text name="accpetedObject" placeholder="Ex: Félicitations ! Votre candidature a été retenue" /> + <Typography variant="subtitle2">Corp</Typography> + <Field.Editor name="acceptedBody" sx={{ maxHeight: 480 }} /> + </Stack> + <Stack spacing={1.5}> + <Typography variant="h6">Mail de refus par défaut</Typography> + <Typography variant="subtitle2">Objet</Typography> + <Field.Text name="refusedObject" placeholder="Ex: Votre candidature" /> + <Typography variant="subtitle2">Corp</Typography> + <Field.Editor name="refusedBody" sx={{ maxHeight: 480 }} /> + </Stack> + + + </Stack> + </Card> + ); + + const renderQuestions = ( + <Card> + <CardHeader title="Questions de présélection" sx={{ mb: 3 }} /> + + <Divider /> + + <Stack spacing={3} sx={{ p: 3 }}> + <Typography variant="subtitle1">Ajouter des questions de présélection</Typography> + + {questions.map((item, index) => ( + <Paper sx={{ p: 3, position: 'relative', }} key={item.id} elevation={2}> + <Stack spacing={1.5} > + <Typography variant="subtitle2">Question</Typography> + <Field.Text name={`questions[${index}].question`} /> + <Typography variant="subtitle2">Réponse</Typography> + <Field.Text name={`questions[${index}].answer`} /> + <IconButton + aria-label="remove" + onClick={() => remove(index)} + sx={{ + position: 'absolute', + top: 0, + right: 0, + }} + > + <CloseIcon fontSize="small" /> + </IconButton> + </Stack> + </Paper> + ))} + + <Button variant="outlined" onClick={() => append({ question: '', answer: '' })}>+</Button> + </Stack> + </Card> + ); + + const renderActions = ( + <Box display="flex" alignItems="center" flexWrap="wrap"> + <FormControlLabel + control={<Switch defaultChecked inputProps={{ id: 'publish-switch' }} />} + label="Publier" + sx={{ flexGrow: 1, pl: 3 }} + /> + + <LoadingButton + type="submit" + variant="contained" + size="large" + loading={isSubmitting} + sx={{ ml: 2 }} + > + {!currentJob ? 'Créer offre' : 'Enregistrer les modifications'} + </LoadingButton> + + + </Box> + ); + + + + return ( + <Form methods={methods} onSubmit={onSubmit}> + <Stack spacing={{ xs: 3, md: 5 }} sx={{ mx: 'auto', maxWidth: { xs: 720, xl: 880 } }}> + {renderDetails} + + {renderProperties} + + {renderContact} + + {renderQuestions} + + {renderActions} + </Stack> + </Form> + ); +} diff --git a/src/shared/sections/job/job-search.tsx b/src/shared/sections/job/job-search.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e86ebec6ac7f4c2aac1bfd0d19e89c67353fcb4 --- /dev/null +++ b/src/shared/sections/job/job-search.tsx @@ -0,0 +1,99 @@ +import type { IJobItem } from 'src/types/job'; +import type { UseSetStateReturn } from 'src/hooks/use-set-state'; + +import parse from 'autosuggest-highlight/parse'; +import match from 'autosuggest-highlight/match'; + +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Autocomplete from '@mui/material/Autocomplete'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { Iconify } from 'src/shared/components/iconify'; +import { SearchNotFound } from 'src/shared/components/search-not-found'; + +// ---------------------------------------------------------------------- + +type Props = { + onSearch: (inputValue: string) => void; + search: UseSetStateReturn<{ + query: string; + results: IJobItem[]; + }>; +}; + +export function JobSearch({ search, onSearch }: Props) { + const router = useRouter(); + + const handleClick = (id: string) => { + router.push(paths.dashboard.job.details(id)); + }; + + const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (search.state.query) { + if (event.key === 'Enter') { + const selectProduct = search.state.results.filter( + (job) => job.title === search.state.query + )[0]; + + handleClick(selectProduct.id); + } + } + }; + + return ( + <Autocomplete + sx={{ width: { xs: 1, sm: 260 } }} + autoHighlight + popupIcon={null} + options={search.state.results} + onInputChange={(event, newValue) => onSearch(newValue)} + getOptionLabel={(option) => option.title} + noOptionsText={<SearchNotFound query={search.state.query} />} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderInput={(params) => ( + <TextField + {...params} + placeholder="Search..." + onKeyUp={handleKeyUp} + InputProps={{ + ...params.InputProps, + startAdornment: ( + <InputAdornment position="start"> + <Iconify icon="eva:search-fill" sx={{ ml: 1, color: 'text.disabled' }} /> + </InputAdornment> + ), + }} + /> + )} + renderOption={(props, job, { inputValue }) => { + const matches = match(job.title, inputValue); + const parts = parse(job.title, matches); + + return ( + <Box component="li" {...props} onClick={() => handleClick(job.id)} key={job.id}> + <div> + {parts.map((part, index) => ( + <Typography + key={index} + component="span" + color={part.highlight ? 'primary' : 'textPrimary'} + sx={{ + typography: 'body2', + fontWeight: part.highlight ? 'fontWeightSemiBold' : 'fontWeightMedium', + }} + > + {part.text} + </Typography> + ))} + </div> + </Box> + ); + }} + /> + ); +} diff --git a/src/shared/sections/job/job-sort.tsx b/src/shared/sections/job/job-sort.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad0f2f184390b1ada23ab3765eabc35e643ed62c --- /dev/null +++ b/src/shared/sections/job/job-sort.tsx @@ -0,0 +1,63 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; + +import { Iconify } from 'src/shared/components/iconify'; +import { usePopover, CustomPopover } from 'src/shared/components/custom-popover'; + +// ---------------------------------------------------------------------- + +type Props = { + sort: string; + onSort: (newValue: string) => void; + sortOptions: { + value: string; + label: string; + }[]; +}; + +export function JobSort({ sort, onSort, sortOptions }: Props) { + const popover = usePopover(); + + return ( + <> + <Button + disableRipple + color="inherit" + onClick={popover.onOpen} + endIcon={ + <Iconify + icon={popover.open ? 'eva:arrow-ios-upward-fill' : 'eva:arrow-ios-downward-fill'} + /> + } + sx={{ fontWeight: 'fontWeightSemiBold' }} + > + Sort by: + <Box + component="span" + sx={{ ml: 0.5, fontWeight: 'fontWeightBold', textTransform: 'capitalize' }} + > + {sort} + </Box> + </Button> + + <CustomPopover open={popover.open} anchorEl={popover.anchorEl} onClose={popover.onClose}> + <MenuList> + {sortOptions.map((option) => ( + <MenuItem + key={option.value} + selected={option.value === sort} + onClick={() => { + popover.onClose(); + onSort(option.value); + }} + > + {option.label} + </MenuItem> + ))} + </MenuList> + </CustomPopover> + </> + ); +} diff --git a/src/shared/sections/job/view/index.ts b/src/shared/sections/job/view/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f96715a2cbfbb53e7c493a40e1d85991d82e132 --- /dev/null +++ b/src/shared/sections/job/view/index.ts @@ -0,0 +1,7 @@ +export * from './job-list-view'; + +export * from './job-edit-view'; + +export * from './job-create-view'; + +export * from './job-details-view'; diff --git a/src/shared/sections/job/view/job-create-view.tsx b/src/shared/sections/job/view/job-create-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2047935b5df7b245db728f2d7c63944c44ac2527 --- /dev/null +++ b/src/shared/sections/job/view/job-create-view.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { paths } from 'src/routes/paths'; + +import { DashboardContent } from 'src/shared/layouts/dashboard'; + +import { CustomBreadcrumbs } from 'src/shared/components/custom-breadcrumbs'; + +import { JobNewEditForm } from '../job-new-edit-form'; + +// ---------------------------------------------------------------------- + +export function JobCreateView() { + return ( + <DashboardContent> + <CustomBreadcrumbs + heading="Créer une nouvelle offre" + links={[ + { name: 'Dashboard', href: paths.dashboard.root }, + { name: `Offres d'emploi`, href: paths.dashboard.job.root }, + { name: 'Nouvelle Offre' }, + ]} + sx={{ mb: { xs: 3, md: 5 } }} + /> + + <JobNewEditForm /> + </DashboardContent> + ); +} diff --git a/src/shared/sections/job/view/job-details-view.tsx b/src/shared/sections/job/view/job-details-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7b2e357230b04de9e87b102b20a9e6482c599e2b --- /dev/null +++ b/src/shared/sections/job/view/job-details-view.tsx @@ -0,0 +1,75 @@ +'use client'; + +import type { IJobItem } from 'src/types/job'; + +import { useState, useCallback } from 'react'; + +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; + +import { paths } from 'src/routes/paths'; + +import { useTabs } from 'src/hooks/use-tabs'; + +import { DashboardContent } from 'src/shared/layouts/dashboard'; +import { JOB_DETAILS_TABS, JOB_PUBLISH_OPTIONS } from 'src/shared/_mock'; + +import { Label } from 'src/shared/components/label'; + +import { JobDetailsToolbar } from '../job-details-toolbar'; +import { JobDetailsContent } from '../job-details-content'; +import { JobDetailsCandidates } from '../job-details-candidates'; + +// ---------------------------------------------------------------------- + +type Props = { + job?: IJobItem; +}; + +export function JobDetailsView({ job }: Props) { + const tabs = useTabs('content'); + + const [publish, setPublish] = useState(job?.publish); + + const handleChangePublish = useCallback((newValue: string) => { + setPublish(newValue); + }, []); + + const renderTabs = ( + <Tabs value={tabs.value} onChange={tabs.onChange} sx={{ mb: { xs: 3, md: 5 } }}> + {JOB_DETAILS_TABS.map((tab) => ( + <Tab + key={tab.value} + iconPosition="end" + value={tab.value} + label={tab.label} + icon={ + tab.value === 'candidates' ? ( + <Label variant="filled">{job?.candidates.length}</Label> + ) : ( + '' + ) + } + /> + ))} + </Tabs> + ); + + return ( + <DashboardContent> + <JobDetailsToolbar + backLink={paths.dashboard.job.root} + editLink={paths.dashboard.job.edit(`${job?.id}`)} + liveLink="#" + publish={publish || ''} + onChangePublish={handleChangePublish} + publishOptions={JOB_PUBLISH_OPTIONS} + /> + {renderTabs} + + {tabs.value === 'content' && <JobDetailsContent job={job} />} + + {tabs.value === 'candidates' && <JobDetailsCandidates candidates={job?.candidates ?? []} />} + </DashboardContent> + ); +} diff --git a/src/shared/sections/job/view/job-edit-view.tsx b/src/shared/sections/job/view/job-edit-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..44f31d0c80a2676416dc36c3025ef35e1834cfeb --- /dev/null +++ b/src/shared/sections/job/view/job-edit-view.tsx @@ -0,0 +1,35 @@ +'use client'; + +import type { IJobItem } from 'src/types/job'; + +import { paths } from 'src/routes/paths'; + +import { DashboardContent } from 'src/shared/layouts/dashboard'; + +import { CustomBreadcrumbs } from 'src/shared/components/custom-breadcrumbs'; + +import { JobNewEditForm } from '../job-new-edit-form'; + +// ---------------------------------------------------------------------- + +type Props = { + job?: IJobItem; +}; + +export function JobEditView({ job }: Props) { + return ( + <DashboardContent> + <CustomBreadcrumbs + heading="Modifier" + links={[ + { name: 'Dashboard', href: paths.dashboard.root }, + { name: `Offres d'emploi`, href: paths.dashboard.job.root }, + { name: job?.title }, + ]} + sx={{ mb: { xs: 3, md: 5 } }} + /> + + <JobNewEditForm currentJob={job} /> + </DashboardContent> + ); +} diff --git a/src/shared/sections/job/view/job-list-view.tsx b/src/shared/sections/job/view/job-list-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51d38fa60b623a05543d08175a4eddebb6fae1df --- /dev/null +++ b/src/shared/sections/job/view/job-list-view.tsx @@ -0,0 +1,199 @@ +'use client'; + +import type { IJobItem, IJobFilters } from 'src/types/job'; + +import { useState, useCallback } from 'react'; + +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { useBoolean } from 'src/hooks/use-boolean'; +import { useSetState } from 'src/hooks/use-set-state'; + +import { orderBy } from 'src/utils/helper'; + +import { DashboardContent } from 'src/shared/layouts/dashboard'; +import { + _jobs, + _roles, + JOB_SORT_OPTIONS, + JOB_BENEFIT_OPTIONS, + JOB_EXPERIENCE_OPTIONS, + JOB_EMPLOYMENT_TYPE_OPTIONS, +} from 'src/shared/_mock'; + +import { Iconify } from 'src/shared/components/iconify'; +import { EmptyContent } from 'src/shared/components/empty-content'; +import { CustomBreadcrumbs } from 'src/shared/components/custom-breadcrumbs'; + +import { JobList } from '../job-list'; +import { JobSort } from '../job-sort'; +import { JobSearch } from '../job-search'; +import { JobFilters } from '../job-filters'; +import { JobFiltersResult } from '../job-filters-result'; + +// ---------------------------------------------------------------------- + +export function JobListView() { + const openFilters = useBoolean(); + + const [sortBy, setSortBy] = useState('latest'); + + const search = useSetState<{ + query: string; + results: IJobItem[]; + }>({ query: '', results: [] }); + + const filters = useSetState<IJobFilters>({ + roles: [], + locations: [], + benefits: [], + experience: 'all', + employmentTypes: [], + }); + + const dataFiltered = applyFilter({ inputData: _jobs, filters: filters.state, sortBy }); + + const canReset = + filters.state.roles.length > 0 || + filters.state.locations.length > 0 || + filters.state.benefits.length > 0 || + filters.state.employmentTypes.length > 0 || + filters.state.experience !== 'all'; + + const notFound = !dataFiltered.length && canReset; + + const handleSortBy = useCallback((newValue: string) => { + setSortBy(newValue); + }, []); + + const handleSearch = useCallback( + (inputValue: string) => { + search.setState({ query: inputValue }); + + if (inputValue) { + const results = _jobs.filter( + (job) => job.title.toLowerCase().indexOf(search.state.query.toLowerCase()) !== -1 + ); + + search.setState({ results }); + } + }, + [search] + ); + + const renderFilters = ( + <Stack + spacing={3} + justifyContent="space-between" + alignItems={{ xs: 'flex-end', sm: 'center' }} + direction={{ xs: 'column', sm: 'row' }} + > + <JobSearch search={search} onSearch={handleSearch} /> + + <Stack direction="row" spacing={1} flexShrink={0}> + <JobFilters + filters={filters} + canReset={canReset} + open={openFilters.value} + onOpen={openFilters.onTrue} + onClose={openFilters.onFalse} + options={{ + roles: _roles, + benefits: JOB_BENEFIT_OPTIONS.map((option) => option.label), + employmentTypes: JOB_EMPLOYMENT_TYPE_OPTIONS.map((option) => option.label), + experiences: ['all', ...JOB_EXPERIENCE_OPTIONS.map((option) => option.label)], + }} + /> + + <JobSort sort={sortBy} onSort={handleSortBy} sortOptions={JOB_SORT_OPTIONS} /> + </Stack> + </Stack> + ); + + const renderResults = <JobFiltersResult filters={filters} totalResults={dataFiltered.length} />; + + return ( + <DashboardContent> + <CustomBreadcrumbs + heading="List" + links={[ + { name: 'Dashboard', href: paths.dashboard.root }, + { name: 'Job', href: paths.dashboard.job.root }, + { name: 'List' }, + ]} + action={ + <Button + component={RouterLink} + href={paths.dashboard.job.new} + variant="contained" + startIcon={<Iconify icon="mingcute:add-line" />} + > + New job + </Button> + } + sx={{ mb: { xs: 3, md: 5 } }} + /> + + <Stack spacing={2.5} sx={{ mb: { xs: 3, md: 5 } }}> + {renderFilters} + + {canReset && renderResults} + </Stack> + + {notFound && <EmptyContent filled sx={{ py: 10 }} />} + + <JobList jobs={dataFiltered} /> + </DashboardContent> + ); +} + +// ---------------------------------------------------------------------- + +type ApplyFilterProps = { + inputData: IJobItem[]; + filters: IJobFilters; + sortBy: string; +}; + +const applyFilter = ({ inputData, filters, sortBy }: ApplyFilterProps) => { + const { employmentTypes, experience, roles, locations } = filters; + + // Sort by + if (sortBy === 'latest') { + inputData = orderBy(inputData, ['createdAt'], ['desc']); + } + + if (sortBy === 'oldest') { + inputData = orderBy(inputData, ['createdAt'], ['asc']); + } + + if (sortBy === 'popular') { + inputData = orderBy(inputData, ['totalViews'], ['desc']); + } + + // Filters + if (employmentTypes.length) { + inputData = inputData.filter((job) => + job.employmentTypes.some((item) => employmentTypes.includes(item)) + ); + } + + if (experience !== 'all') { + inputData = inputData.filter((job) => job.experience === experience); + } + + if (roles.length) { + inputData = inputData.filter((job) => roles.includes(job.role)); + } + + if (locations.length) { + inputData = inputData.filter((job) => job.locations.some((item) => locations.includes(item))); + } + + + return inputData; +}; diff --git a/src/types/blog.ts b/src/types/blog.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe8efc49d105f4f11339a26a177224afcd1d38a0 --- /dev/null +++ b/src/types/blog.ts @@ -0,0 +1,51 @@ +import type { IDateValue } from './common'; + +// ---------------------------------------------------------------------- + +export type IPostFilters = { + publish: string; +}; + +export type IPostHero = { + title: string; + coverUrl: string; + createdAt?: IDateValue; + author?: { name: string; avatarUrl: string }; +}; + +export type IPostComment = { + id: string; + name: string; + avatarUrl: string; + message: string; + postedAt: IDateValue; + users: { id: string; name: string; avatarUrl: string }[]; + replyComment: { + id: string; + userId: string; + message: string; + tagUser?: string; + postedAt: IDateValue; + }[]; +}; + +export type IPostItem = { + id: string; + title: string; + tags: string[]; + publish: string; + content: string; + coverUrl: string; + metaTitle: string; + totalViews: number; + totalShares: number; + description: string; + totalComments: number; + totalFavorites: number; + metaKeywords: string[]; + metaDescription: string; + comments: IPostComment[]; + createdAt: IDateValue; + favoritePerson: { name: string; avatarUrl: string }[]; + author: { name: string; avatarUrl: string }; +}; diff --git a/src/types/calendar.ts b/src/types/calendar.ts new file mode 100644 index 0000000000000000000000000000000000000000..4857fd6dd659367e114b1ff85710651c524dd5be --- /dev/null +++ b/src/types/calendar.ts @@ -0,0 +1,25 @@ +import type { IDatePickerControl } from './common'; + +// ---------------------------------------------------------------------- + +export type ICalendarFilters = { + colors: string[]; + startDate: IDatePickerControl; + endDate: IDatePickerControl; +}; + +export type ICalendarDate = string | number; + +export type ICalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listWeek'; + +export type ICalendarRange = { start: ICalendarDate; end: ICalendarDate } | null; + +export type ICalendarEvent = { + id: string; + color: string; + title: string; + allDay: boolean; + description: string; + end: ICalendarDate; + start: ICalendarDate; +}; diff --git a/src/types/chat.ts b/src/types/chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef969df76bfed411749188a043920ef0788e8819 --- /dev/null +++ b/src/types/chat.ts @@ -0,0 +1,47 @@ +import type { IDateValue } from './common'; + +// ---------------------------------------------------------------------- + +export type IChatAttachment = { + name: string; + size: number; + type: string; + path: string; + preview: string; + createdAt: IDateValue; + modifiedAt: IDateValue; +}; + +export type IChatMessage = { + id: string; + body: string; + senderId: string; + contentType: string; + createdAt: IDateValue; + attachments: IChatAttachment[]; +}; + +export type IChatParticipant = { + id: string; + name: string; + role: string; + email: string; + address: string; + avatarUrl: string; + phoneNumber: string; + lastActivity: IDateValue; + status: 'online' | 'offline' | 'alway' | 'busy'; +}; + +export type IChatConversation = { + id: string; + type: string; + unreadCount: number; + messages: IChatMessage[]; + participants: IChatParticipant[]; +}; + +export type IChatConversations = { + byId: Record<string, IChatConversation>; + allIds: string[]; +}; diff --git a/src/types/checkout.ts b/src/types/checkout.ts new file mode 100644 index 0000000000000000000000000000000000000000..a85fd71607cb62d29ad4b9b9f95b971e628beefc --- /dev/null +++ b/src/types/checkout.ts @@ -0,0 +1,70 @@ +import type { IAddressItem } from './common'; + +// ---------------------------------------------------------------------- + +export type ICheckoutItem = { + id: string; + name: string; + coverUrl: string; + available: number; + price: number; + colors: string[]; + size: string; + quantity: number; + subtotal?: number; +}; + +export type ICheckoutDeliveryOption = { + value: number; + label: string; + description: string; +}; + +export type ICheckoutPaymentOption = { + value: string; + label: string; + description: string; +}; + +export type ICheckoutCardOption = { + value: string; + label: string; +}; + +export type ICheckoutState = { + total: number; + subtotal: number; + discount: number; + shipping: number; + totalItems: number; + items: ICheckoutItem[]; + billing: IAddressItem | null; +}; + +export type CheckoutContextValue = ICheckoutState & { + canReset: boolean; + onReset: () => void; + onUpdate: (updateValue: Partial<ICheckoutState>) => void; + onUpdateField: ( + name: keyof ICheckoutState, + updateValue: ICheckoutState[keyof ICheckoutState] + ) => void; + // + completed: boolean; + // + onAddToCart: (newItem: ICheckoutItem) => void; + onDeleteCart: (itemId: string) => void; + // + onIncreaseQuantity: (itemId: string) => void; + onDecreaseQuantity: (itemId: string) => void; + // + activeStep: number; + initialStep: () => void; + onBackStep: () => void; + onNextStep: () => void; + onGotoStep: (step: number) => void; + // + onCreateBilling: (billing: IAddressItem) => void; + onApplyDiscount: (discount: number) => void; + onApplyShipping: (discount: number) => void; +}; diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..b47adb18ceb511c7d3e6d02a12d592971311e161 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,31 @@ +import type { Dayjs } from 'dayjs'; + +// ---------------------------------------------------------------------- + +export type IPaymentCard = { + id: string; + cardType: string; + primary?: boolean; + cardNumber: string; +}; + +export type IAddressItem = { + id?: string; + name: string; + company?: string; + primary?: boolean; + fullAddress: string; + phoneNumber?: string; + addressType?: string; +}; + +export type IDateValue = string | number | null; + +export type IDatePickerControl = Dayjs | null; + +export type ISocialLink = { + facebook: string; + instagram: string; + linkedin: string; + twitter: string; +}; diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d44fc1ba7b175507e6f4f5643a3888b7d19dafe --- /dev/null +++ b/src/types/file.ts @@ -0,0 +1,47 @@ +import type { IDateValue, IDatePickerControl } from './common'; + +// ---------------------------------------------------------------------- + +export type IFileFilters = { + name: string; + type: string[]; + startDate: IDatePickerControl; + endDate: IDatePickerControl; +}; + +export type IFileShared = { + id: string; + name: string; + email: string; + avatarUrl: string; + permission: string; +}; + +export type IFolderManager = { + id: string; + name: string; + size: number; + type: string; + url: string; + tags: string[]; + totalFiles?: number; + isFavorited: boolean; + shared: IFileShared[] | null; + createdAt: IDateValue; + modifiedAt: IDateValue; +}; + +export type IFileManager = { + id: string; + name: string; + size: number; + type: string; + url: string; + tags: string[]; + isFavorited: boolean; + shared: IFileShared[] | null; + createdAt: IDateValue; + modifiedAt: IDateValue; +}; + +export type IFile = IFileManager | IFolderManager; diff --git a/src/types/invoice.ts b/src/types/invoice.ts new file mode 100644 index 0000000000000000000000000000000000000000..71a73d3df3bf16d0cba5a9cfd2c84410b931af56 --- /dev/null +++ b/src/types/invoice.ts @@ -0,0 +1,38 @@ +import type { IDateValue, IAddressItem, IDatePickerControl } from './common'; + +// ---------------------------------------------------------------------- + +export type IInvoiceTableFilters = { + name: string; + status: string; + service: string[]; + endDate: IDatePickerControl; + startDate: IDatePickerControl; +}; + +export type IInvoiceItem = { + id: string; + title: string; + price: number; + total: number; + service: string; + quantity: number; + description: string; +}; + +export type IInvoice = { + id: string; + sent: number; + taxes: number; + status: string; + subtotal: number; + discount: number; + shipping: number; + totalAmount: number; + invoiceNumber: string; + items: IInvoiceItem[]; + invoiceTo: IAddressItem; + invoiceFrom: IAddressItem; + createDate: IDateValue; + dueDate: IDateValue; +}; diff --git a/src/types/job.ts b/src/types/job.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd4ef632033db95f901834c4530a246117da3d3c --- /dev/null +++ b/src/types/job.ts @@ -0,0 +1,60 @@ +// ---------------------------------------------------------------------- + +export type IJobFilters = { + roles: string[]; + experience: string; + locations: string[]; + benefits: string[]; + employmentTypes: string[]; +}; + +export type IJobCandidate = { + id: string; + name: string; + role: string; + avatarUrl: string; +}; + +export type IJobCompany = { + name: string; + logo: string; + phoneNumber: string; + fullAddress: string; +}; + +export type IJobSalary = { + type: string; + price: number; + negotiable: boolean; +}; +export type IJobMail = { + object: string; + body: string; +}; +export type IJobQuestion = { + question: string; + answer: string; +}; +export type IJobItem = { + id: string; + role: string; + title: string; + content: string; + publish: string; + skills: string[]; + optionalBadges: string[]; + requiredBadges: string[]; + contract: string; + totalViews: number; + experience: string; + salary: IJobSalary; + locations: string[]; + company: IJobCompany; + createdAt: string | null; + employmentTypes: string[]; + // workingSchedule: string[]; + candidates: IJobCandidate[]; + accepted: IJobMail; + rejected: IJobMail; + questions: IJobQuestion[]; +}; diff --git a/src/types/kanban.ts b/src/types/kanban.ts new file mode 100644 index 0000000000000000000000000000000000000000..47e2ec7ff442eb01ea7b5ff2f3ce3dd92b57c8a4 --- /dev/null +++ b/src/types/kanban.ts @@ -0,0 +1,54 @@ +import type { UniqueIdentifier } from '@dnd-kit/core'; + +import type { IDateValue } from './common'; + +// ---------------------------------------------------------------------- + +export type IKanbanComment = { + id: string; + name: string; + message: string; + avatarUrl: string; + messageType: 'image' | 'text'; + createdAt: IDateValue; +}; + +export type IKanbanAssignee = { + id: string; + name: string; + role: string; + email: string; + status: string; + address: string; + avatarUrl: string; + phoneNumber: string; + lastActivity: IDateValue; +}; + +export type IKanbanTask = { + id: UniqueIdentifier; + name: string; + status: string; + priority: string; + labels: string[]; + description?: string; + attachments: string[]; + comments: IKanbanComment[]; + assignee: IKanbanAssignee[]; + due: [IDateValue, IDateValue]; + reporter: { + id: string; + name: string; + avatarUrl: string; + }; +}; + +export type IKanbanColumn = { + id: UniqueIdentifier; + name: string; +}; + +export type IKanban = { + tasks: Record<UniqueIdentifier, IKanbanTask[]>; + columns: IKanbanColumn[]; +}; diff --git a/src/types/mail.ts b/src/types/mail.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecd278bf331395bf67b2c350383aa67e46e423fd --- /dev/null +++ b/src/types/mail.ts @@ -0,0 +1,48 @@ +import type { IDateValue } from './common'; + +// ---------------------------------------------------------------------- + +export type IMailLabel = { + id: string; + type: string; + name: string; + color: string; + unreadCount?: number; +}; + +export type IMailSender = { + name: string; + email: string; + avatarUrl: string | null; +}; + +export type IMailAttachment = { + id: string; + name: string; + size: number; + type: string; + path: string; + preview: string; + createdAt: IDateValue; + modifiedAt: IDateValue; +}; + +export type IMail = { + id: string; + folder: string; + subject: string; + message: string; + isUnread: boolean; + from: IMailSender; + to: IMailSender[]; + labelIds: string[]; + isStarred: boolean; + isImportant: boolean; + createdAt: IDateValue; + attachments: IMailAttachment[]; +}; + +export type IMails = { + byId: Record<string, IMail>; + allIds: string[]; +}; diff --git a/src/types/order.ts b/src/types/order.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2450760dd0e0162bd677680ef28ea0fbfc14bc5 --- /dev/null +++ b/src/types/order.ts @@ -0,0 +1,70 @@ +import type { IDateValue, IDatePickerControl } from './common'; + +// ---------------------------------------------------------------------- + +export type IOrderTableFilters = { + name: string; + status: string; + startDate: IDatePickerControl; + endDate: IDatePickerControl; +}; + +export type IOrderHistory = { + orderTime: IDateValue; + paymentTime: IDateValue; + deliveryTime: IDateValue; + completionTime: IDateValue; + timeline: { title: string; time: IDateValue }[]; +}; + +export type IOrderShippingAddress = { + fullAddress: string; + phoneNumber: string; +}; + +export type IOrderPayment = { + cardType: string; + cardNumber: string; +}; + +export type IOrderDelivery = { + shipBy: string; + speedy: string; + trackingNumber: string; +}; + +export type IOrderCustomer = { + id: string; + name: string; + email: string; + avatarUrl: string; + ipAddress: string; +}; + +export type IOrderProductItem = { + id: string; + sku: string; + name: string; + price: number; + coverUrl: string; + quantity: number; +}; + +export type IOrderItem = { + id: string; + taxes: number; + status: string; + shipping: number; + discount: number; + subtotal: number; + orderNumber: string; + totalAmount: number; + totalQuantity: number; + createdAt: IDateValue; + history: IOrderHistory; + payment: IOrderPayment; + customer: IOrderCustomer; + delivery: IOrderDelivery; + items: IOrderProductItem[]; + shippingAddress: IOrderShippingAddress; +}; diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf01eeea1e82c4b48e42cbf614ffd5c15277974a --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,76 @@ +import type { IDateValue } from './common'; + +// ---------------------------------------------------------------------- + +export type IProductFilters = { + rating: string; + gender: string[]; + category: string; + colors: string[]; + priceRange: number[]; +}; + +export type IProductTableFilters = { + stock: string[]; + publish: string[]; +}; + +export type IProductReviewNewForm = { + rating: number | null; + review: string; + name: string; + email: string; +}; + +export type IProductReview = { + id: string; + name: string; + rating: number; + comment: string; + helpful: number; + avatarUrl: string; + postedAt: IDateValue; + isPurchased: boolean; + attachments?: string[]; +}; + +export type IProductItem = { + id: string; + sku: string; + name: string; + code: string; + price: number; + taxes: number; + tags: string[]; + sizes: string[]; + publish: string; + gender: string[]; + coverUrl: string; + images: string[]; + colors: string[]; + quantity: number; + category: string; + available: number; + totalSold: number; + description: string; + totalRatings: number; + totalReviews: number; + createdAt: IDateValue; + inventoryType: string; + subDescription: string; + priceSale: number | null; + reviews: IProductReview[]; + ratings: { + name: string; + starCount: number; + reviewCount: number; + }[]; + saleLabel: { + enabled: boolean; + content: string; + }; + newLabel: { + enabled: boolean; + content: string; + }; +}; diff --git a/src/types/tour.ts b/src/types/tour.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e761e92aaf00af99a2a1bf28305f16e5278dd34 --- /dev/null +++ b/src/types/tour.ts @@ -0,0 +1,48 @@ +import type { IDateValue, IDatePickerControl } from './common'; + +// ---------------------------------------------------------------------- + +export type ITourFilters = { + services: string[]; + destination: string[]; + tourGuides: ITourGuide[]; + startDate: IDatePickerControl; + endDate: IDatePickerControl; +}; + +export type ITourGuide = { + id: string; + name: string; + avatarUrl: string; + phoneNumber: string; +}; + +export type ITourBooker = { + id: string; + name: string; + guests: number; + avatarUrl: string; +}; + +export type ITourItem = { + id: string; + name: string; + price: number; + totalViews: number; + tags: string[]; + content: string; + publish: string; + images: string[]; + durations: string; + priceSale: number; + services: string[]; + destination: string; + ratingNumber: number; + bookers: ITourBooker[]; + tourGuides: ITourGuide[]; + createdAt: IDateValue; + available: { + startDate: IDateValue; + endDate: IDateValue; + }; +}; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000000000000000000000000000000000000..cefab9690ae5f437edab9bb7d90e23db44a15f11 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,113 @@ +import type { IDateValue, ISocialLink } from './common'; + +// ---------------------------------------------------------------------- + +export type IUserTableFilters = { + name: string; + role: string[]; + status: string; +}; + +export type IUserProfileCover = { + name: string; + role: string; + coverUrl: string; + avatarUrl: string; +}; + +export type IUserProfile = { + id: string; + role: string; + quote: string; + email: string; + school: string; + country: string; + company: string; + totalFollowers: number; + totalFollowing: number; + socialLinks: ISocialLink; +}; + +export type IUserProfileFollower = { + id: string; + name: string; + country: string; + avatarUrl: string; +}; + +export type IUserProfileGallery = { + id: string; + title: string; + imageUrl: string; + postedAt: IDateValue; +}; + +export type IUserProfileFriend = { + id: string; + name: string; + role: string; + avatarUrl: string; +}; + +export type IUserProfilePost = { + id: string; + media: string; + message: string; + createdAt: IDateValue; + personLikes: { name: string; avatarUrl: string }[]; + comments: { + id: string; + message: string; + createdAt: IDateValue; + author: { id: string; name: string; avatarUrl: string }; + }[]; +}; + +export type IUserCard = { + id: string; + name: string; + role: string; + coverUrl: string; + avatarUrl: string; + totalPosts: number; + totalFollowers: number; + totalFollowing: number; +}; + +export type IUserItem = { + id: string; + name: string; + city: string; + role: string; + email: string; + state: string; + status: string; + address: string; + country: string; + zipCode: string; + company: string; + avatarUrl: string; + phoneNumber: string; + isVerified: boolean; +}; + +export type IUserAccount = { + city: string; + email: string; + state: string; + about: string; + address: string; + zipCode: string; + isPublic: boolean; + displayName: string; + phoneNumber: string; + country: string | null; + photoURL: File | string | null; +}; + +export type IUserAccountBillingHistory = { + id: string; + price: number; + invoiceNumber: string; + createdAt: IDateValue; +}; diff --git a/src/utils/format-number.ts b/src/utils/format-number.ts new file mode 100644 index 0000000000000000000000000000000000000000..135b3de613ef723701a5d0ef447cfbdc561a3c2b --- /dev/null +++ b/src/utils/format-number.ts @@ -0,0 +1,106 @@ +import { formatNumberLocale } from 'src/shared/locales'; + +// ---------------------------------------------------------------------- + +/* + * Locales code + * https://gist.github.com/raushankrjha/d1c7e35cf87e69aa8b4208a8171a8416 + */ + +export type InputNumberValue = string | number | null | undefined; + +type Options = Intl.NumberFormatOptions | undefined; + +const DEFAULT_LOCALE = { code: 'en-US', currency: 'USD' }; + +function processInput(inputValue: InputNumberValue): number | null { + if (inputValue == null || Number.isNaN(inputValue)) return null; + return Number(inputValue); +} + +// ---------------------------------------------------------------------- + +export function fNumber(inputValue: InputNumberValue, options?: Options) { + const locale = formatNumberLocale() || DEFAULT_LOCALE; + + const number = processInput(inputValue); + if (number === null) return ''; + + const fm = new Intl.NumberFormat(locale.code, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + ...options, + }).format(number); + + return fm; +} + +// ---------------------------------------------------------------------- + +export function fCurrency(inputValue: InputNumberValue, options?: Options) { + const locale = formatNumberLocale() || DEFAULT_LOCALE; + + const number = processInput(inputValue); + if (number === null) return ''; + + const fm = new Intl.NumberFormat(locale.code, { + style: 'currency', + currency: locale.currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + ...options, + }).format(number); + + return fm; +} + +// ---------------------------------------------------------------------- + +export function fPercent(inputValue: InputNumberValue, options?: Options) { + const locale = formatNumberLocale() || DEFAULT_LOCALE; + + const number = processInput(inputValue); + if (number === null) return ''; + + const fm = new Intl.NumberFormat(locale.code, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 1, + ...options, + }).format(number / 100); + + return fm; +} + +// ---------------------------------------------------------------------- + +export function fShortenNumber(inputValue: InputNumberValue, options?: Options) { + const locale = formatNumberLocale() || DEFAULT_LOCALE; + + const number = processInput(inputValue); + if (number === null) return ''; + + const fm = new Intl.NumberFormat(locale.code, { + notation: 'compact', + maximumFractionDigits: 2, + ...options, + }).format(number); + + return fm.replace(/[A-Z]/g, (match) => match.toLowerCase()); +} + +// ---------------------------------------------------------------------- + +export function fData(inputValue: InputNumberValue) { + const number = processInput(inputValue); + if (number === null || number === 0) return '0 bytes'; + + const units = ['bytes', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb']; + const decimal = 2; + const baseValue = 1024; + + const index = Math.floor(Math.log(number) / Math.log(baseValue)); + const fm = `${parseFloat((number / baseValue ** index).toFixed(decimal))} ${units[index]}`; + + return fm; +} diff --git a/yarn.lock b/yarn.lock index d5ad0caf7cbd7c97822271c5fe5e02531659e684..a3f994466ce0e07c01bcbb2c97af02ae76b236db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1720,7 +1720,7 @@ dependencies: tslib "^2.0.0" -"@emotion/babel-plugin@^11.11.0", "@emotion/babel-plugin@^11.12.0": +"@emotion/babel-plugin@^11.12.0": version "11.12.0" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz" integrity sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw== @@ -1764,38 +1764,33 @@ resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz" integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== -"@emotion/is-prop-valid@*", "@emotion/is-prop-valid@^1.2.2": - version "1.2.2" - resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz" - integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw== +"@emotion/is-prop-valid@*", "@emotion/is-prop-valid@^1.3.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.0.tgz" + integrity sha512-SHetuSLvJDzuNbOdtPVbq6yMMMlLoW5Q94uDqJZqy50gcmAjxFkVqmzqSGEFq9gT2iMuIeKV1PXVWmvUhuZLlQ== dependencies: - "@emotion/memoize" "^0.8.1" - -"@emotion/memoize@^0.8.1": - version "0.8.1" - resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz" - integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + "@emotion/memoize" "^0.9.0" "@emotion/memoize@^0.9.0": version "0.9.0" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== -"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.11.4", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.9.0": - version "11.11.4" - resolved "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz" - integrity sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw== +"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.11.4", "@emotion/react@^11.13.0", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.9.0": + version "11.13.0" + resolved "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz" + integrity sha512-WkL+bw1REC2VNV1goQyfxjx1GYJkcc23CRQkXX+vZNLINyfI7o+uUn/rTGPt/xJ3bJHd5GcljgnxHf4wRw5VWQ== dependencies: "@babel/runtime" "^7.18.3" - "@emotion/babel-plugin" "^11.11.0" - "@emotion/cache" "^11.11.0" - "@emotion/serialize" "^1.1.3" - "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" - "@emotion/utils" "^1.2.1" - "@emotion/weak-memoize" "^0.3.1" + "@emotion/babel-plugin" "^11.12.0" + "@emotion/cache" "^11.13.0" + "@emotion/serialize" "^1.3.0" + "@emotion/use-insertion-effect-with-fallbacks" "^1.1.0" + "@emotion/utils" "^1.4.0" + "@emotion/weak-memoize" "^0.4.0" hoist-non-react-statics "^3.3.1" -"@emotion/serialize@^1.1.3", "@emotion/serialize@^1.1.4", "@emotion/serialize@^1.2.0", "@emotion/serialize@^1.3.0": +"@emotion/serialize@^1.2.0", "@emotion/serialize@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz" integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA== @@ -1811,38 +1806,33 @@ resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== -"@emotion/styled@^11.11.5", "@emotion/styled@^11.3.0", "@emotion/styled@^11.8.1": - version "11.11.5" - resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.5.tgz" - integrity sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ== +"@emotion/styled@^11.11.5", "@emotion/styled@^11.13.0", "@emotion/styled@^11.3.0", "@emotion/styled@^11.8.1": + version "11.13.0" + resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.0.tgz" + integrity sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA== dependencies: "@babel/runtime" "^7.18.3" - "@emotion/babel-plugin" "^11.11.0" - "@emotion/is-prop-valid" "^1.2.2" - "@emotion/serialize" "^1.1.4" - "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" - "@emotion/utils" "^1.2.1" + "@emotion/babel-plugin" "^11.12.0" + "@emotion/is-prop-valid" "^1.3.0" + "@emotion/serialize" "^1.3.0" + "@emotion/use-insertion-effect-with-fallbacks" "^1.1.0" + "@emotion/utils" "^1.4.0" "@emotion/unitless@^0.9.0": version "0.9.0" resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz" integrity sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ== -"@emotion/use-insertion-effect-with-fallbacks@^1.0.1": - version "1.0.1" - resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz" - integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw== +"@emotion/use-insertion-effect-with-fallbacks@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz" + integrity sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw== -"@emotion/utils@^1.2.1", "@emotion/utils@^1.4.0": +"@emotion/utils@^1.4.0": version "1.4.0" resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz" integrity sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ== -"@emotion/weak-memoize@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz" - integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== - "@emotion/weak-memoize@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" @@ -2540,10 +2530,17 @@ clsx "^2.1.0" prop-types "^15.8.1" -"@mui/core-downloads-tracker@^5.16.5": - version "5.16.5" - resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.5.tgz" - integrity sha512-ziFn1oPm6VjvHQcdGcAO+fXvOQEgieIj0BuSqcltFU+JXIxjPdVYNTdn2HU7/Ak5Gabk6k2u7+9PV7oZ6JT5sA== +"@mui/core-downloads-tracker@^5.16.6": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.6.tgz" + integrity sha512-kytg6LheUG42V8H/o/Ptz3olSO5kUXW9zF0ox18VnblX6bO2yif1FPItgc3ey1t5ansb1+gbe7SatntqusQupg== + +"@mui/icons-material@^5.16.6": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.6.tgz" + integrity sha512-ceNGjoXheH9wbIFa1JHmSc9QVjJUvh18KvHrR4/FkJCSi9HXJ+9ee1kUhCOEFfuxNF8UB6WWVrIUOUgRd70t0A== + dependencies: + "@babel/runtime" "^7.23.9" "@mui/lab@^5.0.0-alpha.170": version "5.0.0-alpha.170" @@ -2565,16 +2562,16 @@ dependencies: "@babel/runtime" "^7.23.9" -"@mui/material@^5.0.0", "@mui/material@^5.15.14", "@mui/material@^5.15.20", "@mui/material@^5.16.0", "@mui/material@>=5.15.0": - version "5.16.5" - resolved "https://registry.npmjs.org/@mui/material/-/material-5.16.5.tgz" - integrity sha512-eQrjjg4JeczXvh/+8yvJkxWIiKNHVptB/AqpsKfZBWp5mUD5U3VsjODMuUl1K2BSq0omV3CiO/mQmWSSMKSmaA== +"@mui/material@^5.0.0", "@mui/material@^5.15.14", "@mui/material@^5.16.0", "@mui/material@^5.16.6", "@mui/material@>=5.15.0": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/material/-/material-5.16.6.tgz" + integrity sha512-0LUIKBOIjiFfzzFNxXZBRAyr9UQfmTAFzbt6ziOU2FDXhorNN2o3N9/32mNJbCA8zJo2FqFU6d3dtoqUDyIEfA== dependencies: "@babel/runtime" "^7.23.9" - "@mui/core-downloads-tracker" "^5.16.5" - "@mui/system" "^5.16.5" + "@mui/core-downloads-tracker" "^5.16.6" + "@mui/system" "^5.16.6" "@mui/types" "^7.2.15" - "@mui/utils" "^5.16.5" + "@mui/utils" "^5.16.6" "@popperjs/core" "^2.11.8" "@types/react-transition-group" "^4.4.10" clsx "^2.1.0" @@ -2583,35 +2580,35 @@ react-is "^18.3.1" react-transition-group "^4.4.5" -"@mui/private-theming@^5.16.5": - version "5.16.5" - resolved "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.5.tgz" - integrity sha512-CSLg0YkpDqg0aXOxtjo3oTMd3XWMxvNb5d0v4AYVqwOltU8q6GvnZjhWyCLjGSCrcgfwm6/VDjaKLPlR14wxIA== +"@mui/private-theming@^5.16.6": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.6.tgz" + integrity sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw== dependencies: "@babel/runtime" "^7.23.9" - "@mui/utils" "^5.16.5" + "@mui/utils" "^5.16.6" prop-types "^15.8.1" -"@mui/styled-engine@^5.16.4": - version "5.16.4" - resolved "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.4.tgz" - integrity sha512-0+mnkf+UiAmTVB8PZFqOhqf729Yh0Cxq29/5cA3VAyDVTRIUUQ8FXQhiAhUIbijFmM72rY80ahFPXIm4WDbzcA== +"@mui/styled-engine@^5.16.6": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.6.tgz" + integrity sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g== dependencies: "@babel/runtime" "^7.23.9" "@emotion/cache" "^11.11.0" csstype "^3.1.3" prop-types "^15.8.1" -"@mui/system@^5.15.15", "@mui/system@^5.16.5": - version "5.16.5" - resolved "https://registry.npmjs.org/@mui/system/-/system-5.16.5.tgz" - integrity sha512-uzIUGdrWddUx1HPxW4+B2o4vpgKyRxGe/8BxbfXVDPNPHX75c782TseoCnR/VyfnZJfqX87GcxDmnZEE1c031g== +"@mui/system@^5.15.15", "@mui/system@^5.16.6": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/system/-/system-5.16.6.tgz" + integrity sha512-5xgyJjBIMPw8HIaZpfbGAaFYPwImQn7Nyh+wwKWhvkoIeDosQ1ZMVrbTclefi7G8hNmqhip04duYwYpbBFnBgw== dependencies: "@babel/runtime" "^7.23.9" - "@mui/private-theming" "^5.16.5" - "@mui/styled-engine" "^5.16.4" + "@mui/private-theming" "^5.16.6" + "@mui/styled-engine" "^5.16.6" "@mui/types" "^7.2.15" - "@mui/utils" "^5.16.5" + "@mui/utils" "^5.16.6" clsx "^2.1.0" csstype "^3.1.3" prop-types "^15.8.1" @@ -2621,10 +2618,10 @@ resolved "https://registry.npmjs.org/@mui/types/-/types-7.2.15.tgz" integrity sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q== -"@mui/utils@^5.15.14", "@mui/utils@^5.16.5": - version "5.16.5" - resolved "https://registry.npmjs.org/@mui/utils/-/utils-5.16.5.tgz" - integrity sha512-CwhcA9y44XwK7k2joL3Y29mRUnoBt+gOZZdGyw7YihbEwEErJYBtDwbZwVgH68zAljGe/b+Kd5bzfl63Gi3R2A== +"@mui/utils@^5.15.14", "@mui/utils@^5.16.6": + version "5.16.6" + resolved "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz" + integrity sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA== dependencies: "@babel/runtime" "^7.23.9" "@mui/types" "^7.2.15" @@ -3850,6 +3847,14 @@ dependencies: "@types/react" "*" +"@types/react-lazy-load-image-component@^1.6.4": + version "1.6.4" + resolved "https://registry.npmjs.org/@types/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.4.tgz" + integrity sha512-8pFPeDPF4yVG4lU1/ixZidJEEDZmEOYOTYDvmIu2IAabyuv97Q7n/93nMCocHvQ7vD1czKGiW+op55D9m3MkdA== + dependencies: + "@types/react" "*" + csstype "^3.0.2" + "@types/react-transition-group@^4.4.10": version "4.4.10" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz" @@ -3870,6 +3875,11 @@ resolved "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.6.tgz" integrity sha512-4nebF2ZJGzQk0ka0O6+FZUWceyFv4vWq/0dXBMmrSeAwzOuOd/GxE5Pa64d/ndeNLG73dXoBsRzvtsVsYUv6Uw== +"@types/turndown@^5.0.5": + version "5.0.5" + resolved "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz" + integrity sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.2" resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz"