feat: &&&&&

main
熊二 3 months ago
commit b4e047a10c
  1. 91
      .github/commit-convention.md
  2. 14
      .gitignore
  3. 4
      .oxlintignore
  4. 0
      LICENSE
  5. 13
      apps/imsfe/index.html
  6. 38
      apps/imsfe/package.json
  7. 1
      apps/imsfe/public/react.svg
  8. 1
      apps/imsfe/public/vite.svg
  9. 78
      apps/imsfe/src/App.tsx
  10. 26
      apps/imsfe/src/components/WechatIcon.tsx
  11. 103
      apps/imsfe/src/config/createAuthProvider.ts
  12. 22
      apps/imsfe/src/config/createDataProvider.ts
  13. 26
      apps/imsfe/src/config/i18nProvider.ts
  14. 33
      apps/imsfe/src/layout/Logo.tsx
  15. 1
      apps/imsfe/src/layout/index.ts
  16. 9
      apps/imsfe/src/main.tsx
  17. 7
      apps/imsfe/src/pages/CatchAll.tsx
  18. 10
      apps/imsfe/src/pages/PageError.tsx
  19. 47
      apps/imsfe/src/pages/SignIn.tsx
  20. 127
      apps/imsfe/src/views/SignInCard.tsx
  21. 1
      apps/imsfe/src/vite-env.d.ts
  22. 30
      apps/imsfe/tsconfig.json
  23. 20
      apps/imsfe/vite.config.ts
  24. 14
      apps/simple/package.json
  25. 16
      oxlintrc.json
  26. 35
      package.json
  27. 12
      packages/fetch/package.json
  28. 83
      packages/fetch/src/FetchContext.tsx
  29. 40
      packages/fetch/src/FetchContextProvider.tsx
  30. 12
      packages/fetch/src/FetchError.ts
  31. 16
      packages/fetch/src/index.ts
  32. 63
      packages/fetch/src/types.ts
  33. 54
      packages/fetch/src/useCreateBody.ts
  34. 62
      packages/fetch/src/useCreateErrorCorrector.ts
  35. 40
      packages/fetch/src/useCreateFetchConfig.ts
  36. 25
      packages/fetch/src/useCreateFetcher.ts
  37. 51
      packages/fetch/src/useCreateHeaders.ts
  38. 48
      packages/fetch/src/useCreateRequest.ts
  39. 51
      packages/fetch/src/useCreateRequestIntercepter.ts
  40. 53
      packages/fetch/src/useCreateResponseTransformer.tsx
  41. 65
      packages/fetch/src/useCreateUrl.ts
  42. 108
      packages/fetch/src/useFetch.ts
  43. 8
      packages/fetch/src/useFetchContext.ts
  44. 375
      packages/fetch/src/utils.ts
  45. 23
      packages/joy-ui/package.json
  46. 11
      packages/joy-ui/src/assets/ic-autofit-width.svg
  47. 5
      packages/joy-ui/src/assets/ic-contrast.svg
  48. 3
      packages/joy-ui/src/assets/ic-sidebar-filled.svg
  49. 6
      packages/joy-ui/src/assets/ic-sidebar-outline.svg
  50. 4
      packages/joy-ui/src/assets/ic-siderbar-duotone.svg
  51. 1
      packages/joy-ui/src/components/index.ts
  52. 1
      packages/joy-ui/src/form/index.ts
  53. 26
      packages/joy-ui/src/icons/GoogleIcon.tsx
  54. 26
      packages/joy-ui/src/icons/WechatIcon.tsx
  55. 2
      packages/joy-ui/src/icons/index.ts
  56. 3
      packages/joy-ui/src/index.ts
  57. 40
      packages/joy-ui/src/layout/AdminRoot.tsx
  58. 47
      packages/joy-ui/src/layout/AppBar.tsx
  59. 55
      packages/joy-ui/src/layout/ColorSchemeToggle.tsx
  60. 153
      packages/joy-ui/src/layout/Error.tsx
  61. 25
      packages/joy-ui/src/layout/Loading.tsx
  62. 291
      packages/joy-ui/src/layout/Notification.tsx
  63. 16
      packages/joy-ui/src/layout/PageActions.tsx
  64. 99
      packages/joy-ui/src/layout/PageRoot.tsx
  65. 358
      packages/joy-ui/src/layout/Settings.tsx
  66. 386
      packages/joy-ui/src/layout/Sidebar.tsx
  67. 183
      packages/joy-ui/src/layout/StatusError.tsx
  68. 15
      packages/joy-ui/src/layout/TitlePortal.tsx
  69. 12
      packages/joy-ui/src/layout/index.ts
  70. 26
      packages/joy-ui/src/layout/utils.ts
  71. 25
      packages/joy-ui/src/theme/ThemeProvider.tsx
  72. 5
      packages/joy-ui/src/theme/components.ts
  73. 11
      packages/joy-ui/src/theme/createTheme.ts
  74. 1
      packages/joy-ui/src/theme/index.ts
  75. 4
      packages/joy-ui/src/theme/schemeConfig.ts
  76. 23
      packages/rakit/package.json
  77. 7
      packages/rakit/src/accessControl/AccessControlContext.ts
  78. 16
      packages/rakit/src/accessControl/AccessControlProvider.tsx
  79. 96
      packages/rakit/src/accessControl/CanAccess.tsx
  80. 5
      packages/rakit/src/accessControl/index.ts
  81. 52
      packages/rakit/src/accessControl/types.ts
  82. 18
      packages/rakit/src/accessControl/useAccessControl.ts
  83. 70
      packages/rakit/src/accessControl/useCan.ts
  84. 6
      packages/rakit/src/auth/AuthContext.ts
  85. 58
      packages/rakit/src/auth/Authenticated.tsx
  86. 19
      packages/rakit/src/auth/LogoutOnMount.tsx
  87. 64
      packages/rakit/src/auth/WithPermissions.tsx
  88. 16
      packages/rakit/src/auth/index.ts
  89. 35
      packages/rakit/src/auth/types.ts
  90. 14
      packages/rakit/src/auth/useAuthProvider.ts
  91. 155
      packages/rakit/src/auth/useAuthState.ts
  92. 38
      packages/rakit/src/auth/useAuthenticated.ts
  93. 103
      packages/rakit/src/auth/useCheckAuth.ts
  94. 109
      packages/rakit/src/auth/useGetIdentity.ts
  95. 58
      packages/rakit/src/auth/useGetPermissions.ts
  96. 103
      packages/rakit/src/auth/useHandleAuthCallback.ts
  97. 88
      packages/rakit/src/auth/useLogin.ts
  98. 149
      packages/rakit/src/auth/useLogout.ts
  99. 135
      packages/rakit/src/auth/useLogoutIfAccessDenied.ts
  100. 122
      packages/rakit/src/auth/usePermissions.ts
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,91 @@
## Git Commit Message Convention
> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular).
#### TL;DR:
Messages must be matched by the following regex:
```regexp
/^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip)(\(.+\))?: .{1,50}/
```
#### Examples
Appears under "Features" header, `compiler` subheader:
```
feat(compiler): add 'comments' option
```
Appears under "Bug Fixes" header, `v-model` subheader, with a link to issue #28:
```
fix(v-model): handle events on blur
close #28
```
Appears under "Performance Improvements" header, and under "Breaking Changes" with the breaking change explanation:
```
perf(core): improve vdom diffing by removing 'foo' option
BREAKING CHANGE: The 'foo' option has been removed.
```
The following commit and commit `667ecc1` do not appear in the changelog if they are under the same release. If not, the revert commit appears under the "Reverts" header.
```
revert: feat(compiler): add 'comments' option
This reverts commit 667ecc1654a317a13331b17617d973392f415f02.
```
### Full Message Format
A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**:
```
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
The **header** is mandatory and the **scope** of the header is optional.
### Revert
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit. In the body, it should say: `This reverts commit <hash>.`, where the hash is the SHA of the commit being reverted.
### Type
If the prefix is `feat`, `fix` or `perf`, it will appear in the changelog. However, if there is any [BREAKING CHANGE](#footer), the commit will always appear in the changelog.
Other prefixes are up to your discretion. Suggested prefixes are `docs`, `chore`, `style`, `refactor`, and `test` for non-changelog related tasks.
### Scope
The scope could be anything specifying the place of the commit change. For example `core`, `compiler`, `ssr`, `v-model`, `transition` etc...
### Subject
The subject contains a succinct description of the change:
- use the imperative, present tense: "change" not "changed" nor "changes"
- don't capitalize the first letter
- no dot (.) at the end
### Body
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes".
The body should include the motivation for the change and contrast this with previous behavior.
### Footer
The footer should contain any information about **Breaking Changes** and is also the place to
reference GitHub issues that this commit **Closes**.
**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.

14
.gitignore vendored

@ -0,0 +1,14 @@
logs
*.log
*.tgz
*.local
coverage
dist
bun.lockb
node_modules
temp
TODOs.md
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store

@ -0,0 +1,4 @@
.git
.yarn
dist
node_modules

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -0,0 +1,38 @@
{
"name": "imsfe",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/joy": "^5.0.0-beta.48",
"@mui/utils": "^5.16.6",
"@rakit/core": "workspace:*",
"@rakit/fetch": "workspace:*",
"@rakit/joy-ui": "workspace:*",
"@rakit/use-async": "workspace:*",
"@rakit/use-invariant": "workspace:*",
"lodash": "^4.17.21",
"node-polyglot": "^2.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-router-dom": "^6.26.1"
},
"devDependencies": {
"@types/lodash": "^4.17.7",
"@types/node-polyglot": "^2.5.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"globals": "^15.9.0",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,78 @@
import { CoreAdminContext, CoreAppContext, CoreLayoutProps, Route } from '@rakit/core';
import { useFetch } from '@rakit/fetch';
import { Error, Loading, ThemeProvider } from '@rakit/joy-ui';
import { createAuthProvider } from './config/createAuthProvider';
import { createDataProvider } from './config/createDataProvider';
import { i18nProvider } from './config/i18nProvider';
import { lazy, Suspense } from 'react';
const AppLoading = () => <Loading />
const Layout = (props: CoreLayoutProps) => {
return (
<Suspense fallback={<Loading />}>
{props.children}
</Suspense>
)
}
const CatchAll = lazy(() => import("./pages/CatchAll"));
const SignIn = lazy(() => import("./pages/SignIn"));
const PageError = lazy(() => import("./pages/PageError"));
export default function App() {
return (
<ThemeProvider>
<AppContext />
</ThemeProvider>
);
}
function AppContext() {
const fetch = useFetch();
const authProvider = createAuthProvider(fetch);
const dataProvider = createDataProvider(fetch);
return (
<CoreAppContext
authProvider={authProvider}
// authCallbackPage={ }
// basename={ }
catchAll={<CatchAll />}
dataProvider={dataProvider}
error={Error}
homepage={<CatchAll />}
initialLocation="/"
i18nProvider={i18nProvider}
loading={AppLoading}
layout={Layout}
loginPage={SignIn}
// ready={ }
requireAuth={false}
// store={ }
title="进销存系统"
>
<Route
name="pageErrpr"
path="/error/:status"
element={<PageError />}
/>
<Route
name="test"
path="/sasda"
element={<div></div>}
/>
<CoreAdminContext
basepath="/admin"
initialLocation="/admmin"
>
<Route
name="test"
path="/sasda"
element={<div>222</div>}
/>
</CoreAdminContext>
</CoreAppContext>
)
}

@ -0,0 +1,26 @@
import SvgIcon from '@mui/joy/SvgIcon';
export default function GoogleIcon() {
return (
<SvgIcon fontSize="xl">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</SvgIcon>
);
}

@ -0,0 +1,103 @@
import {
AuthProvider,
UserIdentity,
WithRedirectTo
} from "@rakit/core";
import { FetchFunction } from "@rakit/fetch";
async function login(
fetch: FetchFunction,
{ username, password }: any,
): Promise<WithRedirectTo | void | any> {
try {
const auth = await fetch({
url: "/login",
method: "POST",
body: { username, password },
});
localStorage.setItem('auth', JSON.stringify(auth));
} catch {
throw new Error('Network error');
}
}
function logout(): Promise<void | false | string> {
localStorage.removeItem('auth');
return Promise.resolve();
}
function checkAuth(): Promise<void> {
// // case 1:
// return localStorage.getItem('auth')
// ? Promise.resolve()
// : Promise.reject({ message: 'login.required' });
//
// // case 2:
return localStorage.getItem('auth')
? Promise.resolve()
: Promise.reject({ redirectTo: '/no-access' })
}
function checkError(error: any): Promise<void> {
const status = error.status;
if (status === 401 || status === 403) {
// // case 1:
// localStorage.removeItem('auth');
// return Promise.reject();
//
// // case 2:
// localStorage.removeItem('auth');
// return Promise.reject({ redirectTo: '/credentials-required' });
//
// case 3:
return Promise.reject({
redirectTo: '/unauthorized',
logoutUser: false,
message: 'Unauthorized user!', // or message: false
});
}
// other error code (404, 500, etc): no need to log out
return Promise.resolve();
}
function getIdentity(): Promise<UserIdentity> {
try {
const { id, fullName, avatar, phoneNumber, email } = JSON.parse(localStorage.getItem('auth')!);
return Promise.resolve({
id,
fullName,
avatar,
phoneNumber,
email,
});
} catch (error) {
return Promise.reject(error);
}
}
function getPermissions(): Promise<any> {
return Promise.resolve('');
}
// async function handleCallback(): Promise<AuthRedirectResult | void | any> {
// if (!query.includes('code=') && !query.includes('state=')) {
// throw new Error('Failed to handle login callback.');
// }
// // If we did receive the Auth0 parameters,
// // get an access token based on the query paramaters
// await Auth0Client.handleRedirectCallback();
// return { redirectTo: '/posts' };
// }
export function createAuthProvider(fetch: FetchFunction<any>): AuthProvider {
return {
login: (params) => login(fetch, params),
logout,
checkAuth,
checkError,
getIdentity,
getPermissions,
// handleCallback,
};
}

@ -0,0 +1,22 @@
import {
CreateResult,
DataProvider,
DeleteResult,
GetOneResult,
UpdateResult
} from "@rakit/core";
import { FetchFunction } from "@rakit/fetch";
export function createDataProvider(_fetch: FetchFunction): DataProvider {
return {
create: () => Promise.resolve<CreateResult>({ data: null }),
delete: () => Promise.resolve<DeleteResult>({ data: null }),
deleteMany: () => Promise.resolve({ data: [] }),
getList: () => Promise.resolve({ data: [], total: 0 }),
getMany: () => Promise.resolve({ data: [] }),
getManyReference: () => Promise.resolve({ data: [], total: 0 }),
getOne: () => Promise.resolve<GetOneResult>({ data: null }),
update: () => Promise.resolve<UpdateResult>({ data: null }),
updateMany: () => Promise.resolve({ data: [] }),
}
}

@ -0,0 +1,26 @@
import Polyglot from 'node-polyglot';
import { I18nProvider, Locale } from "@rakit/core";
const locale: Locale = { code: 'zh', name: "简体中文" };
const polyglot = new Polyglot({
locale: locale.code,
phrases: {
'': '',
ra: {
page: {
error: 'ra.page.error222',
},
message: {
error: "ra.message.error222",
}
},
},
});
export const i18nProvider: I18nProvider = {
translate: (key: string, options: any = {}) => polyglot.t(key, options),
changeLocale: () => Promise.resolve(),
getLocale: () => "zh",
getLocales: () => [locale],
}

@ -0,0 +1,33 @@
import AspectRatio, { AspectRatioProps } from '@mui/joy/AspectRatio';
export function Logo(props: AspectRatioProps) {
const { sx, ...other } = props;
return (
<AspectRatio
ratio="1"
variant="plain"
{...other}
sx={[
{
width: 36,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 36 32"
fill="none"
>
<path
d="M30.343 21.976a1 1 0 00.502-.864l.018-5.787a1 1 0 01.502-.864l3.137-1.802a1 1 0 011.498.867v10.521a1 1 0 01-.502.867l-11.839 6.8a1 1 0 01-.994.001l-9.291-5.314a1 1 0 01-.504-.868v-5.305c0-.006.007-.01.013-.007.005.003.012 0 .012-.007v-.006c0-.004.002-.008.006-.01l7.652-4.396c.007-.004.004-.015-.004-.015a.008.008 0 01-.008-.008l.015-5.201a1 1 0 00-1.5-.87l-5.687 3.277a1 1 0 01-.998 0L6.666 9.7a1 1 0 00-1.499.866v9.4a1 1 0 01-1.496.869l-3.166-1.81a1 1 0 01-.504-.87l.028-16.43A1 1 0 011.527.86l10.845 6.229a1 1 0 00.996 0L24.21.86a1 1 0 011.498.868v16.434a1 1 0 01-.501.867l-5.678 3.27a1 1 0 00.004 1.735l3.132 1.783a1 1 0 00.993-.002l6.685-3.839zM31 7.234a1 1 0 001.514.857l3-1.8A1 1 0 0036 5.434V1.766A1 1 0 0034.486.91l-3 1.8a1 1 0 00-.486.857v3.668z"
fill="#007FFF"
/>
</svg>
</div>
</AspectRatio>
);
}

@ -0,0 +1 @@
export * from "./Logo";

@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

@ -0,0 +1,7 @@
import { Navigate } from "react-router-dom";
export default function CatchAll() {
return (
<Navigate to="/error/404" />
);
}

@ -0,0 +1,10 @@
import { StatusError } from "@rakit/joy-ui";
import { useParams } from "react-router-dom";
export default function PageError() {
const { status } = useParams<'status'>();
return (
<StatusError status={status} />
);
}

@ -0,0 +1,47 @@
import { Fragment } from 'react';
import Box from '@mui/joy/Box';
import IconButton from '@mui/joy/IconButton';
import Typography from '@mui/joy/Typography';
import BadgeRoundedIcon from '@mui/icons-material/BadgeRounded';
import { ColorSchemeToggle } from '@rakit/joy-ui';
import { SignInCard } from 'src/views/SignInCard';
export default function SignIn() {
return (
<Fragment>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100dvh',
width: '100%',
px: 2,
justifyContent: "space-between",
}}
>
<Box
component="header"
sx={{
py: 3,
display: 'flex',
justifyContent: 'space-between',
}}
>
<Box sx={{ gap: 2, display: 'flex', alignItems: 'center' }}>
<IconButton variant="soft" color="primary" size="sm">
<BadgeRoundedIcon />
</IconButton>
<Typography level="title-lg">Company logo</Typography>
</Box>
<ColorSchemeToggle />
</Box>
<SignInCard />
<Box component="footer" sx={{ py: 3 }}>
<Typography level="body-xs" sx={{ textAlign: 'center' }}>
© Your company {new Date().getFullYear()}
</Typography>
</Box>
</Box>
</Fragment>
);
}

@ -0,0 +1,127 @@
import Sheet from '@mui/joy/Sheet';
import Typography from '@mui/joy/Typography';
import FormControl from '@mui/joy/FormControl';
import FormLabel from '@mui/joy/FormLabel';
import Input from '@mui/joy/Input';
import Button from '@mui/joy/Button';
import Link from '@mui/joy/Link';
import Box from '@mui/joy/Box';
import Divider from '@mui/joy/Divider';
import Stack from '@mui/joy/Stack';
import Checkbox from '@mui/joy/Checkbox';
import { GoogleIcon } from '@rakit/joy-ui';
export function SignInCard() {
return (
<Sheet
sx={{
width: 400,
mx: 'auto', // margin left & right
my: 4, // margin top & bottom
py: 3, // padding top & bottom
px: 4, // padding left & right
display: 'flex',
flexDirection: 'column',
gap: 2,
borderRadius: 'sm',
boxShadow: 'md',
}}
variant="outlined"
>
<Box
component="main"
sx={{
my: 'auto',
py: 2,
pb: 5,
display: 'flex',
flexDirection: 'column',
gap: 2,
width: 360,
maxWidth: '100%',
mx: 'auto',
borderRadius: 'sm',
'& form': {
display: 'flex',
flexDirection: 'column',
gap: 2,
},
[`& .MuiFormLabel-asterisk`]: {
visibility: 'hidden',
},
}}
>
<Stack sx={{ gap: 4, mb: 2 }}>
<Stack sx={{ gap: 1 }}>
<Typography component="h1" level="h3">
Sign in
</Typography>
<Typography level="body-sm">
New to company?{' '}
<Link href="#replace-with-a-link" level="title-sm">
Sign up!
</Link>
</Typography>
</Stack>
<Button
variant="soft"
color="neutral"
fullWidth
startDecorator={<GoogleIcon />}
>
Continue with Google
</Button>
</Stack>
<Divider
sx={(theme) => ({
[theme.getColorSchemeSelector('light')]: {
color: { xs: '#FFF', md: 'text.tertiary' },
},
})}
>
</Divider>
<Stack sx={{ gap: 4, mt: 2 }}>
<form
onSubmit={(event: React.FormEvent<SignInFormElement>) => {
event.preventDefault();
const formElements = event.currentTarget.elements;
const data = {
email: formElements.email.value,
password: formElements.password.value,
persistent: formElements.persistent.checked,
};
alert(JSON.stringify(data, null, 2));
}}
>
<FormControl required>
<FormLabel></FormLabel>
<Input type="text" name="account" placeholder="用户名/手机/邮箱" />
</FormControl>
<FormControl required>
<FormLabel></FormLabel>
<Input type="password" name="password" placeholder="至少6位" />
</FormControl>
<Stack sx={{ gap: 4, mt: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Checkbox size="sm" label="记住我" name="persistent" />
<Link level="title-sm" href="#replace-with-a-link">
</Link>
</Box>
<Button type="submit" fullWidth>
</Button>
</Stack>
</form>
</Stack>
</Box>
</Sheet>
);
}

@ -0,0 +1 @@
/// <reference types="vite/client" />

@ -0,0 +1,30 @@
{
"compilerOptions": {
// Enable latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ES2020",
"baseUrl": ".",
"module": "ESNext",
"useDefineForClassFields": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"moduleDetection": "force",
"isolatedModules": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": false,
"allowImportingTsExtensions": true,
"allowJs": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": false
},
"include": [
"src"
]
}

@ -0,0 +1,20 @@
import { join } from "node:path";
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: [
{
find: /^~(.+)/,
replacement: join(process.cwd(), 'node_modules/$1'),
},
{
find: /^src(.+)/,
replacement: join(process.cwd(), 'src/$1'),
},
]
},
})

@ -0,0 +1,14 @@
{
"name": "simple-admin",
"type": "module",
"description": "low level admin & dashboard scaffold",
"dependencies": {
"@rakit/core": "workspace:*",
"@rakit/use-async": "workspace:*",
"@rakit/use-fetch": "workspace:*",
"@rakit/use-invariant": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.1"
}
}

@ -0,0 +1,16 @@
{
"env": {
"browser": true
},
"settings": {},
"rules": {
"semi": [
"warn",
"never"
],
"quotes": [
"warn",
"single"
]
}
}

@ -0,0 +1,35 @@
{
"name": "rakit-monorepo",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"type": "module",
"scripts": {
"lint": "oxlint --import-plugin --ignore-path=./.oxlintignore -c ./oxlintrc.json",
"lint:fix": "bun run lint --fix",
"postinstall": "simple-git-hooks",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
},
"devDependencies": {
"@types/bun": "latest",
"conventional-changelog-cli": "^5.0.0",
"lint-staged": "^15.2.9",
"oxlint": "^0.9.1",
"picocolors": "^1.0.1",
"semver": "^7.6.3",
"simple-git-hooks": "^2.11.1",
"vitest": "^2.0.5"
},
"peerDependencies": {
"typescript": "^5.5.0"
},
"lint-staged": {
"**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint"
},
"simple-git-hooks": {
"pre-commit": "bun run lint-staged",
"commit-msg": "node scripts/verify-commit.js"
}
}

@ -0,0 +1,12 @@
{
"name": "@rakit/fetch",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"lodash": "^4.17.21",
"react": "^18.3.1"
},
"devDependencies": {
"@types/lodash": "^4.17.7"
}
}

@ -0,0 +1,83 @@
import { createContext } from "react";
import {
BodyParams,
Combination,
ErrorCorrector,
FetchConfig,
Fetcher,
RequestFactory,
RequestIntercepter,
ResponseTransformer,
SearchParams
} from "./types";
export interface FormatOptions {
bool?: (value: boolean) => string; // default to 'true' or 'false'
null?: string; // default to '' or null
nan?: string; // default to 'NaN'
date?: (date: Date) => string; // default to Date.toISOString()
skipNull?: boolean; // default to true
skipEmptyString?: boolean; // default to true
skipNaN?: boolean; // default to true
arrayFormat?:
| 'default' // 'foo=1&foo=2&foo=3'
| 'bracket' // 'foo[]=1&foo[]=2&foo[]=3'
| 'index' // 'foo[0]=1&foo[1]=2&foo[3]=3'
| 'dot' // 'foo.0=1&foo.1=2&foo.2=3'
| 'comma' // 'foo=1,2,3'
| 'line' // 'foo=1|2|3'
| 'bracket-comma' // 'foo[]=1,2,3'
| 'bracket-line'; // 'foo[]=1|2|3'
objectFormat?:
| 'bracket' // 'foo[bar]=bazz'
| 'dot' // 'foo.bar=bazz'
| 'colon'; // 'foo:bar=bazz'
}
export interface BodyMergeConfig extends FormatOptions {
handle?: (
bodys: BodyParams[],
hasFiles: boolean | undefined,
options: FormatOptions & { combination: Combination },
) => BodyParams;
}
export type BodyBuilder = (
body: BodyParams,
hasFiles: boolean,
options: FormatOptions & { combination: Combination },
) => BodyInit | null;
export interface SearchParamsMergeConfig extends FormatOptions {
handleIntoUrl?: (
url: URL,
params: SearchParams,
options: FormatOptions & { combination: Combination },
) => void;
}
export interface FetchContextValue {
baseUrl?: string;
body?: BodyParams;
bodyCombination?: Combination;
bodyBuilder?: BodyBuilder;
bodyMergeConfig?: BodyMergeConfig;
config?: FetchConfig;
configCombination?: Combination;
correct?: ErrorCorrector;
correctCombination?: Combination;
createRequest?: RequestFactory;
fetcher?: Fetcher;
headers?: HeadersInit;
headersCombination?: Combination;
hasFiles?: boolean | undefined;
intercept?: RequestIntercepter;
interceptCombination?: Combination;
searchParams?: SearchParams;
searchParamsCombination?: Combination;
searchParamsMergeConfig?: SearchParamsMergeConfig;
transform?: ResponseTransformer;
transformCombination?: Combination;
}
export const FetchContext = createContext<FetchContextValue | undefined>(undefined);

@ -0,0 +1,40 @@
import { useMemo } from "react";
import { FetchContext, FetchContextValue } from "./FetchContext";
import { useFetchContext } from "./useFetchContext";
export interface FetchContextProviderProps extends FetchContextValue {
inherit?: boolean;
children?: React.ReactNode;
}
export function FetchContextProvider(props: FetchContextProviderProps) {
const {
children,
inherit,
...localContext
} = props;
const parentContext = useFetchContext();
const value = useMemo<FetchContextValue>(() => {
if (inherit) {
return {
...parentContext,
...localContext,
};
}
return {
...localContext,
};
}, [
inherit,
localContext,
parentContext,
]);
return (
<FetchContext.Provider value={value}>
{children}
</FetchContext.Provider>
)
}

@ -0,0 +1,12 @@
export class FetchError extends Error {
constructor(
message: string,
public readonly request?: Request,
public readonly response?: Response,
public readonly error?: any,
) {
super(message);
this.name = 'FetchError';
Error.captureStackTrace?.(this, FetchError);
}
}

@ -0,0 +1,16 @@
export * from './FetchContext';
export * from './FetchContextProvider';
export * from './FetchError';
export * from './types';
export * from './useCreateBody';
export * from './useCreateHeaders';
export * from './useCreateRequest';
export * from './useCreateUrl';
export * from './useCreateErrorCorrector';
export * from './useFetch';
export * from './useCreateFetchConfig';
export * from './useFetchContext';
export * from './useCreateFetcher';
export * from './useCreateRequestIntercepter';
export * from './useCreateResponseTransformer';
export * from './utils';

@ -0,0 +1,63 @@
export type SearchParams =
| string
| [string, string | number | boolean][]
| Record<string, any>
| URLSearchParams
| null;
export type BodyParams =
| BodyInit
| Record<string, any>
| null;
export type HttpMethod =
| "DELETE"
| "GET"
| "HEAD"
| "OPTIONS"
| "PATCH"
| "POST"
| "PUT"
| RequestInit['method'];
export type Combination = 'overrides' | 'chaining';
export type FetchConfig = {
cache?: RequestCache;
credentials?: RequestCredentials;
integrity?: string;
keepalive?: boolean;
mode?: RequestMode;
priority?: RequestPriority;
redirect?: RequestRedirect;
referrer?: string;
referrerPolicy?: ReferrerPolicy;
}
export type RequestFactory = (
input: RequestInfo | URL,
init?: RequestInit
) => Promise<Request> | PromiseLike<Request> | Request;
export type ErrorCorrector = (
error: unknown,
request: Request | undefined,
response: Response | undefined,
) => Promise<any> | any;
export type RequestIntercepter = (
request: Request
) => Promise<Request> | PromiseLike<Request> | Request;
export type ResponseTransformer = (
request: Request,
response: Response,
data?: any,
) => Promise<any> | PromiseLike<any> | any;
export type Fetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;

@ -0,0 +1,54 @@
import { useCallback } from "react";
import { BodyParams, Combination } from "./types";
import { useFetchContext } from "./useFetchContext";
import { buildBody, mergeBodys } from "./utils";
export interface CreateBodyOptions {
body?: BodyParams;
bodyCombination?: Combination;
hasFiles?: boolean;
[key: string]: any;
}
export type CreateBodyFunction = (
body?: BodyParams | undefined,
hasFiles?: boolean | undefined,
) => BodyInit | null;
export function useCreateBody(options?: CreateBodyOptions) {
const fetchContext = useFetchContext();
return useCallback((newOptions?: CreateBodyOptions): BodyInit | null => {
const bodyBuilder = fetchContext?.bodyBuilder ?? buildBody;
const handle = fetchContext?.bodyMergeConfig?.handle ?? mergeBodys;
const mergeConfig = fetchContext?.bodyMergeConfig ?? {};
const combination = newOptions?.bodyCombination
?? options?.bodyCombination
?? fetchContext?.bodyCombination
?? 'chaining';
const bodys = toList(
fetchContext?.body,
options?.body,
newOptions?.body,
);
const hasFiles = newOptions?.hasFiles === true
|| options?.hasFiles === true
|| fetchContext?.hasFiles === true;
const handleOptions = { ...mergeConfig, combination }
const body = handle(bodys, hasFiles, handleOptions);
return bodyBuilder(body, hasFiles, handleOptions);
}, [
fetchContext?.body,
fetchContext?.bodyBuilder,
fetchContext?.bodyCombination,
fetchContext?.bodyMergeConfig,
fetchContext?.hasFiles,
options?.body,
options?.bodyCombination,
options?.hasFiles,
]);
}
const toList = <T>(...items: Array<T | undefined>): T[] => {
return items.filter(i => i !== undefined);
}

@ -0,0 +1,62 @@
import { useCallback } from "react";
import { useFetchContext } from "./useFetchContext";
import { Combination, ErrorCorrector } from "./types";
export interface CreateErrorCorrectorOptions {
correct?: ErrorCorrector;
correctCombination?: Combination;
[key: string]: any;
}
export type CreateErrorCorrectorFunction = (
options?: CreateErrorCorrectorOptions
) => ErrorCorrector;
export function useCreateErrorCorrector(
options?: CreateErrorCorrectorOptions,
): CreateErrorCorrectorFunction {
const fetchContext = useFetchContext();
return useCallback((newOptions?: CreateErrorCorrectorOptions): ErrorCorrector => {
return (
error: unknown,
request: Request | undefined,
response: Response | undefined
): Promise<any> => {
const combination = newOptions?.correctCombination
?? options?.correctCombination
?? fetchContext?.correctCombination
?? 'overrides';
if (combination === "chaining") {
return [
fetchContext?.correct,
options?.correct,
newOptions?.correct,
].reduce<Promise<any>>(
async (promise, handle) => {
if (!handle) {
return promise;
} else {
return promise.catch(error => handle(error, request, response))
}
},
defaultCorrect(error)
);
}
const handle = newOptions?.correct
?? options?.correct
?? fetchContext?.correct
?? defaultCorrect;
return handle(error, request, response);
}
}, [
fetchContext?.correct,
fetchContext?.correctCombination,
options?.correct,
options?.correctCombination,
]);
}
function defaultCorrect(error: unknown): Promise<any> {
return Promise.reject(error);
}

@ -0,0 +1,40 @@
import { useCallback } from "react";
import { Combination, FetchConfig } from "./types";
import { useFetchContext } from "./useFetchContext";
export interface CreateFetchConfigOptions {
config?: FetchConfig;
configCombination?: Combination;
[key: string]: any;
}
export type CreateFetchConfigFunction = (options?: CreateFetchConfigOptions) => FetchConfig;
export function useCreateFetchConfig(
options?: CreateFetchConfigOptions,
): CreateFetchConfigFunction {
const fetchContext = useFetchContext({});
return useCallback((newOptions?: CreateFetchConfigOptions): FetchConfig => {
const combination = newOptions?.configCombination
?? options?.configCombination
?? fetchContext?.configCombination
?? 'chaining';
if (combination === 'chaining') {
return {
...fetchContext?.config,
...options?.config,
...newOptions?.config,
}
}
return newOptions?.config
?? options?.config
?? fetchContext?.config
?? {};
}, [
fetchContext?.config,
fetchContext?.configCombination,
options?.config,
options?.configCombination,
]);
}

@ -0,0 +1,25 @@
import { useCallback } from "react";
import { useFetchContext } from "./useFetchContext";
import { Fetcher } from "./types";
export interface CreateFetcherOptions {
fetcher?: Fetcher;
[key: string]: any;
}
export const useCreateFetcher = (options?: CreateFetcherOptions) => {
const fetchContext = useFetchContext({});
return useCallback((newOptions?: CreateFetcherOptions) => {
return (input: RequestInfo | URL, init?: RequestInit) => {
const handle = newOptions?.fetcher
?? options?.fetcher
?? fetchContext?.fetcher
?? globalThis.fetch;
return handle(input, init);
}
}, [
fetchContext?.fetcher,
options?.fetcher
]);
}

@ -0,0 +1,51 @@
import { useCallback } from "react";
import { useFetchContext } from "./useFetchContext";
import { Combination } from "./types";
export interface CreateHeadersOptions {
headers?: HeadersInit;
headersCombination?: Combination;
[key: string]: any;
}
export type CreateHeadersFunction = (
options?: CreateHeadersOptions
) => HeadersInit | undefined;
export function useCreateHeaders(options?: CreateHeadersOptions): CreateHeadersFunction {
const fetchContext = useFetchContext();
return useCallback((newOptions?: CreateHeadersOptions): HeadersInit | undefined => {
const combination = newOptions?.headersCombination
?? options?.headersCombination
?? fetchContext?.headersCombination
?? 'chaining';
if (combination === 'chaining') {
return [
fetchContext?.headers,
options?.headers,
newOptions?.headers,
].reduce((acc, item) => {
if (!item) {
return acc;
}
const headers = new Headers(item);
if (!acc) {
return headers;
}
headers.forEach((value, name) => {
(acc as Headers).set(name, value);
});
return acc;
});
}
return newOptions?.headers
?? options?.headers
?? fetchContext?.headers;
}, [
fetchContext?.headers,
fetchContext?.headersCombination,
options?.headers,
options?.headersCombination,
]);
}

@ -0,0 +1,48 @@
import { useCallback } from "react";
import { FetchConfig, RequestFactory } from "./types";
import { useFetchContext } from "./useFetchContext";
export interface UseCreateRequestOptions {
createRequest?: RequestFactory;
[key: string]: any;
}
export type CreateRequestFunction = (
url: URL,
config: FetchConfig,
headers: HeadersInit | undefined,
body: BodyInit | null | undefined,
signal?: AbortSignal,
) => Promise<Request> | Request;
const defaultCreateRequest = (
input: RequestInfo | URL,
init?: RequestInit
) => {
return new Request(input, init);
};
export function useCreateRequest(options?: UseCreateRequestOptions) {
const fetchContext = useFetchContext();
return useCallback(async (
url: URL,
config: FetchConfig,
headers: HeadersInit | undefined,
body: BodyInit | null | undefined,
signal?: AbortSignal,
): Promise<Request> => {
const handle = options?.createRequest
?? fetchContext?.createRequest
?? defaultCreateRequest;
return await handle(url, {
...config,
headers,
body,
signal,
});
}, [
fetchContext?.createRequest,
options?.createRequest,
]);
}

@ -0,0 +1,51 @@
import { useCallback } from "react";
import { useFetchContext } from "./useFetchContext";
import { Combination, RequestIntercepter } from "./types";
export interface CreateRequestIntercepterOptions {
intercept?: RequestIntercepter;
interceptCombination?: Combination;
[key: string]: any;
}
const defaultIntercept = (request: Request) => Promise.resolve(request);
export function useCreateRequestIntercepter(
options?: CreateRequestIntercepterOptions
) {
const fetchContext = useFetchContext();
return useCallback((newOptions?: CreateRequestIntercepterOptions) => {
return (request: Request) => {
const combination = newOptions?.interceptCombination
?? options?.interceptCombination
?? fetchContext?.interceptCombination
?? 'overrides';
if (combination === 'chaining') {
return [
fetchContext?.intercept,
options?.intercept,
newOptions?.intercept,
].reduce<Promise<Request>>(
(promise, handle) => {
if (!handle) {
return promise;
} else {
return promise.then(async request => {
return await handle(request);
});
}
},
defaultIntercept(request),
);
}
const handle = newOptions?.intercept
?? options?.intercept
?? fetchContext?.intercept
?? defaultIntercept
return handle(request);
}
}, [
options?.intercept,
fetchContext?.intercept,
]);
}

@ -0,0 +1,53 @@
import { useCallback } from "react";
import { useFetchContext } from "./useFetchContext";
import { Combination, ResponseTransformer } from "./types";
function defaultTransform(_: Request, response: Response) {
return response.clone().json();
}
export interface CreateResponseTransformerOptions {
transform?: ResponseTransformer;
transformCombination?: Combination;
[key: string]: any;
}
export function useCreateResponseTransformer(options?: CreateResponseTransformerOptions) {
const fetchContext = useFetchContext();
return useCallback((newOptions?: CreateResponseTransformerOptions): ResponseTransformer => {
return (request: Request, response: Response) => {
const combination = newOptions?.transformCombination
?? options?.transformCombination
?? fetchContext?.transformCombination
?? 'overrides';
if (combination === 'chaining') {
return [
fetchContext?.transform,
options?.transform,
newOptions?.transform,
].reduce<Promise<any>>(
(promise, handle) => {
if (!handle) {
return promise;
} else {
return promise.then(async data => {
return await handle(request, response, data);
});
}
},
defaultTransform(request, response),
);
}
const handle = newOptions?.transform
?? options?.transform
?? fetchContext?.transform
?? defaultTransform;
return handle(request, response);
};
}, [
fetchContext?.transform,
fetchContext?.transformCombination,
options?.transform,
options?.transformCombination,
]);
}

@ -0,0 +1,65 @@
import { useCallback } from "react";
import { Combination, SearchParams } from "./types";
import { useFetchContext } from "./useFetchContext";
import { searchParamsIntoUrl } from "./utils";
export interface CreateUrlOptions {
baseUrl?: string;
searchParams?: SearchParams;
searchParamsCombination?: Combination;
[key: string]: any;
}
export type CreateUrlFunction = (
newOptions: CreateUrlOptions & { url: string }
) => URL;
export function useCreateUrl(options?: CreateUrlOptions): CreateUrlFunction {
const fetchContext = useFetchContext();
return useCallback((newOptions: CreateUrlOptions & { url: string }): URL => {
const mergeConfig = fetchContext?.searchParamsMergeConfig ?? {};
const handleIntoUrl = mergeConfig.handleIntoUrl ?? searchParamsIntoUrl;
const baseUrl = newOptions.baseUrl
?? options?.baseUrl
?? fetchContext?.baseUrl;
const uri = new URL(newOptions.url, baseUrl);
const combination = newOptions.searchParamsCombination
?? options?.searchParamsCombination
?? fetchContext?.searchParamsCombination
?? 'chaining';
const handleOptions = {
...mergeConfig,
combination,
};
if (combination === 'overrides') {
const searchParams = newOptions.searchParams
?? options?.searchParams
?? fetchContext?.searchParams;
if (searchParams != null) {
handleIntoUrl(uri, searchParams, handleOptions);
}
return uri;
}
if (fetchContext?.searchParams != null) {
handleIntoUrl(uri, fetchContext.searchParams, handleOptions);
}
if (options?.searchParams != null) {
handleIntoUrl(uri, options.searchParams, handleOptions);
}
if (newOptions.searchParams != null) {
handleIntoUrl(uri, newOptions.searchParams, handleOptions);
}
return uri;
}, [
fetchContext?.baseUrl,
fetchContext?.searchParams,
fetchContext?.searchParamsCombination,
fetchContext?.searchParamsMergeConfig,
options?.baseUrl,
options?.searchParams,
options?.searchParamsCombination,
]);
}

@ -0,0 +1,108 @@
import { useCallback } from "react";
import {
BodyParams,
Combination,
ErrorCorrector,
FetchConfig,
Fetcher,
HttpMethod,
RequestFactory,
RequestIntercepter,
ResponseTransformer,
SearchParams,
} from "./types";
import { useCreateBody } from "./useCreateBody";
import { useCreateHeaders } from "./useCreateHeaders";
import { useCreateRequest } from "./useCreateRequest";
import { useCreateUrl } from "./useCreateUrl";
import { useCreateErrorCorrector } from "./useCreateErrorCorrector";
import { useCreateFetchConfig } from "./useCreateFetchConfig";
import { useCreateFetcher } from "./useCreateFetcher";
import { useCreateRequestIntercepter } from "./useCreateRequestIntercepter";
import { useCreateResponseTransformer } from "./useCreateResponseTransformer";
export interface FetchOptionBase {
body?: BodyParams;
bodyCombination?: Combination;
config?: FetchConfig;
configCombination?: Combination;
correct?: ErrorCorrector;
correctCombination?: Combination;
fetcher?: Fetcher;
hasFiles?: boolean;
headers?: HeadersInit;
headersCombination?: Combination;
intercept?: RequestIntercepter;
interceptCombination?: Combination;
searchParams?: SearchParams;
searchParamsCombination?: Combination;
transform?: ResponseTransformer;
}
export interface FetchVariables extends FetchOptionBase {
url: string;
method?: HttpMethod;
signal?: AbortSignal;
}
export interface UseFetchOptions extends FetchOptionBase {
baseUrl?: string;
createRequest?: RequestFactory;
}
export type FetchFunction = <TData = any>(options: FetchVariables) => Promise<TData>;
export function useFetch(
options: UseFetchOptions = {}
): FetchFunction {
const createFetchConfig = useCreateFetchConfig(options);
const createUrl = useCreateUrl(options);
const createHeaders = useCreateHeaders(options);
const createBody = useCreateBody(options);
const createRequest = useCreateRequest(options);
const createFetcher = useCreateFetcher(options);
const createIntercepter = useCreateRequestIntercepter(options);
const createTransformer = useCreateResponseTransformer(options);
const createCorrector = useCreateErrorCorrector(options);
return useCallback(
async <TData>(options: FetchVariables): Promise<TData> => {
let uri: URL;
let body: BodyInit | null;
let headers: HeadersInit | undefined;
let config: FetchConfig;
let intercept: RequestIntercepter;
let transform: ResponseTransformer;
let correct: ErrorCorrector;
let request: Request;
let response: Response | undefined;
let fetch: Fetcher;
return Promise.resolve()
.then(() => uri = createUrl(options))
.then(() => body = createBody(options))
.then(() => headers = createHeaders(options))
.then(() => config = createFetchConfig(options))
.then(() => intercept = createIntercepter(options))
.then(() => transform = createTransformer(options))
.then(() => correct = createCorrector(options))
.then(() => fetch = createFetcher(options))
.then(() => createRequest(uri, config, headers, body, options.signal))
.then(async req => await intercept(request = req))
.then(async req => await fetch(request = req))
.then(async res => await transform(request, response = res))
.catch(async err => await correct(err, request, response));
},
[
createFetchConfig,
createUrl,
createHeaders,
createBody,
createRequest,
createFetcher,
createIntercepter,
createTransformer,
createCorrector,
],
);
}

@ -0,0 +1,8 @@
import { useContext } from "react";
import { FetchContext, FetchContextValue } from "./FetchContext";
export function useFetchContext(): FetchContextValue | undefined
export function useFetchContext(fallback: FetchContextValue): FetchContextValue
export function useFetchContext(fallback?: FetchContextValue | undefined): FetchContextValue | undefined {
return useContext(FetchContext) ?? fallback;
}

@ -0,0 +1,375 @@
import isPlainObject from 'lodash/isPlainObject';
import isString from 'lodash/isString';
import isMap from 'lodash/isMap';
import isSet from 'lodash/isSet';
import isArray from 'lodash/isArray';
import isDate from 'lodash/isDate';
import isBoolean from 'lodash/isBoolean';
import isNaN from 'lodash/isNaN';
import isNumber from 'lodash/isNumber';
import { FormatOptions } from "./FetchContext";
import { BodyParams, Combination, SearchParams } from "./types";
import { FetchError } from './FetchError';
export const resolveFormatOptions = (
options: FormatOptions | undefined,
): Required<FormatOptions> => ({
bool: options?.bool ?? String,
null: options?.null ?? '',
nan: options?.nan ?? 'NaN',
date: options?.date ?? (d => d.toISOString()),
skipNull: options?.skipNull ?? true,
skipEmptyString: options?.skipEmptyString ?? true,
skipNaN: options?.skipNaN ?? true,
arrayFormat: options?.arrayFormat ?? 'default',
objectFormat: options?.objectFormat ?? 'bracket',
});
export function searchParamsIntoUrl(
url: URL,
params: SearchParams,
options: FormatOptions & { combination: Combination },
): void {
if (options.combination === "overrides") {
url.search = ""; // clean
}
if (params === null || params === "") {
return;
} else if (typeof params === 'string') {
params = new URLSearchParams();
} else if (params instanceof URLSearchParams) {
// nothing
} else if (Array.isArray(params)) {
for (const [key, value] of params) {
url.searchParams.set(key, String(value));
}
return;
}
merge(
url.searchParams as Merger,
params,
options,
);
}
const isObject = <T = any>(v: any): v is Record<string, T> => {
return isPlainObject(v);
};
export function mergeBodys(
bodys: BodyParams[],
hasFiles: boolean | undefined,
options: FormatOptions & { combination: Combination },
): BodyParams | null {
const size = bodys.length;
if (size === 0) {
return null;
}
if (size === 1 || options.combination === 'overrides') {
const body = bodys[size - 1];
if (isBodyInit(body) || body === null) {
return body;
}
if (hasFiles) {
const data = new FormData();
merge(data, body, options);
return data;
}
return body;
}
if (hasFiles || bodys.some(b => b instanceof FormData)) {
const data = new FormData();
bodys.forEach(body => merge(data, body, options));
return data;
}
return bodys.reduce((acc, body) => {
if (!body) {
return acc;
} else if (
!acc ||
body instanceof ReadableStream ||
body instanceof Blob ||
body instanceof ArrayBuffer ||
ArrayBuffer.isView(body) ||
body instanceof FormData
) {
return body;
} else if (acc instanceof URLSearchParams) {
if (!(body instanceof URLSearchParams)) {
throw new Error(
`Should not merge a ${displayName(body)} into a URLSearchParams.`,
);
}
body.forEach((_, name) => {
body.getAll(name).forEach((value, index) => {
if (index === 0) {
acc.set(name, value); // replace
} else {
acc.append(name, value); // append
}
})
})
} else if (isObject(acc)) {
if (body instanceof URLSearchParams) {
body.forEach((_, name) => {
const values = body.getAll(name);
const value = values.length > 1 ? values : values[0];
// todo expand the name to path
// @ts-ignore
acc[name] = value;
})
} else if (isObject(body)) {
// merge first level only
return {
...acc,
...body,
}
} else {
throw new Error(
`Should not merge a ${displayName(body)} into a plaint object.`,
);
}
} else {
const accName = displayName(acc);
const bodyName = displayName(body);
if (accName === bodyName) {
throw new FetchError(
`Should not merge a ${bodyName} into another ${acc}`,
);
}
throw new FetchError(
`Should not merge a ${bodyName} into a ${acc}`,
);
}
return acc;
});
}
export function buildBody(
body: BodyParams,
hasFiles: boolean,
options: FormatOptions,
): BodyInit | null {
if (isBodyInit(body) || body == null) {
return body;
}
if (hasFiles) {
const data = new FormData();
merge(data, body, options);
return data;
}
return JSON.stringify(body);
}
const displayName = (a: any) => Object.prototype.toString.call(a);
export const isBodyInit = (body: BodyParams): body is BodyInit => {
return body != null && (
body instanceof ReadableStream
|| body instanceof Blob
|| body instanceof ArrayBuffer
|| ArrayBuffer.isView(body)
|| body instanceof FormData
|| body instanceof URLSearchParams
|| typeof body === 'string'
);
}
interface Merger {
append: (path: string, value: Blob | string) => void;
set: (path: string, value: Blob | string) => void;
}
const arrayKey = (
strategy: FormatOptions['arrayFormat'],
path: string,
index: number,
) => {
if (!path) {
throw new FetchError("invalid root data");
}
switch(strategy) {
case 'bracket':
case 'bracket-comma':
case 'bracket-line':
return `${path}[]`;
case 'index':
return `${path}[${index}]`;
case 'dot':
return `${path}.${index}`;
case 'comma':
case 'line':
case 'default':
default:
return path;
}
}
const objectKey = (
strategy: FormatOptions['objectFormat'],
path: string,
key: string,
): string => {
if (!path) {
throw new FetchError("invalid root data");
}
switch(strategy) {
case 'dot':
return `${path}.${key}`;
case 'colon':
return `${path}:${key}`;
case 'bracket':
default:
return `${path}[${key}]`;
}
}
export function merge(
merger: Merger,
params: any,
options: FormatOptions | undefined,
supportedFile?: boolean,
) {
const opts = resolveFormatOptions(options);
const arrayShallowMode = opts.arrayFormat === 'comma'
|| opts.arrayFormat === 'line'
|| opts.arrayFormat === 'bracket-comma'
|| opts.arrayFormat === 'bracket-line';
let append = 0;
const withArray = ((cb: () => void) => {
append++;
cb();
append--;
});
const walk = (value: any, path: string, report?: (s: string) => void) => {
const use = (name: string, value: Blob | string) => {
if (!name) {
throw new FetchError("invalid root data");
} else if (append > 0) {
merger.append(name, value);
} else {
merger.set(name, value);
}
};
const smartUse = (name: string, value: string) => {
if (!name) {
throw new FetchError("invalid root data");
} else if (report && arrayShallowMode) {
report(value);
} else {
use(name, value);
}
};
const shallowUse = (items: string[]) => {
// | 'comma' // 'foo=1,2,3'
// | 'line' // 'foo=1|2|3'
// | 'bracket-comma' // 'foo[]=1,2,3'
// | 'bracket-line'; // 'foo[]=1|2|3'
const key = opts.arrayFormat.startsWith('bracket-') ? path + '[]' : path;
const sep = opts.arrayFormat.endsWith("-comma") ? "," : "|";
smartUse(key, items.map(v => encodeURIComponent(v)).join(sep));
};
if (value == null) {
if (!opts.skipNull) {
smartUse(path, opts.null);
}
} else if (isString(value)) {
if (value || !opts.skipEmptyString) {
smartUse(path, value);
}
} else if (isNumber(value)) {
if (!isNaN(value)) {
smartUse(path, String(value));
} else if (!opts.skipNaN) {
smartUse(path, opts.nan);
}
} else if (isBoolean(value)) {
smartUse(path, opts.bool(value));
} else if (isDate(value)) {
smartUse(path, opts.date(value));
} else if (report && arrayShallowMode) {
throw new FetchError("The data is too complicated");
} else if (value instanceof URLSearchParams) {
for (const key of value.keys()) {
const name = objectKey(opts.objectFormat, path, key);
for (const str of value.getAll(key)) {
smartUse(name, str);
}
}
} else if (value instanceof Blob) {
if (!path) {
throw new FetchError("invalid root data");
} else if (!supportedFile) {
throw new FetchError("Unsupport File type");
} else {
use(path, value);
}
} else if (value instanceof FormData) {
value.forEach((_, key) => {
const name = objectKey(opts.objectFormat, path, key);
value.getAll(key).forEach((val) => {
if (!(val instanceof File)) {
merger.append(name, val);
} else if (!supportedFile) {
throw new Error("Unsupported file type.");
} else {
merger.append(name, val);
}
});
});
} else if (isSet(value)) {
withArray(() => {
const items: string[] = [];
Array.from(value).forEach((val, index) => {
const name = arrayKey(opts.arrayFormat, path, index);
walk(val, name, s => items.push(s));
});
shallowUse(items);
})
} else if (isMap(value)) {
const keys = Array.from(value.keys());
if (keys.every(k => isNumber(k) && !isNaN(k))) {
withArray(() => {
const items: string[] = [];
(value as Map<number, any>).forEach((val, index) => {
const name = arrayKey(opts.arrayFormat, path, index);
walk(val, name, (s) => items.push(s));
});
shallowUse(items);
});
} else if (keys.every(k => isString(k))) {
value.forEach((val, key) => {
const name = objectKey(opts.objectFormat, path, key);
walk(val, name);
});
} else {
throw new FetchError("invalid data of a Map type");
}
} else if (isArray(value)) {
withArray(() => {
const items: string[] = [];
value.forEach((val, index) => {
const name = arrayKey(opts.arrayFormat, path, index);
walk(val, name, (s) => items.push(s));
});
shallowUse(items);
});
} else if (isObject(value)) {
for (const [key, val] of Object.entries(value)) {
const name = objectKey(opts.objectFormat, path, key);
walk(val, name);
}
} else {
throw new FetchError("Unsupported data type.");
}
}
walk(params, "");
}

@ -0,0 +1,23 @@
{
"name": "@rakit/joy-ui",
"type": "module",
"description": "low level admin & dashboard scaffold",
"main": "./src/index.ts",
"dependencies": {
"@rakit/use-async": "workspace:*",
"@rakit/use-invariant": "workspace:*",
"@tanstack/react-query": "^5.52.2",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-is": "^18.3.1",
"react-router-dom": "^6.26.1",
"sonner": "^1.5.0"
},
"devDependencies": {
"@types/lodash": "^4.17.7",
"@types/react-dom": "^18.3.0",
"@types/react-is": "^18.3.0"
}
}

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
opacity="0.32"
d="M21 5.66667C21 4.95942 20.7037 4.28115 20.1762 3.78105C19.6488 3.28095 18.9334 3 18.1875 3H5.8125C5.06658 3 4.35121 3.28095 3.82376 3.78105C3.29632 4.28115 3 4.95942 3 5.66667V9.93333C3 10.2162 3.11853 10.4875 3.32951 10.6876C3.54048 10.8876 3.82663 11 4.125 11C4.42337 11 4.70952 10.8876 4.9205 10.6876C5.13147 10.4875 5.25 10.2162 5.25 9.93333V5.66667C5.25 5.52522 5.30926 5.38956 5.41475 5.28954C5.52024 5.18952 5.66332 5.13333 5.8125 5.13333H18.1875C18.3367 5.13333 18.4798 5.18952 18.5852 5.28954C18.6907 5.38956 18.75 5.52522 18.75 5.66667V9.93333C18.75 10.2162 18.8685 10.4875 19.0795 10.6876C19.2905 10.8876 19.5766 11 19.875 11C20.1734 11 20.4595 10.8876 20.6705 10.6876C20.8815 10.4875 21 10.2162 21 9.93333V5.66667Z"
fill="#1877F2"
/>
<path
d="M18.8972 20.6792L21.6747 17.822C21.883 17.6077 22 17.3171 22 17.014C22 16.711 21.883 16.4204 21.6747 16.206L18.8972 13.3489C18.6889 13.1345 18.4062 13.0139 18.1115 13.0138C17.8168 13.0137 17.5341 13.134 17.3256 13.3483C17.1172 13.5626 17 13.8533 16.9999 14.1565C16.9998 14.4597 17.1168 14.7505 17.3251 14.9649L19.3171 17.014L17.3251 19.0632C17.1227 19.2787 17.0107 19.5674 17.0133 19.8671C17.0158 20.1667 17.1326 20.4534 17.3386 20.6653C17.5446 20.8772 17.8233 20.9974 18.1146 21C18.4059 21.0026 18.6876 20.8874 18.8972 20.6792ZM2.32529 16.206C2.11701 16.4204 2 16.711 2 17.014C2 17.3171 2.11701 17.6077 2.32529 17.822L5.10283 20.6792C5.31236 20.8874 5.59301 21.0026 5.88431 21C6.17562 20.9974 6.45427 20.8772 6.66027 20.6653C6.86626 20.4534 6.9831 20.1667 6.98563 19.8671C6.98816 19.5674 6.87618 19.2787 6.6738 19.0632L4.68286 17.014L6.67491 14.9649C6.78102 14.8595 6.86566 14.7334 6.92389 14.5939C6.98212 14.4545 7.01277 14.3045 7.01405 14.1528C7.01533 14.001 6.98722 13.8505 6.93136 13.7101C6.87549 13.5696 6.793 13.442 6.68868 13.3347C6.58436 13.2274 6.46032 13.1426 6.32378 13.0851C6.18724 13.0276 6.04094 12.9987 5.89342 13C5.7459 13.0014 5.60011 13.0329 5.46457 13.0928C5.32902 13.1527 5.20642 13.2397 5.10394 13.3489L2.32529 16.206ZM13.111 17.014C13.111 16.7109 12.994 16.4202 12.7856 16.2059C12.5773 15.9916 12.2947 15.8712 12 15.8712C11.7053 15.8712 11.4228 15.9916 11.2144 16.2059C11.006 16.4202 10.889 16.7109 10.889 17.014C10.889 17.3171 11.006 17.6078 11.2144 17.8222C11.4228 18.0365 11.7053 18.1569 12 18.1569C12.2947 18.1569 12.5773 18.0365 12.7856 17.8222C12.994 17.6078 13.111 17.3171 13.111 17.014ZM8.66696 15.8712C8.96162 15.8712 9.24421 15.9916 9.45256 16.2059C9.66092 16.4202 9.77797 16.7109 9.77797 17.014C9.77797 17.3171 9.66092 17.6078 9.45256 17.8222C9.24421 18.0365 8.96162 18.1569 8.66696 18.1569H7.55594C7.26129 18.1569 6.97869 18.0365 6.77034 17.8222C6.56198 17.6078 6.44493 17.3171 6.44493 17.014C6.44493 16.7109 6.56198 16.4202 6.77034 16.2059C6.97869 15.9916 7.26129 15.8712 7.55594 15.8712H8.66696ZM17.5551 17.014C17.5551 16.7109 17.438 16.4202 17.2297 16.2059C17.0213 15.9916 16.7387 15.8712 16.4441 15.8712H15.333C15.0384 15.8712 14.7558 15.9916 14.5474 16.2059C14.3391 16.4202 14.222 16.7109 14.222 17.014C14.222 17.3171 14.3391 17.6078 14.5474 17.8222C14.7558 18.0365 15.0384 18.1569 15.333 18.1569H16.4441C16.7387 18.1569 17.0213 18.0365 17.2297 17.8222C17.438 17.6078 17.5551 17.3171 17.5551 17.014Z"
fill="#1877F2"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM12 20.5C16.6944 20.5 20.5 16.6944 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 16.6944 7.30558 20.5 12 20.5Z" fill="#1877F2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM12 20.5C16.6944 20.5 20.5 16.6944 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 16.6944 7.30558 20.5 12 20.5Z" fill="#1877F2"/>
<path d="M11.1299 20.6213C9.59086 20.4566 8.12388 19.8832 6.88089 18.9607C5.63791 18.0383 4.66413 16.8002 4.06042 15.3747C4.04526 15.3389 4.03101 15.3029 4.01638 15.2669L11.1299 19.2499V20.6213ZM11.1299 17.7217L3.50026 13.4499C3.42001 12.9712 3.37975 12.4867 3.37988 12.0013C3.37988 11.726 3.39269 11.4527 3.4183 11.1816L11.1299 15.4993V17.7217ZM11.1299 13.9711L3.66401 9.79102C3.76825 9.39432 3.90072 9.00559 4.06042 8.6278C4.13729 8.44591 4.22016 8.26733 4.30905 8.09205L11.1299 11.9153V13.9711ZM11.1299 10.3866L5.0013 6.95138C5.27722 6.56735 5.5839 6.20639 5.9183 5.87205C6.02163 5.76871 6.12719 5.6683 6.23497 5.57084L11.1299 8.25191V10.3866ZM11.1299 6.73156L7.39455 4.68571C8.52175 3.96725 9.80052 3.52071 11.1299 3.38135V6.73156Z" fill="#1877F2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.99 2.403C17.851 2.25 16.395 2.25 14.557 2.25H10.445L9.53498 2.251C9.51066 2.24984 9.4863 2.24984 9.46198 2.251C8.07898 2.255 6.93998 2.278 6.01198 2.403C4.83998 2.561 3.89098 2.893 3.14198 3.641C2.39398 4.39 2.06198 5.339 1.90398 6.511C1.75098 7.65 1.75098 9.106 1.75098 10.944V13.056C1.75098 14.894 1.75098 16.349 1.90398 17.489C2.06198 18.661 2.39398 19.61 3.14198 20.359C3.89098 21.107 4.83998 21.439 6.01198 21.597C6.93998 21.722 8.07898 21.745 9.46298 21.749C9.48695 21.7505 9.51097 21.7508 9.53498 21.75H14.557C16.395 21.75 17.85 21.75 18.99 21.597C20.162 21.439 21.111 21.107 21.86 20.359C22.608 19.61 22.94 18.661 23.098 17.489C23.251 16.35 23.251 14.894 23.251 13.056V10.944C23.251 9.106 23.251 7.651 23.098 6.511C22.94 5.339 22.608 4.39 21.86 3.641C21.111 2.893 20.162 2.561 18.99 2.403ZM14.501 3.75H10.251V20.25H14.501C16.408 20.25 17.762 20.248 18.79 20.11C19.796 19.975 20.376 19.721 20.799 19.298C21.222 18.875 21.476 18.295 21.611 17.29C21.749 16.262 21.751 14.907 21.751 13V11C21.751 9.093 21.749 7.739 21.611 6.711C21.476 5.705 21.222 5.125 20.799 4.702C20.376 4.279 19.796 4.025 18.791 3.89C17.762 3.752 16.408 3.75 14.501 3.75ZM4.49993 9C4.49993 8.58579 4.83571 8.25 5.24993 8.25H6.74993C7.16414 8.25 7.49993 8.58579 7.49993 9C7.49993 9.41421 7.16414 9.75 6.74993 9.75H5.24993C4.83571 9.75 4.49993 9.41421 4.49993 9ZM4.49993 12C4.49993 11.5858 4.83571 11.25 5.24993 11.25H6.74993C7.16414 11.25 7.49993 11.5858 7.49993 12C7.49993 12.4142 7.16414 12.75 6.74993 12.75H5.24993C4.83571 12.75 4.49993 12.4142 4.49993 12ZM5.24993 14.25C4.83571 14.25 4.49993 14.5858 4.49993 15C4.49993 15.4142 4.83571 15.75 5.24993 15.75H6.74993C7.16414 15.75 7.49993 15.4142 7.49993 15C7.49993 14.5858 7.16414 14.25 6.74993 14.25H5.24993Z" fill="#1877F2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.557 2.25C16.395 2.25 17.851 2.25 18.99 2.403C20.162 2.561 21.111 2.893 21.86 3.641C22.608 4.39 22.94 5.339 23.098 6.511C23.251 7.651 23.251 9.106 23.251 10.944V13.056C23.251 14.894 23.251 16.35 23.098 17.489C22.94 18.661 22.608 19.61 21.86 20.359C21.111 21.107 20.162 21.439 18.99 21.597C17.85 21.75 16.395 21.75 14.557 21.75H9.53498C9.51097 21.7508 9.48695 21.7505 9.46298 21.749C8.07898 21.745 6.93998 21.722 6.01198 21.597C4.83998 21.439 3.89098 21.107 3.14198 20.359C2.39398 19.61 2.06198 18.661 1.90398 17.489C1.75098 16.349 1.75098 14.894 1.75098 13.056V10.944C1.75098 9.106 1.75098 7.65 1.90398 6.511C2.06198 5.339 2.39398 4.39 3.14198 3.641C3.89098 2.893 4.83998 2.561 6.01198 2.403C6.93998 2.278 8.07898 2.255 9.46198 2.251C9.4863 2.24984 9.51066 2.24984 9.53498 2.251L10.445 2.25H14.557ZM10.251 3.75H14.501C16.408 3.75 17.762 3.752 18.791 3.89C19.796 4.025 20.376 4.279 20.799 4.702C21.222 5.125 21.476 5.705 21.611 6.711C21.749 7.739 21.751 9.093 21.751 11V13C21.751 14.907 21.749 16.262 21.611 17.29C21.476 18.295 21.222 18.875 20.799 19.298C20.376 19.721 19.796 19.975 18.79 20.11C17.762 20.248 16.408 20.25 14.501 20.25H10.251V3.75ZM8.75098 20.244C7.71698 20.234 6.89298 20.202 6.21098 20.11C5.20598 19.975 4.62598 19.721 4.20298 19.298C3.77998 18.875 3.52598 18.295 3.39098 17.289C3.25298 16.262 3.25098 14.907 3.25098 13V11C3.25098 9.093 3.25298 7.739 3.39098 6.71C3.52598 5.705 3.77998 5.125 4.20298 4.702C4.62598 4.279 5.20598 4.025 6.21198 3.89C6.89198 3.798 7.71698 3.767 8.75098 3.756V20.244Z" fill="#1877F2"/>
<path d="M4.49991 9C4.49991 8.58579 4.8357 8.25 5.24991 8.25H6.74991C7.16412 8.25 7.49991 8.58579 7.49991 9C7.49991 9.41421 7.16412 9.75 6.74991 9.75H5.24991C4.8357 9.75 4.49991 9.41421 4.49991 9Z" fill="#1877F2"/>
<path d="M4.49991 12C4.49991 11.5858 4.8357 11.25 5.24991 11.25H6.74991C7.16412 11.25 7.49991 11.5858 7.49991 12C7.49991 12.4142 7.16412 12.75 6.74991 12.75H5.24991C4.8357 12.75 4.49991 12.4142 4.49991 12Z" fill="#1877F2"/>
<path d="M4.49991 15C4.49991 14.5858 4.8357 14.25 5.24991 14.25H6.74991C7.16412 14.25 7.49991 14.5858 7.49991 15C7.49991 15.4142 7.16412 15.75 6.74991 15.75H5.24991C4.8357 15.75 4.49991 15.4142 4.49991 15Z" fill="#1877F2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path opacity="0.32" fill-rule="evenodd" clip-rule="evenodd" d="M20.828 4.172C22 5.343 22 7.229 22 11V13C22 16.771 22 18.657 20.828 19.828C19.657 21 17.771 21 14 21H9V3H14C17.771 3 19.657 3 20.828 4.172Z" fill="#1877F2"/>
<path d="M18.5 9.244C18.6989 9.244 18.8897 9.32302 19.0303 9.46367C19.171 9.60432 19.25 9.79509 19.25 9.994C19.25 10.1929 19.171 10.3837 19.0303 10.5243C18.8897 10.665 18.6989 10.744 18.5 10.744H12.5C12.3011 10.744 12.1103 10.665 11.9697 10.5243C11.829 10.3837 11.75 10.1929 11.75 9.994C11.75 9.79509 11.829 9.60432 11.9697 9.46367C12.1103 9.32302 12.3011 9.244 12.5 9.244H18.5ZM17.5 13.244C17.6989 13.244 17.8897 13.323 18.0303 13.4637C18.171 13.6043 18.25 13.7951 18.25 13.994C18.25 14.1929 18.171 14.3837 18.0303 14.5243C17.8897 14.665 17.6989 14.744 17.5 14.744H13.5C13.3011 14.744 13.1103 14.665 12.9697 14.5243C12.829 14.3837 12.75 14.1929 12.75 13.994C12.75 13.7951 12.829 13.6043 12.9697 13.4637C13.1103 13.323 13.3011 13.244 13.5 13.244H17.5ZM2 12.994V10.994C2 7.223 2 5.337 3.172 4.166C4.146 3.191 6.364 3.027 9 3V20.988C6.364 20.961 4.146 20.797 3.172 19.822C2 18.651 2 16.765 2 12.994Z" fill="#1877F2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1 @@
console.log("sss")

@ -0,0 +1,26 @@
import SvgIcon from '@mui/joy/SvgIcon';
export function GoogleIcon() {
return (
<SvgIcon fontSize="xl">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</SvgIcon>
);
}

@ -0,0 +1,26 @@
import SvgIcon from '@mui/joy/SvgIcon';
export function WechatIcon() {
return (
<SvgIcon fontSize="xl">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</SvgIcon>
);
}

@ -0,0 +1,2 @@
export * from "./GoogleIcon";
export * from "./WechatIcon";

@ -0,0 +1,3 @@
export * from "./icons";
export * from "./layout";
export * from "./theme";

@ -0,0 +1,40 @@
import { CssVarsProvider } from '@mui/joy/styles';
import CssBaseline from '@mui/joy/CssBaseline';
import Box from '@mui/joy/Box';
import { AppBar } from './AppBar';
import { Sidebar } from './Sidebar';
import { Outlet } from 'react-router-dom';
export function AdminRoot() {
return (
<CssVarsProvider disableTransitionOnChange>
<CssBaseline />
<Box sx={{ display: 'flex', minHeight: '100dvh' }}>
<AppBar />
<Sidebar />
<Box
component="main"
className="MainContent"
sx={{
px: { xs: 2, md: 6 },
pt: {
xs: 'calc(12px + var(--Header-height))',
sm: 'calc(12px + var(--Header-height))',
md: 3,
},
pb: { xs: 2, sm: 2, md: 3 },
flex: 1,
display: 'flex',
flexDirection: 'column',
minWidth: 0,
height: '100dvh',
gap: 1,
}}
>
<Outlet />
</Box>
</Box>
</CssVarsProvider>
);
}

@ -0,0 +1,47 @@
import GlobalStyles from "@mui/joy/GlobalStyles";
import Sheet from "@mui/joy/Sheet";
import IconButton from "@mui/joy/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import { toggleSidebar } from "./utils";
export function AppBar() {
return (
<Sheet
sx={{
display: { xs: "flex", md: "none" },
alignItems: "center",
justifyContent: "space-between",
position: "fixed",
top: 0,
width: "100vw",
height: "var(--Header-height)",
zIndex: 9995,
p: 2,
gap: 1,
borderBottom: "1px solid",
borderColor: "background.level1",
boxShadow: "sm",
}}
>
<GlobalStyles
styles={(theme) => ({
":root": {
"--Header-height": "52px",
[theme.breakpoints.up("md")]: {
"--Header-height": "0px",
},
},
})}
/>
<IconButton
onClick={() => toggleSidebar()}
variant="outlined"
color="neutral"
size="sm"
>
<MenuIcon />
</IconButton>
</Sheet>
);
}

@ -0,0 +1,55 @@
import * as React from "react";
import { useColorScheme } from "@mui/joy/styles";
import IconButton, { type IconButtonProps } from "@mui/joy/IconButton";
import DarkModeRoundedIcon from "@mui/icons-material/DarkModeRounded";
import LightModeIcon from "@mui/icons-material/LightMode";
export function ColorSchemeToggle(props: IconButtonProps) {
const { onClick, sx, ...other } = props;
const { mode, setMode } = useColorScheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<IconButton
size="sm"
variant="outlined"
color="neutral"
{...other}
sx={sx}
disabled
/>
);
}
return (
<IconButton
id="toggle-mode"
size="sm"
variant="outlined"
color="neutral"
{...other}
onClick={(event) => {
setMode(mode === "light" ? "dark" : "light");
onClick?.(event);
}}
sx={[
{
"& > *:first-child": {
display: mode === "dark" ? "none" : "initial",
},
"& > *:last-child": {
display: mode === "light" ? "none" : "initial",
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<DarkModeRoundedIcon />
<LightModeIcon />
</IconButton>
);
}

@ -0,0 +1,153 @@
import History from '@mui/icons-material/History';
import ErrorIcon from '@mui/icons-material/Report';
import Accordion from '@mui/joy/Accordion';
import AccordionDetails from '@mui/joy/AccordionDetails';
import AccordionSummary from '@mui/joy/AccordionSummary';
import Button from '@mui/joy/Button';
import { styled } from '@mui/joy/styles';
import Typography from '@mui/joy/Typography';
import {
Title,
useDefaultTitle,
useErrorContext,
useResetErrorBoundaryOnLocationChange,
useTranslate
} from '@rakit/core';
import { Fragment } from 'react';
export function Error() {
const title = useDefaultTitle();
const { error, errorInfo, resetErrorBoundary } = useErrorContext();
const translate = useTranslate();
useResetErrorBoundaryOnLocationChange(resetErrorBoundary);
return (
<Fragment>
{title && <Title title={title} />}
<Root>
<h1 className={ErrorClasses.title} role="alert">
<ErrorIcon className={ErrorClasses.icon} />
{translate('ra.page.error')}
</h1>
<div>{translate('ra.message.error')}</div>
{process.env.NODE_ENV !== 'production' && (
<>
<Accordion className={ErrorClasses.panel}>
<AccordionSummary className={ErrorClasses.panelSumary}>
{translate(error.message)}
</AccordionSummary>
<AccordionDetails className={ErrorClasses.panelDetails}>
{/* error message is repeated here to allow users to copy it. AccordionSummary doesn't support text selection. */}
<p>{translate(error.message)}</p>
<p>{errorInfo?.componentStack}</p>
</AccordionDetails>
</Accordion>
<div className={ErrorClasses.advice}>
<Typography textAlign="center">
Need help with this error? Try the following:
</Typography>
<Typography component="div">
<ul>
<li>
Check the{' '}
<a href="https://marmelab.com/react-admin/documentation.html">
react-admin documentation
</a>
</li>
<li>
Search on{' '}
<a href="https://stackoverflow.com/questions/tagged/react-admin">
StackOverflow
</a>{' '}
for community answers
</li>
<li>
Get help from the core team via{' '}
<a href="https://react-admin-ee.marmelab.com/#fromsww">
react-admin Enterprise Edition
</a>
</li>
</ul>
</Typography>
</div>
</>
)}
<div className={ErrorClasses.toolbar}>
<Button
variant="solid"
startDecorator={<History />}
onClick={goBack}
>
{translate('ra.action.back')}
</Button>
</div>
</Root>
</Fragment>
);
}
const PREFIX = 'RaError';
export const ErrorClasses = {
container: `${PREFIX}-container`,
title: `${PREFIX}-title`,
icon: `${PREFIX}-icon`,
panel: `${PREFIX}-panel`,
panelSumary: `${PREFIX}-panelSumary`,
panelDetails: `${PREFIX}-panelDetails`,
toolbar: `${PREFIX}-toolbar`,
advice: `${PREFIX}-advice`,
};
const Root = styled('div', {
name: PREFIX,
overridesResolver: (_, styles) => styles.root,
})(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
[theme.breakpoints.down('md')]: {
padding: '1em',
},
fontFamily: 'Roboto, sans-serif',
opacity: 0.5,
[`& .${ErrorClasses.title}`]: {
display: 'flex',
alignItems: 'center',
},
[`& .${ErrorClasses.icon}`]: {
width: '2em',
height: '2em',
marginRight: '0.5em',
},
[`& .${ErrorClasses.panel}`]: {
marginTop: '1em',
maxWidth: '60em',
},
[`& .${ErrorClasses.panelSumary}`]: {
userSelect: 'all',
},
[`& .${ErrorClasses.panelDetails}`]: {
whiteSpace: 'pre-wrap',
},
[`& .${ErrorClasses.toolbar}`]: {
marginTop: '2em',
},
[`& .${ErrorClasses.advice}`]: {
marginTop: '2em',
},
}));
function goBack() {
window.history.go(-1);
}

@ -0,0 +1,25 @@
import Box, { BoxProps } from '@mui/joy/Box';
import CircularProgress, { CircularProgressProps } from '@mui/joy/CircularProgress';
export interface LoadingProps extends CircularProgressProps {
rootProps?: BoxProps;
}
export function Loading(props: LoadingProps) {
const { rootProps, ...rest } = props;
return (
<Box
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
gap={2}
height="100dvh"
{...rootProps}
>
<CircularProgress
{...rest}
/>
</Box>
)
}

@ -0,0 +1,291 @@
import Alert, { AlertProps } from "@mui/joy/Alert";
import Button from "@mui/joy/Button";
import IconButton from "@mui/joy/IconButton";
import Typography from "@mui/joy/Typography";
import { styled, VariantProp } from "@mui/joy/styles";
import InfoIcon from '@mui/icons-material/Info';
import WarningIcon from '@mui/icons-material/Warning';
import ReportIcon from '@mui/icons-material/Report';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import {
NotificationPayload,
NotificationPosition,
NotificationType,
useNotificationContext,
useTranslate,
useUndoableDispatcher
} from "@rakit/core";
import {
Toaster,
toast,
ExternalToast
} from 'sonner'
import {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState
} from "react";
const colorMap: Record<NotificationType, AlertProps["color"]> = {
success: 'success',
info: 'primary',
warning: 'warning',
error: 'danger',
}
const iconMap: Record<NotificationType, ReactNode> = {
success: <CheckCircleIcon />,
info: <InfoIcon />,
warning: <WarningIcon />,
error: <ReportIcon />,
}
function getColor(type: NotificationType | undefined): AlertProps["color"] {
return type ? colorMap[type] : "neutral";
}
function getIcon(
type: NotificationType | undefined,
icon: ReactNode | undefined
): ReactNode | undefined {
return icon ?? (type ? iconMap[type] : null);
}
export interface NotificationProps {
expand?: boolean;
multiLine?: boolean;
position?: NotificationPosition;
autoHideDuration?: number;
visibleToasts?: number;
dismissable?: boolean;
gap?: number;
offset?: string | number;
dir?: 'rtl' | 'ltr' | 'auto';
containerAriaLabel?: string;
variant?: VariantProp;
}
type ToastPayload = NotificationPayload & { id: number | string }
function useRenderNotification(
defaultDismissable: boolean | undefined,
defaultMultiLine: boolean | undefined,
variant: VariantProp,
setToastItems: Dispatch<SetStateAction<ToastPayload[]>>
) {
const translate = useTranslate();
const dispatchUndo = useUndoableDispatcher();
return useCallback((notification: NotificationPayload) => {
const {
description,
descriptionArgs,
message,
messageArgs,
multiLine = defaultMultiLine,
icon,
type,
undoable,
undoableId,
dismissable = defaultDismissable,
} = notification;
let isDismissed = false;
let id: string | number;
const onDismiss = (): void => {
if (isDismissed) {
return;
}
isDismissed = true;
setToastItems(list => {
const index = list.findIndex(n => n.id === id);
if (index === -1) return list;
return list.toSpliced(index, 1);
});
}
const data: ExternalToast = {
duration: notification.autoHideDuration,
position: notification.position,
unstyled: true,
onAutoClose: onDismiss,
onDismiss: onDismiss,
};
const color = getColor(type);
const undoButton = undoable ? (
<Button
color={color}
size="sm"
sx={{ mr: dismissable ? 1 : 0 }}
variant={variant}
onClick={() => dispatchUndo(undoableId!, true)}
>
<>{translate('ra.action.undo')}</>
</Button>
) : null;
const dismissButton = dismissable ? (
<IconButton
variant={variant}
color={color}
size="sm"
onClick={() => toast.dismiss(id)}
>
<CloseRoundedIcon />
</IconButton>
) : null;
id = toast((
<Alert
variant={variant}
color={color}
startDecorator={getIcon(type, icon)}
endDecorator={
undoable && dismissable
? (
<>
{undoButton}
{dismissButton}
</>
)
: undoable
? undoButton
: dismissable
? dismissButton
: null
}
role="toast"
>
<div>
{typeof message === "string" ? (
<AutoMultiLine multiLine={multiLine}>
{translate(message, messageArgs)}
</AutoMultiLine>
) : (
<div>{message}</div>
)}
{description != null ? (
<Typography level="body-sm" color={color}>
{typeof description === "string" ? (
<AutoMultiLine multiLine={multiLine}>
{translate(description, descriptionArgs)}
</AutoMultiLine>
) : description}
</Typography>
) : null}
</div>
</Alert >
), data);
setToastItems(list => [...list, {
id,
...notification,
}]);
}, [
defaultDismissable,
defaultMultiLine,
setToastItems,
variant,
]);
}
/**
* Provides a way to show a notification.
* @see useNotify
*
* @example <caption>Basic usage</caption>
* <Notification />
*
* @param props The component props
* @param {string} props.type The notification type. Defaults to 'info'.
* @param {number} props.autoHideDuration Duration in milliseconds to wait until hiding a given notification. Defaults to 4000.
* @param {boolean} props.multiLine Set it to `true` if the notification message should be shown in more than one line.
*/
export function Notification(props: NotificationProps) {
const {
dismissable: defaultDismissable,
expand = false,
multiLine: defaultMultiLine,
position = "bottom-center",
visibleToasts = 3,
variant = "soft",
} = props;
const { notifications, takeNotification } = useNotificationContext();
const [toastItems, setToastItems] = useState<ToastPayload[]>([]);
const renderNotification = useRenderNotification(
defaultDismissable,
defaultMultiLine,
variant,
setToastItems,
);
const hasUndoable = useMemo(
() => toastItems.some(n => n.undoable),
[toastItems],
);
useEffect(() => {
while (notifications.length && toastItems.length < visibleToasts) {
const notification = takeNotification();
if (notification) {
renderNotification(notification);
}
}
const beforeunload = (e: BeforeUnloadEvent) => {
e.preventDefault();
const confirmationMessage = '';
e.returnValue = confirmationMessage;
return confirmationMessage;
};
if (hasUndoable) {
window.addEventListener('beforeunload', beforeunload);
return () => {
window.removeEventListener('beforeunload', beforeunload);
};
}
}, [
notifications,
toastItems,
visibleToasts,
hasUndoable,
takeNotification,
renderNotification,
]);
return (
<Toaster
expand={expand}
position={position}
duration={props.autoHideDuration}
gap={props.gap}
offset={props.offset}
dir={props.dir}
visibleToasts={visibleToasts}
containerAriaLabel={props.containerAriaLabel}
pauseWhenPageIsHidden={hasUndoable}
/>
)
}
const AutoMultiLine = styled('div', {
name: "AutoMultiLine",
overridesResolver: (props, styles) => styles.root,
shouldForwardProp: prop => prop !== 'multiLine',
})<{ multiLine?: boolean }>(({ multiLine }) => multiLine
? { whiteSpace: 'pre-wrap' }
: {}
);

@ -0,0 +1,16 @@
import { Portlet } from "@rakit/core";
import { ReactNode } from "react";
export function PageActions(props: { children?: ReactNode }) {
const { children } = props;
if (children == null) {
return null;
}
return (
<Portlet to="page-actions">
{children}
</Portlet>
);
}

@ -0,0 +1,99 @@
import Box from '@mui/joy/Box';
import Breadcrumbs from '@mui/joy/Breadcrumbs';
import Link from '@mui/joy/Link';
import Typography from '@mui/joy/Typography';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import { ReactNode, useState } from 'react';
import { PortalProvider, TitlePortalProvider } from '@rakit/core';
export interface PageRootProps {
children: ReactNode;
}
export function PageRoot(props: PageRootProps) {
const [titlePortal, setTitlePortal] = useState<Element | null>(null);
const [actionsPortal, setActionsPortal] = useState<Element | null>(null);
return (
<Box
component="main"
className="MainContent"
sx={{
px: { xs: 2, md: 6 },
pt: {
xs: 'calc(12px + var(--Header-height))',
sm: 'calc(12px + var(--Header-height))',
md: 3,
},
pb: { xs: 2, sm: 2, md: 3 },
flex: 1,
display: 'flex',
flexDirection: 'column',
minWidth: 0,
height: '100dvh',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Breadcrumbs
size="sm"
aria-label="breadcrumbs"
separator={<ChevronRightRoundedIcon fontSize="small" />}
sx={{ pl: 0 }}
>
<Link
underline="none"
color="neutral"
href="#some-link"
aria-label="Home"
>
<HomeRoundedIcon />
</Link>
<Link
underline="hover"
color="neutral"
href="#some-link"
sx={{ fontSize: 12, fontWeight: 500 }}
>
Dashboard
</Link>
<Typography color="primary" sx={{ fontWeight: 500, fontSize: 12 }}>
Orders
</Typography>
</Breadcrumbs>
</Box>
<Box
sx={{
display: 'flex',
mb: 1,
gap: 1,
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'start', sm: 'center' },
flexWrap: 'wrap',
justifyContent: 'space-between',
}}
>
<Typography
level="h2"
component="h1"
flex="1"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
ref={setTitlePortal}
/>
<Box ref={setActionsPortal} />
</Box>
<TitlePortalProvider value={titlePortal}>
<PortalProvider
name="page-actions"
container={actionsPortal}
>
{props.children}
</PortalProvider>
</TitlePortalProvider>
</Box>
)
}

@ -0,0 +1,358 @@
import Box from '@mui/joy/Box';
import Drawer from '@mui/joy/Drawer';
import Button from '@mui/joy/Button';
import Card from '@mui/joy/Card';
import CardContent from '@mui/joy/CardContent';
import Checkbox from '@mui/joy/Checkbox';
import DialogTitle from '@mui/joy/DialogTitle';
import DialogContent from '@mui/joy/DialogContent';
import ModalClose from '@mui/joy/ModalClose';
import Divider from '@mui/joy/Divider';
import FormControl from '@mui/joy/FormControl';
import FormLabel from '@mui/joy/FormLabel';
import List from '@mui/joy/List';
import ListItem from '@mui/joy/ListItem';
import ListItemDecorator from '@mui/joy/ListItemDecorator';
import Stack from '@mui/joy/Stack';
import RadioGroup from '@mui/joy/RadioGroup';
import Radio from '@mui/joy/Radio';
import Sheet from '@mui/joy/Sheet';
import Typography from '@mui/joy/Typography';
import TuneIcon from '@mui/icons-material/TuneRounded';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import ApartmentRoundedIcon from '@mui/icons-material/ApartmentRounded';
import MeetingRoomRoundedIcon from '@mui/icons-material/MeetingRoomRounded';
// import HotelRoundedIcon from '@mui/icons-material/HotelRounded';
import Person from '@mui/icons-material/Person';
import People from '@mui/icons-material/People';
// import Apartment from '@mui/icons-material/Apartment';
// import Done from '@mui/icons-material/Done';
import { Fragment, useState } from 'react';
import IcContrastSvg from "../assets/ic-contrast.svg";
// import IcAutofitWidthSvg from "../assets/ic-autofit-width.svg";
import IcSidebarOutlineSvg from "../assets/ic-sidebar-outline.svg";
import IcSidebarFilledSvg from "../assets/ic-sidebar-filled.svg";
const sidebarSettings = [
{
id: "integrate",
label: "Integrate",
icon: IcSidebarOutlineSvg,
},
{
id: "apparent",
label: "Apparent",
icon: IcSidebarFilledSvg,
},
]
const genericSettings = [
{
id: "comtrast",
label: "Comtrast",
icon: IcContrastSvg,
},
{
id: "compact",
label: "Compact",
icon: IcSidebarFilledSvg,
},
]
export default function Settings() {
const [open, setOpen] = useState(false);
const [type, setType] = useState('Guesthouse');
const [, setAmenities] = useState([0, 6]);
return (
<Fragment>
<Button
variant="outlined"
color="neutral"
startDecorator={<TuneIcon />}
onClick={() => setOpen(true)}
>
Change filters
</Button>
<Drawer
size="md"
variant="plain"
open={open}
onClose={() => setOpen(false)}
slotProps={{
content: {
sx: {
bgcolor: 'transparent',
p: { md: 3, sm: 0 },
boxShadow: 'none',
},
},
}}
>
<Sheet
sx={{
borderRadius: 'md',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
overflow: 'auto',
}}
>
<DialogTitle>Filters</DialogTitle>
<ModalClose />
<Divider sx={{ mt: 'auto' }} />
<DialogContent sx={{ gap: 2 }}>
<FormControl>
<FormLabel sx={{ typography: 'title-md', fontWeight: 'bold' }}>
Property type
</FormLabel>
<RadioGroup
value={type || ''}
onChange={(event) => {
setType(event.target.value);
}}
>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 1.5,
}}
>
{[
{
name: '浅色',
icon: <HomeRoundedIcon />,
},
{
name: '深色',
icon: <ApartmentRoundedIcon />,
},
{
name: '自动',
icon: <MeetingRoomRoundedIcon />,
},
].map((item) => (
<Card
key={item.name}
sx={{
boxShadow: 'none',
'&:hover': { bgcolor: 'background.level1' },
}}
>
<CardContent>
{item.icon}
<Typography level="title-md">{item.name}</Typography>
</CardContent>
<Radio
disableIcon
overlay
checked={type === item.name}
variant="outlined"
color="neutral"
value={item.name}
sx={{ mt: -2 }}
slotProps={{
action: {
sx: {
...(type === item.name && {
borderWidth: 2,
borderColor:
'var(--joy-palette-primary-outlinedBorder)',
}),
'&:hover': {
bgcolor: 'transparent',
},
},
},
}}
/>
</Card>
))}
</Box>
</RadioGroup>
</FormControl>
<Typography level="title-md" sx={{ fontWeight: 'bold', mt: 1 }}>
</Typography>
<RadioGroup aria-label="Your plan" name="people" defaultValue="Individual">
<List
sx={{
minWidth: 240,
'--List-gap': '0.5rem',
'--ListItem-paddingY': '1rem',
'--ListItem-radius': '8px',
'--ListItemDecorator-size': '32px',
}}
>
{sidebarSettings.map((item, index) => (
<ListItem variant="outlined" key={item.id} sx={{ boxShadow: 'sm' }}>
<ListItemDecorator>
{[<Person key="1" />, <People key="2" />][index]}
</ListItemDecorator>
<Radio
overlay
value={item.id}
label={item.label}
sx={{ flexGrow: 1, flexDirection: 'row-reverse' }}
slotProps={{
action: ({ checked }) => ({
sx: (theme) => ({
...(checked && {
inset: -1,
border: '2px solid',
borderColor: theme.vars.palette.primary[500],
}),
}),
}),
}}
/>
</ListItem>
))}
</List>
</RadioGroup>
<Typography id="sandwich-group" level="title-md" sx={{ fontWeight: 'bold', mt: 1 }}>
Settings
</Typography>
<div role="group" aria-labelledby="sandwich-group">
<List
sx={{
minWidth: 240,
'--List-gap': '0.5rem',
'--ListItem-paddingY': '1rem',
'--ListItem-radius': '8px',
'--ListItemDecorator-size': '32px',
}}
>
{genericSettings.map((item, index) => (
<ListItem variant="outlined" key={item.id} sx={{ boxShadow: 'sm' }}>
<ListItemDecorator>
{[<Person key="p" />, <People key="2" />][index]}
</ListItemDecorator>
<Checkbox
overlay
value={item.id}
label={item.label}
sx={{ flexGrow: 1, flexDirection: 'row-reverse' }}
slotProps={{
action: ({ checked }) => ({
sx: (theme) => ({
...(checked && {
inset: -1,
border: '2px solid',
borderColor: theme.vars.palette.primary[500],
}),
}),
}),
}}
/>
</ListItem>
))}
</List>
</div>
<Typography level="title-md" sx={{ fontWeight: 'bold', mt: 1 }}>
Presets
</Typography>
<FormControl>
<FormLabel sx={{ typography: 'title-md', fontWeight: 'bold' }}>
Presets
</FormLabel>
<RadioGroup
value={type || ''}
onChange={(event) => {
setType(event.target.value);
}}
>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 1.5,
}}
>
{[
{
name: '浅色',
icon: <HomeRoundedIcon />,
},
{
name: '深色',
icon: <ApartmentRoundedIcon />,
},
{
name: '自动',
icon: <MeetingRoomRoundedIcon />,
},
].map((item) => (
<Card
key={item.name}
sx={{
boxShadow: 'none',
'&:hover': { bgcolor: 'background.level1' },
}}
>
<CardContent>
{item.icon}
<Typography level="title-md">{item.name}</Typography>
</CardContent>
<Radio
disableIcon
overlay
checked={type === item.name}
variant="outlined"
color="neutral"
value={item.name}
sx={{ mt: -2 }}
slotProps={{
action: {
sx: {
...(type === item.name && {
borderWidth: 2,
borderColor:
'var(--joy-palette-primary-outlinedBorder)',
}),
'&:hover': {
bgcolor: 'transparent',
},
},
},
}}
/>
</Card>
))}
</Box>
</RadioGroup>
</FormControl>
</DialogContent>
<Divider sx={{ mt: 'auto' }} />
<Stack
direction="row"
useFlexGap
spacing={1}
sx={{ justifyContent: 'space-between' }}
>
<Button
variant="outlined"
color="neutral"
onClick={() => {
setType('');
setAmenities([]);
}}
>
Clear
</Button>
<Button onClick={() => setOpen(false)}>Show 165 properties</Button>
</Stack>
</Sheet>
</Drawer>
</Fragment>
);
}

@ -0,0 +1,386 @@
import * as React from "react";
import GlobalStyles from "@mui/joy/GlobalStyles";
import Avatar from "@mui/joy/Avatar";
import Box from "@mui/joy/Box";
import Button from "@mui/joy/Button";
import Card from "@mui/joy/Card";
import Chip, { type ChipProps } from "@mui/joy/Chip";
import Divider from "@mui/joy/Divider";
import IconButton from "@mui/joy/IconButton";
// import Input from "@mui/joy/Input"
import LinearProgress from "@mui/joy/LinearProgress";
import List from "@mui/joy/List";
import ListItem from "@mui/joy/ListItem";
import ListItemButton, { listItemButtonClasses } from "@mui/joy/ListItemButton";
import ListItemContent from "@mui/joy/ListItemContent";
import Typography from "@mui/joy/Typography";
import Sheet from "@mui/joy/Sheet";
import Stack from "@mui/joy/Stack";
// import SearchRoundedIcon from "@mui/icons-material/SearchRounded"
import HomeRoundedIcon from "@mui/icons-material/HomeRounded";
import DashboardRoundedIcon from "@mui/icons-material/DashboardRounded";
import ShoppingCartRoundedIcon from "@mui/icons-material/ShoppingCartRounded";
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
import QuestionAnswerRoundedIcon from "@mui/icons-material/QuestionAnswerRounded";
import GroupRoundedIcon from "@mui/icons-material/GroupRounded";
import SupportRoundedIcon from "@mui/icons-material/SupportRounded";
import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
import BrightnessAutoRoundedIcon from "@mui/icons-material/BrightnessAutoRounded";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { ColorSchemeToggle } from "./ColorSchemeToggle";
import { closeSidebar } from "./utils";
interface ChildItem {
name: string
label: string
badge?: React.ComponentType<ChipProps>
}
interface RootItem extends ChildItem {
icon: React.ComponentType
label: string
children?: ChildItem[]
}
const items: RootItem[] = [
{
name: "home",
icon: HomeRoundedIcon,
label: "Home",
},
{
name: "dashboard",
icon: DashboardRoundedIcon,
label: "Dashboard",
},
{
name: "orders",
icon: ShoppingCartRoundedIcon,
label: "Orders",
},
{
name: "tasks",
icon: AssignmentRoundedIcon,
label: "Tasks",
children: [
{ name: "all", label: "All tasks" },
{ name: "backlog", label: "Backlog" },
{ name: "inProgress", label: "In progress" },
{ name: "done", label: "Done" },
],
},
{
name: "messages",
icon: QuestionAnswerRoundedIcon,
label: "Messages",
badge: EventBadge,
},
{
name: "users",
icon: GroupRoundedIcon,
label: "Users",
children: [
{ name: "myProfile", label: "My profile" },
{ name: "create", label: "Create a new user" },
{ name: "roles&permissions", label: "Roles & permission" },
],
},
// {
// icon: SupportRoundedIcon,
// label: "Support",
// },
// {
// icon: SettingsRoundedIcon,
// label: "Settings",
// }
];
function RenderRootItem(props: RootItem) {
const {
icon: Icon,
badge: Badge,
label,
children,
} = props;
// TODO
const selected = label === "Orders";
if (!children?.length) {
return (
<ListItem>
<ListItemButton
role="menuitem"
component="a"
href="/joy-ui/getting-started/templates/messages/"
selected={selected}
>
<Icon />
<ListItemContent>
<Typography level="title-sm">{label}</Typography>
</ListItemContent>
{Badge && <Badge size="sm" color="primary" variant="solid" />}
</ListItemButton>
</ListItem>
);
}
return (
<ListItem nested>
<Toggler
renderToggle={({ open, setOpen }) => (
<ListItemButton
selected={selected}
onClick={() => setOpen(!open)}
>
<Icon />
<ListItemContent>
<Typography level="title-sm">{label}</Typography>
</ListItemContent>
{Badge && <Badge size="sm" color="primary" variant="solid" />}
<KeyboardArrowDownIcon
sx={{ transform: open ? "rotate(180deg)" : "none" }}
/>
</ListItemButton>
)}
>
<List sx={{ gap: 0.5 }}>
{children.map(({ label, badge: Badge }, index) => (
<ListItem
key={label}
sx={{ mt: index > 0 ? 0 : 0.5 }}
>
<ListItemButton
role="menuitem"
component="a"
href="/joy-ui/getting-started/templates/profile-dashboard/"
selected={selected}
>
<RingIcon />
<ListItemContent>{label}</ListItemContent>
{Badge && <Badge size="sm" color="primary" variant="solid" />}
</ListItemButton>
</ListItem>
))}
</List>
</Toggler>
</ListItem>
);
}
function EventBadge(props: ChipProps) {
return (
<Chip {...props}>4</Chip>
);
}
function RingIcon() {
return (
<Box
sx={{
width: "8px",
height: "8px",
borderRadius: "4px",
boxSizing: "border-box",
border: "2px solid #ccc"
}}
/>
);
}
function Toggler({
defaultExpanded = false,
renderToggle,
children,
}: {
defaultExpanded?: boolean;
children: React.ReactNode;
renderToggle: (params: {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => React.ReactNode;
}) {
const [open, setOpen] = React.useState(defaultExpanded);
return (
<React.Fragment>
{renderToggle({ open, setOpen })}
<Box
sx={{
display: "grid",
gridTemplateRows: open ? "1fr" : "0fr",
transition: "0.2s ease",
"& > *": {
overflow: "hidden",
},
}}
>
{children}
</Box>
</React.Fragment>
);
}
export function Sidebar() {
return (
<Sheet
className="Sidebar"
sx={{
position: { xs: "fixed", md: "sticky" },
transform: {
xs: "translateX(calc(100% * (var(--SideNavigation-slideIn, 0) - 1)))",
md: "none",
},
transition: "transform 0.4s, width 0.4s",
zIndex: 10000,
height: "100dvh",
width: "var(--Sidebar-width)",
top: 0,
// p: 2,
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: 2,
borderRight: "1px solid",
borderColor: "divider",
}}
>
<GlobalStyles
styles={(theme) => ({
":root": {
"--Sidebar-width": "80%",
[theme.breakpoints.up("sm")]: {
"--Sidebar-width": "40%",
},
[theme.breakpoints.up("md")]: {
"--Sidebar-width": "240px",
},
[theme.breakpoints.up("lg")]: {
"--Sidebar-width": "260px",
},
},
})}
/>
<Box
className="Sidebar-overlay"
sx={{
position: "fixed",
zIndex: 9998,
top: 0,
left: 0,
width: "100vw",
height: "100vh",
opacity: "var(--SideNavigation-slideIn)",
backgroundColor: "var(--joy-palette-background-backdrop)",
transition: "opacity 0.4s",
transform: {
xs: "translateX(calc(100% * (var(--SideNavigation-slideIn, 0) - 1) + var(--SideNavigation-slideIn, 0) * var(--Sidebar-width, 0px)))",
lg: "translateX(-100%)",
},
}}
onClick={() => closeSidebar()}
/>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", p: 2 }}>
<IconButton variant="soft" color="primary" size="sm">
<BrightnessAutoRoundedIcon />
</IconButton>
<Typography level="title-lg">Acme Co.</Typography>
<ColorSchemeToggle sx={{ ml: "auto" }} />
</Box>
{/* <Input
size="sm"
sx={{ mx: 2 }}
startDecorator={<SearchRoundedIcon />}
placeholder="Search"
/> */}
<Box
sx={{
minHeight: 0,
overflow: "hidden auto",
flexGrow: 1,
display: "flex",
px: 2,
flexDirection: "column",
[`& .${listItemButtonClasses.root}`]: {
gap: 1.5,
},
}}
>
<List
size="sm"
sx={{
gap: 1,
"--List-nestedInsetStart": "30px",
"--ListItem-radius": (theme) => theme.vars.radius.sm,
}}
>
{items.map((item) => (
<RenderRootItem key={item.label} {...item} />
))}
</List>
<List
size="sm"
sx={{
mt: "auto",
flexGrow: 0,
"--ListItem-radius": (theme) => theme.vars.radius.sm,
"--List-gap": "8px",
mb: 2,
}}
>
<ListItem>
<ListItemButton>
<SupportRoundedIcon />
Support
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>
<SettingsRoundedIcon />
Settings
</ListItemButton>
</ListItem>
</List>
<Card
invertedColors
variant="soft"
color="warning"
size="sm"
sx={{ boxShadow: "none" }}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography level="title-sm">Used space</Typography>
<IconButton size="sm">
<CloseRoundedIcon />
</IconButton>
</Stack>
<Typography level="body-xs">
Your team has used 80% of your available space. Need more?
</Typography>
<LinearProgress variant="outlined" value={80} determinate sx={{ my: 1 }} />
<Button size="sm" variant="solid">
Upgrade plan
</Button>
</Card>
</Box>
<Divider />
<Box sx={{ display: "flex", gap: 1, alignItems: "center", px: 2, pb: 2 }}>
<Avatar
variant="outlined"
size="sm"
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=286"
/>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography level="title-sm">Siriwat K.</Typography>
<Typography level="body-xs">siriwatk@test.com</Typography>
</Box>
<IconButton size="sm" variant="plain" color="neutral">
<LogoutRoundedIcon />
</IconButton>
</Box>
</Sheet>
);
}

@ -0,0 +1,183 @@
import AspectRatio from '@mui/joy/AspectRatio';
import Box from '@mui/joy/Box';
import Button from '@mui/joy/Button';
import Typography from '@mui/joy/Typography';
import { useDefaultTitle } from '@rakit/core';
import { ColorSchemeToggle } from './ColorSchemeToggle';
import { ReactNode } from 'react';
const Icon403 = (
<svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id=":r2:" x1="19.496%" x2="77.479%" y1="71.822%" y2="16.69%">
<stop offset="0%" stop-color="var(--palette-primary-main)" />
<stop offset="100%" stop-color="var(--palette-primary-main)" stop-opacity="0" />
</linearGradient>
</defs>
<path fill="url(#:r2:)" fill-rule="nonzero" d="M0 198.78c0 41.458 14.945 79.236 39.539 107.786 28.214 32.765 69.128 53.365 114.734 53.434a148.44 148.44 0 0056.495-11.036c9.051-3.699 19.182-3.274 27.948 1.107a75.779 75.779 0 0033.957 8.01c5.023 0 9.942-.494 14.7-1.433 13.58-2.67 25.94-8.99 36.09-17.94 6.378-5.627 14.547-8.456 22.897-8.446h.142c27.589 0 53.215-8.732 74.492-23.696 19.021-13.36 34.554-31.696 44.904-53.224C474.92 234.58 480 213.388 480 190.958c0-76.93-59.774-139.305-133.498-139.305-7.516 0-14.88.663-22.063 1.899C305.418 21.42 271.355 0 232.499 0a103.651 103.651 0 00-45.88 10.661c-13.24 6.487-25.011 15.705-34.64 26.939-32.698.544-62.931 11.69-87.676 30.291C25.351 97.155 0 144.882 0 198.781z" opacity="0.2" />
<image href="https://assets.minimals.cc/public/assets/illustrations/characters/character-4.webp" height="300" x="220" y="30" />
<path fill="var(--palette-primary-main)" d="M425.545 119.2c0-5-4.6-9-9.6-8.2-2-3.7-6-6-10.2-5.9 4.3-21.4-30-21.4-25.7 0-8.7-.8-15.1 9.4-10.4 16.8 2.1 3.5 5.9 5.6 10 5.5h38.7v-.1c4.1-.4 7.2-3.9 7.2-8.1zm-321.3 81.8c.1-4.2-4.1-7.8-8.2-7-1.7-3.2-5.1-5.1-8.8-5 3.8-18.4-25.8-18.4-22 0-7.4-.7-12.9 8.1-8.9 14.4 1.8 3 5.1 4.8 8.6 4.7h33.2v-.1c3.4-.4 6.1-3.4 6.1-7z" opacity="0.08" /><path fill="#FFAB00" d="M111.045 142.2c58.7-1 58.6-88.3 0-89.2-58.6 1-58.6 88.3 0 89.2z" opacity="0.12" />
<path fill="#FFD666" d="M111.045 121c30.8-.5 30.8-46.3 0-46.8-30.8.5-30.8 46.3 0 46.8z" />
<path fill="#FBCDBE" d="M278.045 250.1c-4.6-6.5-14 5.1-18.1 7.2-.6-2.1 1.5-41.3-1.4-41.8-2.8-3-8.1-.7-8 3.3.2-4 .5-11.3-5.6-10.2-4.8.6-3.8 6.9-3.8 10.2.1-6.1-9.5-6.1-9.4 0v5.6c.2-4.2-5.7-6.4-8.3-3-2.6-.2-.4 41.8-1.1 43.3-.2 10 8.7 19 18.8 18.7 6.1.4 12.6-1.2 16.8-5.9l19.7-21c1.7-1.6 1.8-4.5.4-6.4z" />
<path fill="#000" fill-opacity="0.24" fill-rule="evenodd" d="M248.745 212.3v32.8h1.9v-31.9c.1-2.9-2.8-5.2-5.6-4.6 2 0 3.7 1.7 3.7 3.7zm-9.4 5.6v27.2h1.9v-26.3c.1-2.8-2.8-5.2-5.5-4.6 1.9 0 3.6 1.8 3.6 3.7zm-9.4 27.2v-21.6c.1-2-1.7-3.7-3.7-3.8 2.8-.6 5.6 1.8 5.5 4.6V245h-1.8v.1z" clip-rule="evenodd" />
<path fill="var(--palette-primary-darker)" d="M244.945 189.8c-67.6 1.3-77 97-11 111.4 81 11.8 92.7-107.3 11-111.4zm-48.5 56.2c-1-40.4 49.8-63.8 79.9-36.9l-68.3 68.3c-7.5-8.7-11.6-19.9-11.6-31.4zm48.5 48.5c-11.5 0-22.7-4.1-31.4-11.6l68.3-68.3c27 30.1 3.5 80.9-36.9 79.9z" />
<path fill="url(#paint0_linear_1_129)" d="M169.245 261h-11.3v-66.6c0-4.5-1.5-5.6-5.6-5.6-5.3.3-13.8-1.4-17.1 4l-55 68.3c-2.7 3.3-1.8 8.8-2 12.8 0 4.1 1.5 5.6 5.6 5.6h54.7v21.7c-.9 7.9 9.1 5.2 13.7 5.6 4.1 0 5.6-1.5 5.6-5.6v-21.7h11.4c4.4 0 5.6-1.5 5.6-5.6-.3-4.8 2-13.8-5.6-12.9zm-30.8 0h-36l36-44.4V261zm263.9 12.1c1.9 44.8-78.7 46-78 1.2h19.3c-.8 15.3 18.3 21.4 30.1 15.5 12.7-6 12.3-29.1-1-34-5.6-2.8-16.6-2-23.1-2.1v-15.1c6.3-.2 17.6.9 22.7-2.3 11.6-5.5 11.9-25.4.9-31.4-10.8-5.9-29 .1-28.2 14.5h-19.4c-.5-28.1 35.4-38.5 57-28.2 23.4 9 24.1 45.5-.2 54.6 12.3 3.9 20.1 14.6 19.9 27.3z" />
<defs>
<linearGradient id="paint0_linear_1_129" x1="78.245" x2="78.245" y1="187.309" y2="307.306" gradientUnits="userSpaceOnUse">
<stop stop-color="var(--palette-primary-light)" />
<stop offset="1" stop-color="var(--palette-primary-dark)" />
</linearGradient>
</defs>
</svg>
);
const Icon404 = (
<svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id=":rm9:" x1="19.496%" x2="77.479%" y1="71.822%" y2="16.69%">
<stop offset="0%" stop-color="var(--palette-primary-main)" />
<stop offset="100%" stop-color="var(--palette-primary-main)" stop-opacity="0" />
</linearGradient>
</defs>
<path fill="url(#:rm9:)" fill-rule="nonzero" d="M0 198.78c0 41.458 14.945 79.236 39.539 107.786 28.214 32.765 69.128 53.365 114.734 53.434a148.44 148.44 0 0056.495-11.036c9.051-3.699 19.182-3.274 27.948 1.107a75.779 75.779 0 0033.957 8.01c5.023 0 9.942-.494 14.7-1.433 13.58-2.67 25.94-8.99 36.09-17.94 6.378-5.627 14.547-8.456 22.897-8.446h.142c27.589 0 53.215-8.732 74.492-23.696 19.021-13.36 34.554-31.696 44.904-53.224C474.92 234.58 480 213.388 480 190.958c0-76.93-59.774-139.305-133.498-139.305-7.516 0-14.88.663-22.063 1.899C305.418 21.42 271.355 0 232.499 0a103.651 103.651 0 00-45.88 10.661c-13.24 6.487-25.011 15.705-34.64 26.939-32.698.544-62.931 11.69-87.676 30.291C25.351 97.155 0 144.882 0 198.781z" opacity="0.2" />
<image href="https://assets.minimals.cc/public/assets/illustrations/characters/character-6.webp" height="300" x="205" y="30" />
<path fill="#FFAB00" d="M111.1 141.2c58.7-1 58.6-88.3 0-89.2-58.6 1-58.6 88.3 0 89.2z" opacity="0.12" />
<path fill="#FFD666" d="M111.1 120c30.8-.5 30.8-46.3 0-46.8-30.8.5-30.8 46.3 0 46.8z" />
<path fill="var(--palette-primary-darker)" d="M244.9 182.5c82.3 1.4 82.2 123.8 0 125.2-82.3-1.5-82.3-123.8 0-125.2zm0 23.1c-51.8.9-51.8 77.9 0 78.8 51.8-.9 51.7-77.9 0-78.8z" />
<path fill="url(#paint0_linear_1_119)" d="M175 265.6c1-8.7-12.1-4.8-17-5.6v-66.6c0-4.5-1.5-5.6-5.6-5.6-5.3.3-13.8-1.4-17.1 4l-55 68.3c-2.7 3.3-1.8 8.8-2 12.8 0 4.1 1.5 5.6 5.6 5.6h54.7v21.7c-.9 7.9 9.1 5.2 13.7 5.6 4.1 0 5.6-1.5 5.6-5.6v-21.7c13.8-1.1 18.1 4.5 17.1-12.9zm-72.5-5.6l36-44.4V260h-36zm309.1 5.6c1-8.7-12.2-4.8-17.1-5.6v-66.6c0-4.5-1.5-5.6-5.6-5.6-5.3.3-13.7-1.4-17.1 4l-55 68.3c-2.7 3.3-1.9 8.8-2 12.8 0 4.1 1.5 5.6 5.6 5.6h54.7v21.7c-.9 7.9 9.1 5.2 13.7 5.6 4.1 0 5.6-1.5 5.6-5.6v-21.7c14.1-1.1 18.2 4.5 17.2-12.9zm-72.4-5.6l36-44.4V260h-36z" />
<path fill="var(--palette-primary-main)" d="M425.6 118.2c0-5-4.6-9-9.6-8.2-2-3.7-6-6-10.2-5.9 4.3-21.4-30-21.4-25.7 0-8.7-.8-15.1 9.4-10.4 16.8 2.1 3.5 5.9 5.6 10 5.5h38.7v-.1c4.1-.4 7.2-3.9 7.2-8.1zM104.3 200c.1-4.2-4.1-7.8-8.2-7-1.7-3.2-5.1-5.1-8.8-5 3.8-18.4-25.8-18.4-22 0-7.4-.7-12.9 8.1-8.9 14.4 1.8 3 5.1 4.8 8.6 4.7h33.2v-.1c3.4-.4 6.1-3.4 6.1-7z" opacity="0.08" />
<defs>
<linearGradient id="paint0_linear_1_119" x1="78.3" x2="78.3" y1="187.77" y2="305.935" gradientUnits="userSpaceOnUse">
<stop stop-color="var(--palette-primary-light)" />
<stop offset="1" stop-color="var(--palette-primary-dark)" />
</linearGradient>
</defs>
</svg>
);
const Icon500 = (
<svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id=":r1:" x1="19.496%" x2="77.479%" y1="71.822%" y2="16.69%">
<stop offset="0%" stop-color="var(--palette-primary-main)" />
<stop offset="100%" stop-color="var(--palette-primary-main)" stop-opacity="0" />
</linearGradient>
</defs>
<path fill="url(#:r1:)" fill-rule="nonzero" d="M0 198.78c0 41.458 14.945 79.236 39.539 107.786 28.214 32.765 69.128 53.365 114.734 53.434a148.44 148.44 0 0056.495-11.036c9.051-3.699 19.182-3.274 27.948 1.107a75.779 75.779 0 0033.957 8.01c5.023 0 9.942-.494 14.7-1.433 13.58-2.67 25.94-8.99 36.09-17.94 6.378-5.627 14.547-8.456 22.897-8.446h.142c27.589 0 53.215-8.732 74.492-23.696 19.021-13.36 34.554-31.696 44.904-53.224C474.92 234.58 480 213.388 480 190.958c0-76.93-59.774-139.305-133.498-139.305-7.516 0-14.88.663-22.063 1.899C305.418 21.42 271.355 0 232.499 0a103.651 103.651 0 00-45.88 10.661c-13.24 6.487-25.011 15.705-34.64 26.939-32.698.544-62.931 11.69-87.676 30.291C25.351 97.155 0 144.882 0 198.781z" opacity="0.2" />
<image href="https://assets.minimals.cc/public/assets/illustrations/characters/character-8.webp" height="300" x="340" y="30" />
<path fill="var(--palette-primary-main)" d="M292.4 266.4h-7.3v-.6h6.7v-59.6h-25.7V118h-23.6v-.6h24.2v88.2h25.7v60.8zM146 164.5h-.6v-21.1h16.5v-19h.6v19.7H146v20.4z" />
<path fill="var(--palette-primary-main)" d="M242.5 112.3c0 3.2-1.3 6.3-3.5 8.5-2.3 2.3-5.3 3.5-8.5 3.5h-82.9c-4.4.1-8.5-2.2-10.7-5.9-2.2-3.8-2.2-8.5 0-12.3 2.2-3.8 6.3-6.1 10.7-5.9h2.8c-2-7.2-.6-14.9 3.9-20.8s11.6-9.4 19-9.4h7c8.9 0 17 4.9 21.1 12.8 2-1 4.2-1.6 6.5-1.6h1.8c3.8 0 7.4 1.5 10.1 4.2 2.7 2.7 4.2 6.3 4.2 10.1v.7c0 1.3-.2 2.7-.6 3.9h6.9c6.8.2 12.2 5.6 12.2 12.2z" opacity="0.08" />
<path fill="#fff" d="M275.4 228.3v14c0 .8-.4 1.5-1.1 1.8-.3.2-.7.3-1.1.3-1.2 0-2.2-1-2.2-2.1v-14c0-4.7-3.5-8.6-8.1-9.1-.4 0-.7-.1-1.1-.1-5.1 0-9.2 4.1-9.2 9.2v14c0 .8-.4 1.5-1.1 1.8-.3.2-.7.3-1.1.3-1.2 0-2.2-1-2.2-2.2v-14c0-7.5 6.1-13.5 13.5-13.5.4 0 .7 0 1.1.1 7.2.6 12.6 6.4 12.6 13.5z" />
<path fill="#DFE3E8" d="M275.4 228.3v14c.3.5.2 1.2-.3 1.6-.5.4-1.1.4-1.6 0s-.6-1.1-.3-1.6v-14c0-5.1-4.1-9.2-9.2-9.2-.4 0-.7 0-1.1.1-.4 0-.7-.1-1.1-.1-5.1 0-9.2 4.1-9.2 9.2v14c.3.5.2 1.2-.3 1.6-.5.4-1.1.4-1.6 0s-.6-1-.3-1.6v-14c-.4-4.7 1.9-9.2 5.9-11.7s9.1-2.5 13.1 0c4.1 2.4 6.4 7 6 11.7z" />
<path fill="var(--palette-primary-darker)" d="M277.8 242.2h-33.2c-4 0-7.3 3.3-7.3 7.3v33.2c0 4 3.3 7.3 7.3 7.3h33.2c4 0 7.3-3.3 7.3-7.3v-33.2c0-4-3.3-7.3-7.3-7.3z" />
<path fill="var(--palette-primary-dark)" d="M277.8 242.2h-24.9c-4 0-7.3 3.3-7.3 7.3v33.2c0 4 3.3 7.3 7.3 7.3h24.9c4 0 7.3-3.3 7.3-7.3v-33.2c0-4-3.3-7.3-7.3-7.3z" />
<path fill="url(#paint0_linear_1_140)" d="M278 145h-22c-4.4 0-8 3.6-8 8v22.8c0 4.4 3.6 8 8 8h22c4.4 0 8-3.6 8-8V153c0-4.4-3.6-8-8-8z" />
<path fill="var(--palette-primary-main)" d="M126 129.7h-22.4c-1.7 0-3 1.3-3 3v32.8c0 1.7 1.3 3 3 3H126c1.7 0 3-1.3 3-3v-32.8c0-1.7-1.4-3-3-3z" opacity="0.08" />
<path fill="#fff" d="M119.1 135.9H96.6c-1.7 0-3 1.3-3 3v32.8c0 1.7 1.3 3 3 3H119c1.7 0 3-1.3 3-3v-32.8c.1-1.6-1.3-3-2.9-3z" />
<path fill="var(--palette-primary-main)" d="M119.1 135.9H96.6c-1.7 0-3 1.3-3 3v32.8c0 1.7 1.3 3 3 3H119c1.7 0 3-1.3 3-3v-32.8c.1-1.6-1.3-3-2.9-3z" opacity="0.48" />
<path fill="var(--palette-primary-main)" d="M80 243.5c.2 2 .9 3.9 2 5.5 4.4 7.8 9.4 15.5 16.2 21.3 10.1 8.5 23.2 12.2 36 15.7-1.1-.6-2.5-6.4-3-7.7-1-2.5-1.9-5.1-2.8-7.6-1.5-4-4.6-7.2-7.7-10.2-6.9-6.7-15.3-11.6-24.5-14.4-5.3-1.7-10.8-2.6-16.2-2.6z" />
<path fill="var(--palette-primary-darker)" d="M129.8 247.8c-1-7.3-2.1-14.6-5-21.4-2.9-6.7-8-12.9-14.9-15.4l-.6 31.2c-.1 6-.2 12.1 1.5 17.9 3 10.1 13.5 21.9 23.6 25.3 1.5-4.8-1-12.2-1.7-17.2l-2.9-20.4z" />
<path fill="var(--palette-primary-dark)" d="M237.2 164H140c-4.7 0-8.4 3.8-8.4 8.4v19.1c0 4.7 3.8 8.4 8.4 8.4h97.1c4.7 0 8.4-3.8 8.4-8.4v-19.1c.1-4.6-3.7-8.4-8.3-8.4zm0 44H140c-4.7 0-8.4 3.8-8.4 8.4v19.1c0 4.7 3.8 8.4 8.4 8.4h97.1c4.7 0 8.4-3.8 8.4-8.4v-19.1c.1-4.6-3.7-8.4-8.3-8.4zm0 44.2H140c-4.7 0-8.4 3.8-8.4 8.4v19.1c0 4.7 3.8 8.4 8.4 8.4h97.1c4.7 0 8.4-3.8 8.4-8.4v-19.1c.1-4.6-3.7-8.4-8.3-8.4z" />
<path fill="url(#paint1_linear_1_140)" d="M237.6 164h-91.2c-4.7 0-8.4 3.8-8.4 8.4v19.1c0 4.7 3.8 8.4 8.4 8.4h91.1c4.7 0 8.4-3.8 8.4-8.4v-19.1c.1-4.6-3.7-8.4-8.3-8.4zm8.4 53.6v16.8c0 2.5-1 5-2.9 6.8-1.9 1.8-4.4 2.8-7.1 2.8h-88c-2.7 0-5.2-1-7.1-2.8-1.9-1.8-2.9-4.2-2.9-6.8v-16.8c0-5.3 4.5-9.6 10-9.6h88c2.7 0 5.2 1 7.1 2.8 1.8 1.8 2.9 4.2 2.9 6.8zm-8.4 34.4h-91.2c-4.7 0-8.4 3.8-8.4 8.4v19.1c0 4.7 3.8 8.4 8.4 8.4h91.1c4.7 0 8.4-3.8 8.4-8.4v-19.1c.1-4.6-3.7-8.4-8.3-8.4z" />
<path fill="var(--palette-primary-lighter)" d="M161.6 182c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zm12 0c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zm8 4c2.2 0 4-1.8 4-4s-1.8-4-4-4-4 1.8-4 4 1.8 4 4 4zM162 226c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zm12 0c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zm8 4c2.2 0 4-1.8 4-4s-1.8-4-4-4-4 1.8-4 4 1.8 4 4 4zm52-6h-30c-.6 0-1 .4-1 1v2c0 .6.4 1 1 1h30c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1zm-72 46c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zm12 0c0 2.2-1.8 4-4 4s-4-1.8-4-4 1.8-4 4-4 4 1.8 4 4zm8 4c2.2 0 4-1.8 4-4s-1.8-4-4-4-4 1.8-4 4 1.8 4 4 4zm52-6h-30c-.6 0-1 .4-1 1v2c0 .6.4 1 1 1h30c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1z" />
<path fill="var(--palette-primary-darker)" d="M264.4 267.7c.5-1.8-.8-3.7-1.2-5.5-.1-.3-.1-.7 0-1 .2-1.5 1.5-2.6 3-2.6s2.8 1.1 3 2.6c.1.3 0 .7 0 1-.3 1.8-1.6 3.8-1.1 5.6l.4 1.3c.5 1.5-.7 3.1-2.3 3.1-1.6 0-2.7-1.5-2.3-3l.5-1.5zM258 158.8l9.2-4.8 8.8 4.8s-1.6 11.8-8.6 15.2c0 0-8.6-3.3-9.4-15.2z" />
<defs>
<linearGradient id="paint0_linear_1_140" x1="277.574" x2="255.652" y1="143.24" y2="187.057" gradientUnits="userSpaceOnUse">
<stop stop-color="var(--palette-primary-main)" />
<stop offset="1" stop-color="var(--palette-primary-dark)" />
</linearGradient>
<linearGradient id="paint1_linear_1_140" x1="138" x2="138" y1="164" y2="287.9" gradientUnits="userSpaceOnUse">
<stop stop-color="var(--palette-primary-light)" />
<stop offset="1" stop-color="var(--palette-primary-dark)" />
</linearGradient>
</defs>
</svg>
);
const statusIcons = {
"403": Icon403,
"404": Icon404,
"500": Icon500,
};
const statusTitles = {
"403": "No permission",
"404": "Sorry, page not found!",
"500": "Internal server error",
};
const statusDescriptions = {
"403": "The page you’re trying to access has restricted access. Please refer to your system administrator.",
"404": "Sorry, we couldn’t find the page you’re looking for. Perhaps you’ve mistyped the URL? Be sure to check your spelling.",
"500": "There was an error, please try again later.",
};
export interface StatusErrorProps {
status: "403" | "404" | "500";
icon?: ReactNode;
title?: ReactNode;
description?: ReactNode;
}
export function StatusError(props: StatusErrorProps) {
const status = statusIcons[props.status] ? props.status : "500";
const siteTitle = useDefaultTitle();
const icon = props.icon || statusIcons[status];
const title = props.title || statusTitles[status];
const description = props.description ?? statusDescriptions[status];
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: {
sm: "flex-start",
md: "center",
},
alignItems: "center",
pt: 12,
pb: 8,
minHeight: "100dvh",
}}
>
<Box sx={{
position: "fixed",
top: 0,
insetInline: 0,
display: "flex",
justifyContent: "space-between",
p: 4,
}}>
<div>{siteTitle}</div>
<ColorSchemeToggle variant="plain" />
</Box>
<Box sx={{ maxWidth: "448px", p: 2 }}>
<Typography level="h2" textAlign="center">
{title}
</Typography>
<Typography color="neutral" pt={2} textAlign="center">
{description}
</Typography>
<AspectRatio
ratio="1"
sx={(theme) => ({
maxWidth: "320px",
pt: 4, flexShrink: 1,
mx: "auto",
"--palette-primary-light": theme.palette.primary[300],
"--palette-primary-main": theme.palette.primary[500],
"--palette-primary-dark": theme.palette.primary[600],
"--palette-primary-darker": theme.palette.primary[700]
})}
variant="plain"
>
{icon}
</AspectRatio>
<Box my={2} textAlign="center">
<Button size="lg" color="neutral">Go to home</Button>
</Box>
</Box>
</Box>
)
}

@ -0,0 +1,15 @@
import Typography, { TypographyProps } from '@mui/joy/Typography';
export function TitlePortal(props: TypographyProps) {
return (
<Typography
sx={{ color: "inherit" }}
flex="1"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
level="title-md"
id="react-admin-title"
{...props} />
);
}

@ -0,0 +1,12 @@
export * from "./AdminRoot";
export * from "./AppBar";
export * from "./ColorSchemeToggle";
export * from "./Error";
export * from "./Loading";
export * from "./Notification";
export * from "./PageActions";
export * from "./PageRoot";
export * from "./Sidebar";
export * from "./StatusError";
export * from "./TitlePortal";
export * from "./utils";

@ -0,0 +1,26 @@
export function openSidebar() {
if (typeof window !== "undefined") {
document.body.style.overflow = "hidden";
document.documentElement.style.setProperty("--SideNavigation-slideIn", "1");
}
}
export function closeSidebar() {
if (typeof window !== "undefined") {
document.documentElement.style.removeProperty("--SideNavigation-slideIn");
document.body.style.removeProperty("overflow");
}
}
export function toggleSidebar() {
if (typeof window !== "undefined" && typeof document !== "undefined") {
const slideIn = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--SideNavigation-slideIn");
if (slideIn) {
closeSidebar();
} else {
openSidebar();
}
}
}

@ -0,0 +1,25 @@
import CssBaseline from '@mui/joy/CssBaseline';
import { CssVarsProvider } from '@mui/joy/styles';
import { createTheme } from "./createTheme";
import { schemeConfig } from './schemeConfig';
// ----------------------------------------------------------------------
type Props = {
children: React.ReactNode;
};
export function ThemeProvider({ children }: Props) {
const theme = createTheme();
return (
<CssVarsProvider
theme={theme}
defaultMode={schemeConfig.defaultMode}
modeStorageKey={schemeConfig.modeStorageKey}
>
<CssBaseline />
{children}
</CssVarsProvider>
);
}

@ -0,0 +1,5 @@
import { Theme, Components } from '@mui/joy/styles';
// ----------------------------------------------------------------------
export const components: Components<Theme> = {}

@ -0,0 +1,11 @@
import { Theme, extendTheme } from "@mui/joy/styles";
import { components } from "./components";
// ----------------------------------------------------------------------
export function createTheme(): Theme {
return extendTheme({
components,
cssVarPrefix: '',
});
}

@ -0,0 +1 @@
export * from "./ThemeProvider";

@ -0,0 +1,4 @@
export const schemeConfig = {
modeStorageKey: 'theme-mode',
defaultMode: 'light',
} as const;

@ -0,0 +1,23 @@
{
"name": "@rakit/core",
"type": "module",
"description": "low level admin & dashboard scaffold",
"main": "./src/index.ts",
"dependencies": {
"@rakit/use-async": "workspace:*",
"@rakit/use-invariant": "workspace:*",
"@tanstack/react-query": "^5.52.2",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-hook-form": "^7.53.0",
"react-is": "^18.3.1",
"react-router-dom": "^6.26.1"
},
"devDependencies": {
"@types/lodash": "^4.17.7",
"@types/react-dom": "^18.3.0",
"@types/react-is": "^18.3.0"
}
}

@ -0,0 +1,7 @@
import { createContext } from "react";
import { AccessControlContextValue } from "./types";
/**
* @private
*/
export const AccessControlContext = createContext<AccessControlContextValue | undefined>(undefined);

@ -0,0 +1,16 @@
import { ReactNode } from "react";
import { AccessControlContext } from "./AccessControlContext";
import { CanFunction } from "./types";
export type AccessControlProviderProps = {
can?: CanFunction;
children?: ReactNode;
}
export function AccessControlProvider(props: AccessControlProviderProps) {
return (
<AccessControlContext.Provider value={props.value}>
{props.children}
</AccessControlContext.Provider>
)
}

@ -0,0 +1,96 @@
import {
cloneElement,
createElement,
isValidElement,
ReactNode,
useEffect
} from "react";
import {
AccessControlOptions,
AccessFallbackComponent,
CanParams
} from "./types";
import { useCan } from "./useCan";
import { useAccessControl } from "./useAccessControl";
type OnUnauthorizedProps = {
reason?: string;
params: CanParams;
};
export interface CanAccessProps extends CanParams {
children: ReactNode;
/**
* Content to show if access control returns `false`
*/
fallback?: AccessFallbackComponent;
loading?: ReactNode
queryOptions?: AccessControlOptions;
/**
* Callback function to be called if access control returns `can: false`
*/
onUnauthorized?: (props: OnUnauthorizedProps) => void;
}
export function CanAccess(props: CanAccessProps) {
const {
children,
fallback,
loading,
queryOptions,
onUnauthorized,
...params
} = props;
const {
isExecuting: isLoading,
reason,
can,
error,
} = useCan({
...params,
queryOptions,
});
const {
fallback: fallbackElement,
} = useAccessControl({});
useEffect(() => {
if (onUnauthorized && can === false && !isLoading) {
onUnauthorized({ reason, params });
}
}, [can, isLoading]);
if (isLoading) {
return loading;
}
if (can) {
return children;
}
return resolveFallback(
fallback ?? fallbackElement,
reason,
error,
);
}
function resolveFallback(
fallback: AccessFallbackComponent | undefined | null,
reason: string | undefined,
error: unknown,
) {
if (fallback == null) {
return null;
}
if (isValidElement(fallback)) {
return cloneElement(fallback, { reason, error })
}
return createElement(fallback, { reason, error })
}

@ -0,0 +1,5 @@
export * from "./AccessControlProvider";
export * from "./CanAccess";
export * from "./types";
export * from "./useAccessControl";
export * from "./useCan";

@ -0,0 +1,52 @@
import { UseAsyncOptions } from "@rakit/use-async";
import { ComponentType, ReactElement } from "react";
export type CanReturn = {
can: boolean;
reason?: string;
};
export interface AccessParamsCustoms {}
export type AccessParams = Record<string, any> & AccessParamsCustoms;
export interface CanParams {
/**
* Resource name for API data interactions
*/
on?: string;
/**
* Intended action on resource
*/
key: string;
/**
* Parameters associated with the resource
* @type {
* resource?: [IResourceItem](https://refine.dev/docs/api-reference/core/interfaceReferences/#canparams),
* id?: [BaseKey](https://refine.dev/docs/api-reference/core/interfaceReferences/#basekey), [key: string]: any
* }
*/
params?: AccessParams;
}
export type CanFunction = (params: CanParams) => Promise<CanReturn> | CanReturn;
export type AccessControlOptions = Omit<
UseAsyncOptions<CanReturn, CanParams, unknown>,
'executor' | 'variables'
>;
export type AccessFallbackProps = {
reason?: string;
error?: unknown;
}
export type AccessFallbackComponent = ComponentType<AccessFallbackProps> | ReactElement<AccessFallbackProps>;
export interface AccessControlContextCustomValue {}
export interface AccessControlContextValue extends AccessControlContextCustomValue {
can?: CanFunction;
queryOptions?: AccessControlOptions;
fallback?: AccessFallbackComponent;
}

@ -0,0 +1,18 @@
import { useContext } from "react";
import { AccessControlContext } from "./AccessControlContext";
import { AccessControlContextValue } from "./types";
export function useAccessControl(): AccessControlContextValue | undefined;
export function useAccessControl(overrides: AccessControlContextValue): AccessControlContextValue;
export function useAccessControl(overrides?: AccessControlContextValue) {
const fromContext = useContext(AccessControlContext)
if (fromContext != null) {
return {
...fromContext,
...overrides,
}
}
return overrides;
}

@ -0,0 +1,70 @@
import {
useAsync,
UseAsyncResult,
useAutomatic
} from "@rakit/use-async";
import {
AccessControlOptions,
CanParams,
CanReturn
} from "./types";
import { useAccessControl } from "./useAccessControl";
import { useMemo } from "react";
export type UseCanProps = CanParams & {
queryOptions?: AccessControlOptions;
}
export type UseCanResult =
& Omit<
UseAsyncResult<CanReturn, CanParams, unknown>,
'execute' | 'abort' | 'data' | 'isExecuting'
>
& { isLoading: boolean }
& CanReturn;
export function useCan(options: UseCanProps): UseCanResult {
const { queryOptions, ...params } = options;
const context = useAccessControl({});
const can = context?.can
const asyncOptions = {
...context?.queryOptions,
...queryOptions,
}
const {
data,
error,
isExecuting: isLoading,
execute,
} = useAsync<CanReturn, CanParams, unknown>({
...asyncOptions,
variables: params,
executor: (params) => can?.(params) ?? ({ can: true }),
immediate: typeof can !== "undefined",
});
useAutomatic({
execute,
params,
asyncOptions,
});
return useMemo(() => {
if (typeof can === "undefined") {
return {
can: true,
isLoading: false,
error: undefined,
reason: undefined,
}
}
return {
can: data?.can ?? false,
error,
isLoading,
reason: data?.reason,
}
}, [can, data, error, isLoading]);
}

@ -0,0 +1,6 @@
import { createContext } from "react";
import { AuthProvider } from "./types";
export const AuthContext = createContext<AuthProvider | undefined>(undefined);
AuthContext.displayName = 'AuthContext';

@ -0,0 +1,58 @@
import { ReactNode } from 'react';
import { useAuthState } from './useAuthState';
export interface AuthenticatedProps {
children: ReactNode;
authParams?: object;
requireAuth?: boolean;
}
/**
* Restrict access to children to authenticated users.
* Redirects anonymous users to the login page.
*
* Use it to decorate your custom page components to require
* authentication.
*
* By default this component is optimistic: it does not block
* rendering children when checking authentication, but this mode
* can be turned off by setting `requireAuth` to true.
*
* You can set additional `authParams` at will if your authProvider
* requires it.
*
* @see useAuthState
*
* @example
* import { Admin, CustomRoutes, Authenticated } from 'react-admin';
*
* const customRoutes = [
* <Route
* path="/foo"
* element={
* <Authenticated authParams={{ foo: 'bar' }}>
* <Foo />
* </Authenticated>
* }
* />
* ];
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
*/
export function Authenticated(props: AuthenticatedProps) {
const { authParams, children, requireAuth = false } = props;
// this hook will log out if the authProvider doesn't validate that the user is authenticated
const { isPending, authenticated } = useAuthState(authParams, true);
// in pessimistic mode don't render the children until authenticated
if ((requireAuth && isPending) || !authenticated) {
return null;
}
// render the children in optimistic rendering or after authenticated
return <>{children}</>;
}

@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useLogout } from './useLogout';
/**
* Log the user out and redirect them to login.
*
* To be used as a catch-all route for anonymous users in a secure app.
*
* @see CoreAdminRoutes
*/
export const LogoutOnMount = () => {
const logout = useLogout();
useEffect(() => {
logout();
}, [logout]);
return null;
};

@ -0,0 +1,64 @@
import { ComponentType, createElement } from "react";
import { useAuthenticated } from "./useAuthenticated";
import { usePermissions } from "./usePermissions";
export interface WithPermissionsProps {
authParams?: object;
component: ComponentType<any>;
location?: Location;
staticContext?: object;
[key: string]: any;
}
/**
* After checking that the user is authenticated,
* retrieves the user's permissions for a specific context.
*
* Useful for Route components ; used internally by Resource.
* Use it to decorate your custom page components to require
* a custom role. It will pass the permissions as a prop to your
* component.
*
* You can set additional `authParams` at will if your authProvider
* requires it.
*
* @example
* import { Admin, CustomRoutes, WithPermissions } from 'react-admin';
*
* const Foo = ({ permissions }) => (
* {permissions === 'admin' ? <p>Sensitive data</p> : null}
* <p>Not sensitive data</p>
* );
*
* const customRoutes = [
* <Route path="/foo" element={
* <WithPermissions
* authParams={{ foo: 'bar' }}
* component={({ permissions, ...props }) => <Foo permissions={permissions} {...props} />}
* />
* } />
* ];
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
*/
export function WithPermissions(props: WithPermissionsProps) {
const {
authParams,
component,
staticContext: _,
...rest
} = props;
useAuthenticated(authParams);
const { permissions } = usePermissions(authParams);
// render even though the usePermissions() call isn't finished (optimistic rendering)
return createElement(component, {
permissions,
...rest
});
}

@ -0,0 +1,16 @@
export * from "./AuthContext";
export * from "./Authenticated";
export * from "./LogoutOnMount";
export * from "./types";
export * from "./useAuthenticated";
export * from "./useAuthProvider";
export * from "./useAuthState";
export * from "./useCheckAuth";
export * from "./useGetIdentity";
export * from "./useGetPermissions";
export * from "./useHandleAuthCallback";
export * from "./useLogin";
export * from "./useLogout";
export * from "./useLogoutIfAccessDenied";
export * from "./usePermissions";
export * from "./WithPermissions";

@ -0,0 +1,35 @@
import { To } from "react-router-dom";
type QueryFunctionContext = {
signal?: AbortSignal;
}
export type WithRedirectTo<P = unknown> = P & {
redirectTo?: To | false;
};
export type AuthRedirectResult = {
redirectTo?: To | false;
logoutOnFailure?: boolean;
};
export interface UserIdentity {
id: string | number;
fullName?: string;
email?: string;
phoneNumber?: string;
avatarUrl?: string;
[key: string]: any;
}
export interface AuthProviderCustoms {}
export interface AuthProvider extends AuthProviderCustoms {
login: (params: any) => Promise<WithRedirectTo | void | any>;
logout: (params: any) => Promise<void | false | string>;
checkAuth: (params: QueryFunctionContext) => Promise<void>;
checkError: (error: any) => Promise<void>;
getIdentity?: (params?: QueryFunctionContext) => Promise<UserIdentity>;
getPermissions: (params: QueryFunctionContext) => Promise<any>;
handleCallback?: (params?: QueryFunctionContext) => Promise<AuthRedirectResult | void | any>;
}

@ -0,0 +1,14 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext";
export const defaultAuthParams = {
loginUrl: '/login',
afterLoginUrl: '/',
};
/**
* Get the authProvider stored in the context
*/
export function useAuthProvider() {
return useContext(AuthContext);
}

@ -0,0 +1,155 @@
import useInvariant from '@rakit/use-invariant';
import {
QueryObserverResult,
useQuery,
UseQueryOptions,
} from '@tanstack/react-query';
import noop from 'lodash/noop';
import { useEffect, useMemo } from "react";
import { useNotify } from "../notification";
import { useBasename } from "../routing";
import { getErrorMessage, removeDoubleSlashes } from "../util";
import { defaultAuthParams, useAuthProvider } from "./useAuthProvider";
import { useLogout } from "./useLogout";
type UseAuthStateOptions<ErrorType = Error> = Omit<
UseQueryOptions<boolean, ErrorType>,
'queryKey' | 'queryFn'
> & {
onSuccess?: (data: boolean) => void;
onError?: (err: ErrorType) => void;
onSettled?: (data?: boolean, error?: Error) => void;
};
export type UseAuthStateResult<ErrorType = Error> =
& QueryObserverResult<boolean, ErrorType>
& { authenticated: boolean };
/**
* Hook for getting the authentication status
*
* Calls the authProvider.checkAuth() method asynchronously.
*
* The return value updates according to the authProvider request state:
*
* - isPending: true just after mount, while the authProvider is being called. false once the authProvider has answered.
* - authenticated: true while loading. then true or false depending on the authProvider response.
*
* To avoid rendering a component and force waiting for the authProvider response, use the useAuthState() hook
* instead of the useAuthenticated() hook.
*
* You can render different content depending on the authenticated status.
*
* @see useAuthenticated()
*
* @param {Object} params Any params you want to pass to the authProvider
*
* @param {Boolean} logoutOnFailure: Optional. Whether the user should be logged out if the authProvider fails to authenticate them. False by default.
*
* @returns The current auth check state. Destructure as { authenticated, error, isPending }.
*
* @example
* import { useAuthState, Loading } from 'react-admin';
*
* const MyPage = () => {
* const { isPending, authenticated } = useAuthState();
* if (isPending) {
* return <Loading />;
* }
* if (authenticated) {
* return <AuthenticatedContent />;
* }
* return <AnonymousContent />;
* };
*/
export function useAuthState<TError = Error>(
params: any = {},
logoutOnFailure: boolean = false,
queryOptions: UseAuthStateOptions<TError> = {},
): UseAuthStateResult<TError> {
const authProvider = useAuthProvider();
const logout = useLogout();
const basename = useBasename();
const notify = useNotify();
const { onSuccess, onError, onSettled, ...options } = queryOptions;
const result = useQuery<boolean, any>({
queryKey: ['auth', 'checkAuth', params],
queryFn: ({ signal }) => {
// The authProvider is optional in react-admin
if (!authProvider) {
return true;
}
return authProvider
.checkAuth({ ...params, signal })
.then(() => true)
.catch(error => {
// This is necessary because react-query requires the error to be defined
if (error != null) {
throw error;
}
throw new Error();
});
},
retry: false,
...options,
});
const onSuccessEvent = useInvariant(onSuccess ?? noop);
const onSettledEvent = useInvariant(onSettled ?? noop);
const onErrorEvent = useInvariant(
onError ??
((error: any) => {
const loginUrl = removeDoubleSlashes(
`${basename}/${defaultAuthParams.loginUrl}`
);
if (logoutOnFailure) {
logout(
{},
error && error.redirectTo != null
? error.redirectTo
: loginUrl
);
const shouldSkipNotify = error && error.message === false;
!shouldSkipNotify &&
notify(
getErrorMessage(error, 'ra.auth.auth_check_error'),
{ type: 'error' }
);
}
})
);
useEffect(() => {
if (result.data === undefined || result.isFetching) return;
onSuccessEvent(result.data);
}, [onSuccessEvent, result.data, result.isFetching]);
useEffect(() => {
if (result.error == null || result.isFetching) return;
onErrorEvent(result.error);
}, [onErrorEvent, result.error, result.isFetching]);
useEffect(() => {
if (result.status === 'pending' || result.isFetching) return;
onSettledEvent(result.data, result.error);
}, [
onSettledEvent,
result.data,
result.error,
result.status,
result.isFetching,
]);
return useMemo(() => {
return {
...result,
// If the data is undefined and the query isn't loading anymore, it means the query failed.
// In that case, we set authenticated to false unless there's no authProvider.
authenticated: result.data ?? result.isLoading
? true
: authProvider == null, // Optimistic,
};
}, [authProvider, result]);
}

@ -0,0 +1,38 @@
import { UseQueryOptions } from '@tanstack/react-query';
import { useAuthState } from "./useAuthState";
export type UseAuthenticatedOptions<ParamsType> =
& Omit<UseQueryOptions<boolean, any>, 'queryKey' | 'queryFn'>
& { params?: ParamsType };
/**
* Restrict access to authenticated users.
* Redirect anonymous users to the login page.
*
* Use it in your custom page components to require
* authentication.
*
* You can set additional `authParams` at will if your authProvider
* requires it.
*
* @example
* import { Admin, CustomRoutes, useAuthenticated } from 'react-admin';
* const FooPage = () => {
* useAuthenticated();
* return <Foo />;
* }
* const customRoutes = [
* <Route path="/foo" element={<FooPage />} />
* ];
* const App = () => (
* <Admin>
* <CustomRoutes>{customRoutes}</CustomRoutes>
* </Admin>
* );
*/
export function useAuthenticated<TParams = any, TError = unknown>({
params,
...options
}: UseAuthenticatedOptions<TParams> = {}) {
useAuthState<TError>(params ?? {}, true, options);
}

@ -0,0 +1,103 @@
import { useCallback } from "react";
import { To } from "react-router-dom";
import { useNotify } from "../notification";
import { useBasename } from "../routing";
import { getErrorMessage, removeDoubleSlashes } from "../util";
import { defaultAuthParams, useAuthProvider } from "./useAuthProvider";
import { useLogout } from "./useLogout";
/**
* Check if the current user is authenticated by calling authProvider.checkAuth().
* Logs the user out on failure.
*
* @param {Object} params The parameters to pass to the authProvider
* @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticate them. True by default.
* @param {string} redirectTo The login form url. Defaults to '/login'
*
* @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise
*/
export type CheckAuth = (
params?: any,
logoutOnFailure?: boolean,
redirectTo?: string
) => Promise<any>;
const checkAuthWithoutAuthProvider = () => Promise.resolve();
/**
* Get a callback for calling the authProvider.checkAuth() method.
* In case of rejection, redirects to the login page, displays a notification,
* and throws an error.
*
* This is a low level hook. See those more specialized hooks
* for common authentication tasks, based on useCheckAuth.
*
* @see useAuthenticated
* @see useAuthState
*
* @returns {Function} checkAuth callback
*
* @example
*
* import { useCheckAuth } from 'react-admin';
*
* const MyProtectedPage = () => {
* const checkAuth = useCheckAuth();
* useEffect(() => {
* checkAuth().catch(() => {});
* }, []);
* return <p>Private content: EZAEZEZAET</p>
* } // tip: use useAuthenticated() hook instead
*
* const MyPage = () => {
* const checkAuth = useCheckAuth();
* const [authenticated, setAuthenticated] = useState(true); // optimistic auth
* useEffect(() => {
* checkAuth({}, false)
* .then(() => setAuthenticated(true))
* .catch(() => setAuthenticated(false));
* }, []);
* return authenticated ? <Bar /> : <BarNotAuthenticated />;
* } // tip: use useAuthState() hook instead
*/
export function useCheckAuth(): CheckAuth {
const authProvider = useAuthProvider();
const notify = useNotify();
const logout = useLogout();
const basename = useBasename();
const loginUrl = basename
? removeDoubleSlashes(`${basename}/${defaultAuthParams.loginUrl}`)
: defaultAuthParams.loginUrl;
const checkAuth = useCallback(
(
params: any = {},
logoutOnFailure = true,
redirectTo?: To
) => authProvider
? authProvider.checkAuth(params).catch(error => {
if (logoutOnFailure) {
logout(
{},
error && error.redirectTo != null
? error.redirectTo
: (redirectTo ?? loginUrl)
);
const shouldSkipNotify = error && error.message === false;
!shouldSkipNotify &&
notify(
getErrorMessage(
error,
'ra.auth.auth_check_error'
),
{ type: 'error' }
);
}
throw error;
})
: checkAuthWithoutAuthProvider(),
[authProvider, logout, notify, loginUrl]
);
return checkAuth;
}

@ -0,0 +1,109 @@
import {
useQuery,
UseQueryOptions,
QueryObserverResult,
} from '@tanstack/react-query';
import { useEffect, useMemo } from "react";
import { UserIdentity } from "./types";
import { useAuthProvider } from "./useAuthProvider";
import useInvariant from '@rakit/use-invariant';
export interface UseGetIdentityOptions<ErrorType extends Error = Error>
extends Omit<UseQueryOptions<UserIdentity, ErrorType>, 'queryKey' | 'queryFn'> {
onSuccess?: (data: UserIdentity) => void;
onError?: (err: Error) => void;
onSettled?: (data?: UserIdentity, error?: Error | null) => void;
}
export type UseGetIdentityResult<ErrorType = Error> = QueryObserverResult<
UserIdentity,
ErrorType
> & {
identity: UserIdentity | undefined;
};
const defaultIdentity: UserIdentity = { id: '' };
/**
* Return the current user identity by calling authProvider.getIdentity() on mount
*
* The return value updates according to the call state:
*
* - mount: { isPending: true }
* - success: { identity, refetch: () => {}, isPending: false }
* - error: { error: Error, isPending: false }
*
* The implementation is left to the authProvider.
*
* @returns The current user identity. Destructure as { isPending, identity, error, refetch }.
*
* @example
* import { useGetIdentity, useGetOne } from 'react-admin';
*
* const PostDetail = ({ id }) => {
* const { data: post, isPending: postLoading } = useGetOne('posts', { id });
* const { identity, isPending: identityLoading } = useGetIdentity();
* if (postLoading || identityLoading) return <>Loading...</>;
* if (!post.lockedBy || post.lockedBy === identity.id) {
* // post isn't locked, or is locked by me
* return <PostEdit post={post} />
* } else {
* // post is locked by someone else and cannot be edited
* return <PostShow post={post} />
* }
* }
*/
export function useGetIdentity<ErrorType extends Error = Error>(
options: UseGetIdentityOptions<ErrorType> = {}
): UseGetIdentityResult<ErrorType> {
const authProvider = useAuthProvider();
const { onSuccess, onError, onSettled, ...queryOptions } = options;
const result = useQuery({
queryKey: ['auth', 'getIdentity'],
queryFn: async ({ signal }) => {
if (
authProvider &&
typeof authProvider.getIdentity === 'function'
) {
return authProvider.getIdentity({ signal });
} else {
return defaultIdentity;
}
},
...queryOptions,
});
const onSuccessEvent = useInvariant(onSuccess ?? noop);
const onErrorEvent = useInvariant(onError ?? noop);
const onSettledEvent = useInvariant(onSettled ?? noop);
useEffect(() => {
if (result.data === undefined || result.isFetching) return;
onSuccessEvent(result.data);
}, [onSuccessEvent, result.data, result.isFetching]);
useEffect(() => {
if (result.error == null || result.isFetching) return;
onErrorEvent(result.error);
}, [onErrorEvent, result.error, result.isFetching]);
useEffect(() => {
if (result.status === 'pending' || result.isFetching) return;
onSettledEvent(result.data, result.error);
}, [
onSettledEvent,
result.data,
result.error,
result.status,
result.isFetching,
]);
return useMemo(
() => ({
...result,
identity: result.data,
}),
[result],
);
}

@ -0,0 +1,58 @@
import { useCallback } from "react";
import { useAuthProvider } from "./useAuthProvider";
/**
* Proxy for calling authProvider.getPermissions()
*
* @param {Object} params The parameters to pass to the authProvider
*
* @return {Promise} The authProvider response
*/
type GetPermissions = (params?: any) => Promise<any>;
const getPermissionsWithoutProvider = () => Promise.resolve([]);
/**
* Get a callback for calling the authProvider.getPermissions() method.
*
* @see useAuthProvider
*
* @returns {Function} getPermissions callback
*
* This is a low level hook. See those more specialized hooks
* offering state handling.
*
* @see usePermissions
*
* @example
*
* import { useGetPermissions } from 'react-admin';
*
* const Roles = () => {
* const [permissions, setPermissions] = useState([]);
* const getPermissions = useGetPermissions();
* useEffect(() => {
* getPermissions().then(permissions => setPermissions(permissions))
* }, [])
* return (
* <ul>
* {permissions.map((permission, key) => (
* <li key={key}>{permission}</li>
* ))}
* </ul>
* );
* }
*/
export function useGetPermissions(): GetPermissions {
const authProvider = useAuthProvider();
return useCallback(
(params: any = {}) =>
authProvider
? authProvider
.getPermissions(params)
.then(result => result ?? null)
: getPermissionsWithoutProvider(),
[authProvider]
);
}

@ -0,0 +1,103 @@
import useInvariant from '@rakit/use-invariant';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import noop from 'lodash/noop';
import { useEffect } from 'react';
import { useLocation } from "react-router-dom";
import { useRedirect } from "../routing";
import { AuthRedirectResult } from "./types";
import { useAuthProvider } from "./useAuthProvider";
/**
* Key used to store the previous location in localStorage.
* Used by the useHandleAuthCallback hook to redirect the user to their previous location after a successful login.
*/
export const PreviousLocationStorageKey = '@react-admin/nextPathname';
export type UseHandleAuthCallbackOptions = Omit<
UseQueryOptions<AuthRedirectResult | void>,
'queryKey' | 'queryFn'
> & {
onSuccess?: (data: AuthRedirectResult | void) => void;
onError?: (err: Error) => void;
onSettled?: (
data?: AuthRedirectResult | void,
error?: Error | null
) => void;
};
/**
* This hook calls the `authProvider.handleCallback()` method on mount. This is meant to be used in a route called
* by an external authentication service (e.g. Auth0) after the user has logged in.
* By default, it redirects to application home page upon success, or to the `redirectTo` location returned by `authProvider. handleCallback`.
*
* @returns An object containing { isPending, data, error, refetch }.
*/
export function useHandleAuthCallback(
options: UseHandleAuthCallbackOptions = {}
) {
const authProvider = useAuthProvider();
const redirect = useRedirect();
const location = useLocation();
const locationState = location.state as any;
const nextPathName = locationState && locationState.nextPathname;
const nextSearch = locationState && locationState.nextSearch;
const defaultRedirectUrl = nextPathName ? nextPathName + nextSearch : '/';
const { onSuccess, onError, onSettled, ...queryOptions } = options ?? {};
const queryResult = useQuery({
queryKey: ['auth', 'handleCallback'],
queryFn: ({ signal }) =>
authProvider && typeof authProvider.handleCallback === 'function'
? authProvider
.handleCallback({ signal })
.then(result => result ?? null)
: Promise.resolve(),
retry: false,
...queryOptions,
});
const onSuccessEvent = useInvariant(
onSuccess ??
((data: any) => {
// AuthProviders relying on a third party services redirect back to the app can't
// use the location state to store the path on which the user was before the login.
// So we support a fallback on the localStorage.
const previousLocation = localStorage.getItem(
PreviousLocationStorageKey
);
const redirectTo =
(data as AuthRedirectResult)?.redirectTo ??
previousLocation;
if (redirectTo === false) {
return;
}
redirect(redirectTo ?? defaultRedirectUrl);
})
);
const onErrorEvent = useInvariant(onError ?? noop);
const onSettledEvent = useInvariant(onSettled ?? noop);
useEffect(() => {
if (queryResult.error == null || queryResult.isFetching) return;
onErrorEvent(queryResult.error);
}, [onErrorEvent, queryResult.error, queryResult.isFetching]);
useEffect(() => {
if (queryResult.data === undefined || queryResult.isFetching) return;
onSuccessEvent(queryResult.data);
}, [onSuccessEvent, queryResult.data, queryResult.isFetching]);
useEffect(() => {
if (queryResult.status === 'pending' || queryResult.isFetching) return;
onSettledEvent(queryResult.data, queryResult.error);
}, [
onSettledEvent,
queryResult.data,
queryResult.error,
queryResult.status,
queryResult.isFetching,
]);
return queryResult;
}

@ -0,0 +1,88 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNotificationContext } from '../notification';
import { defaultAuthParams, useAuthProvider } from './useAuthProvider';
import { useBasename } from '../routing';
import { removeDoubleSlashes } from '../util';
/**
* Log a user in by calling the authProvider.login() method
*
* @param {Object} params Login parameters to pass to the authProvider. May contain username/email, password, etc
* @param {string} pathName The path to redirect to after login. By default, redirects to the home page, or to the last page visited after disconnection.
*
* @return {Promise} The authProvider response
*/
type Login = (params: any, pathName?: string) => Promise<any>;
/**
* Get a callback for calling the authProvider.login() method
* and redirect to the previous authenticated page (or the home page) on success.
*
* @see useAuthProvider
*
* @returns {Function} login callback
*
* @example
*
* import { useLogin } from 'react-admin';
*
* const LoginButton = () => {
* const [loading, setLoading] = useState(false);
* const login = useLogin();
* const handleClick = {
* setLoading(true);
* login({ username: 'john', password: 'p@ssw0rd' }, '/posts')
* .then(() => setLoading(false));
* }
* return <button onClick={handleClick}>Login</button>;
* }
*/
export function useLogin(): Login {
const authProvider = useAuthProvider();
const location = useLocation();
const locationState = location.state as any;
const navigate = useNavigate();
const basename = useBasename();
const { resetNotifications } = useNotificationContext();
const nextPathName = locationState && locationState.nextPathname;
const nextSearch = locationState && locationState.nextSearch;
const afterLoginUrl = basename
? removeDoubleSlashes(`${basename}/${defaultAuthParams.afterLoginUrl}`)
: defaultAuthParams.afterLoginUrl;
const login = useCallback(
async (params: any = {}, pathName?: string) => {
if (authProvider) {
return authProvider.login(params).then(ret => {
resetNotifications();
if (ret && ret.hasOwnProperty('redirectTo')) {
if (ret) {
navigate(ret.redirectTo);
}
} else {
const redirectUrl = pathName
? pathName
: nextPathName + nextSearch || afterLoginUrl;
navigate(redirectUrl);
}
return ret;
});
} else {
resetNotifications();
navigate(afterLoginUrl);
return Promise.resolve();
}
},
[
authProvider,
afterLoginUrl,
navigate,
nextPathName,
nextSearch,
resetNotifications,
]
);
return login;
}

@ -0,0 +1,149 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from "react";
import { Path, useLocation, useNavigate } from "react-router-dom";
import { useBasename } from "../routing";
import { useResetStore } from "../store";
import { removeDoubleSlashes } from "../util";
import { defaultAuthParams, useAuthProvider } from "./useAuthProvider";
/**
* Log the current user out by calling the authProvider.logout() method,
* and redirect them to the login screen.
*
* @param {Object} params The parameters to pass to the authProvider
* @param {string} redirectTo The path name to redirect the user to (optional, defaults to login)
* @param {boolean} redirectToCurrentLocationAfterLogin Whether the button shall record the current location to redirect to it after login. true by default.
*
* @return {Promise} The authProvider response
*/
export type Logout = (
params?: any,
redirectTo?: string | false,
redirectToCurrentLocationAfterLogin?: boolean
) => Promise<any>;
/**
* Get a callback for calling the authProvider.logout() method,
* redirect to the login page, and clear the store.
*
* @see useAuthProvider
*
* @returns {Function} logout callback
*
* @example
*
* import { useLogout } from 'react-admin';
*
* const LogoutButton = () => {
* const logout = useLogout();
* const handleClick = () => logout();
* return <button onClick={handleClick}>Logout</button>;
* }
*/
export function useLogout(): Logout {
const authProvider = useAuthProvider();
const queryClient = useQueryClient();
const resetStore = useResetStore();
const navigate = useNavigate();
// useNavigate forces rerenders on every navigation, even if we don't use the result
// see https://github.com/remix-run/react-router/issues/7634
// so we use a ref to bail out of rerenders when we don't need to
const navigateRef = useRef(navigate);
const location = useLocation();
const basename = useBasename();
const locationRef = useRef(location);
const loginUrl = basename
? removeDoubleSlashes(`${basename}/${defaultAuthParams.loginUrl}`)
: defaultAuthParams.loginUrl;
/*
* We need the current location to pass in the router state
* so that the login hook knows where to redirect to as next route after login.
*
* But if we used the location from useLocation as a dependency of the logout
* function, it would be rebuilt each time the user changes location.
* Consequently, that would force a rerender of all components using this hook
* upon navigation (CoreAdminRouter for example).
*
* To avoid that, we store the location in a ref.
*/
useEffect(() => {
locationRef.current = location;
navigateRef.current = navigate;
}, [location, navigate]);
const logout: Logout = useCallback(async (
params = {},
redirectTo = loginUrl,
redirectToCurrentLocationAfterLogin = true
) => {
if (authProvider) {
return authProvider
.logout(params)
.then(redirectToFromProvider => {
if (redirectToFromProvider === false || redirectTo === false) {
resetStore();
queryClient.clear();
// do not redirect
return;
}
const finalRedirectTo = redirectToFromProvider || redirectTo;
if (finalRedirectTo?.startsWith('http')) {
// absolute link (e.g. https://my.oidc.server/login)
resetStore();
queryClient.clear();
window.location.href = finalRedirectTo;
return finalRedirectTo;
}
// redirectTo is an internal location that may contain a query string, e.g. '/login?foo=bar'
// we must split it to pass a structured location to navigate()
const redirectToParts = finalRedirectTo.split('?');
const newLocation: Partial<Path> = {
pathname: redirectToParts[0],
};
let newLocationOptions = {};
if (redirectToCurrentLocationAfterLogin &&
locationRef.current &&
locationRef.current.pathname) {
newLocationOptions = {
state: {
nextPathname: locationRef.current.pathname,
nextSearch: locationRef.current.search,
},
};
}
if (redirectToParts[1]) {
newLocation.search = redirectToParts[1];
}
navigateRef.current(newLocation, newLocationOptions);
resetStore();
queryClient.clear();
return redirectToFromProvider;
});
} else {
navigateRef.current(
{ pathname: loginUrl },
{
state: {
nextPathname: locationRef.current && locationRef.current.pathname,
},
}
);
resetStore();
queryClient.clear();
return Promise.resolve();
}
}, [
authProvider,
resetStore,
loginUrl,
queryClient,
]);
return logout;
}

@ -0,0 +1,135 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useNotify } from '../notification';
import { getErrorMessage } from '../util';
import { useAuthProvider } from './useAuthProvider';
import { useLogout } from './useLogout';
let timer: ReturnType<typeof setTimeout> | undefined;
/**
* Returns a callback used to call the authProvider.checkError() method
* and an error from the dataProvider. If the authProvider rejects the call,
* the hook logs the user out and shows a logged out notification.
*
* Used in the useDataProvider hook to check for access denied responses
* (e.g. 401 or 403 responses) and trigger a logout.
*
* @see useLogout
* @see useDataProvider
*
* @returns {Function} logoutIfAccessDenied callback
*
* @example
*
* import { useLogoutIfAccessDenied, useNotify, DataProviderContext } from 'react-admin';
*
* const FetchRestrictedResource = () => {
* const dataProvider = useContext(DataProviderContext);
* const logoutIfAccessDenied = useLogoutIfAccessDenied();
* const notify = useNotify()
* useEffect(() => {
* dataProvider.getOne('secret', { id: 123 })
* .catch(error => {
* logoutIfAccessDenied(error);
* notify('server error', { type: 'error' });
* })
* }, []);
* // ...
* }
*/
export const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => {
const authProvider = useAuthProvider();
const logout = useLogout();
const notify = useNotify();
const navigate = useNavigate();
const logoutIfAccessDenied = useCallback(
async (error?: any) => {
if (!authProvider) {
return logoutIfAccessDeniedWithoutProvider();
}
return authProvider
.checkError(error)
.then(() => false)
.catch(async e => {
const logoutUser = e?.logoutUser ?? true;
//manual debounce
if (timer) {
// side effects already triggered in this tick, exit
return true;
}
timer = setTimeout(() => {
timer = undefined;
}, 0);
const redirectTo =
e && e.redirectTo != null
? e.redirectTo
: error && error.redirectTo
? error.redirectTo
: undefined;
const shouldNotify = !(
(e && e.message === false) ||
(error && error.message === false) ||
redirectTo?.startsWith('http')
);
if (shouldNotify) {
// notify only if not yet logged out
authProvider
.checkAuth({})
.then(() => {
if (logoutUser) {
notify(
getErrorMessage(
e,
'ra.notification.logged_out'
),
{ type: 'error' }
);
} else {
notify(
getErrorMessage(
e,
'ra.notification.not_authorized'
),
{ type: 'error' }
);
}
})
.catch(() => { });
}
if (logoutUser) {
logout({}, redirectTo);
} else {
if (redirectTo.startsWith('http')) {
// absolute link (e.g. https://my.oidc.server/login)
window.location.href = redirectTo;
} else {
// internal location
navigate(redirectTo);
}
}
return true;
});
},
[authProvider, logout, notify, navigate]
);
return logoutIfAccessDenied;
};
const logoutIfAccessDeniedWithoutProvider = () => Promise.resolve(false);
/**
* Call the authProvider.authError() method, using the error passed as argument.
* If the authProvider rejects the call, logs the user out and shows a logged out notification.
*
* @param {Error} error An Error object (usually returned by the dataProvider)
*
* @return {Promise} Resolved to true if there was a logout, false otherwise
*/
type LogoutIfAccessDenied = (error?: any) => Promise<boolean>;

@ -0,0 +1,122 @@
import {
QueryObserverResult,
useQuery,
UseQueryOptions,
} from '@tanstack/react-query';
import { useEffect, useMemo } from "react";
import { useAuthProvider } from "./useAuthProvider";
import { useLogoutIfAccessDenied } from "./useLogoutIfAccessDenied";
import useInvariant from '@rakit/use-invariant';
import noop from 'lodash/noop';
export interface UsePermissionsOptions<PermissionsType = any, ErrorType = Error>
extends Omit<
UseQueryOptions<PermissionsType, ErrorType>,
'queryKey' | 'queryFn'
> {
onSuccess?: (data: PermissionsType) => void;
onError?: (err: ErrorType) => void;
onSettled?: (data?: PermissionsType, error?: ErrorType | null) => void;
}
export type UsePermissionsResult<
PermissionsType = any,
ErrorType = Error,
> = QueryObserverResult<PermissionsType, ErrorType> & {
permissions: PermissionsType | undefined;
};
/**
* Hook for getting user permissions
*
* Calls the authProvider.getPermissions() method using react-query.
* If the authProvider returns a rejected promise, returns empty permissions.
*
* The return value updates according to the request state:
*
* - start: { isPending: true }
* - success: { permissions: [any], isPending: false }
* - error: { error: [error from provider], isPending: false }
*
* Useful to enable features based on user permissions
*
* @param {Object} params Any params you want to pass to the authProvider
*
* @returns The current auth check state. Destructure as { permissions, error, isPending, refetch }.
*
* @example
* import { usePermissions } from 'react-admin';
*
* const PostDetail = () => {
* const { isPending, permissions } = usePermissions();
* if (!isPending && permissions == 'editor') {
* return <PostEdit />
* } else {
* return <PostShow />
* }
* };
*/
export function usePermissions<PermissionsType = any, ErrorType = Error>(
params: any = {},
queryParams: UsePermissionsOptions<PermissionsType, ErrorType> = {
staleTime: 5 * 60 * 1000,
}
): UsePermissionsResult<PermissionsType, ErrorType> {
const authProvider = useAuthProvider();
const logoutIfAccessDenied = useLogoutIfAccessDenied();
const { onSuccess, onError, onSettled, ...queryOptions } = queryParams ?? {};
const result = useQuery<PermissionsType, ErrorType>({
queryKey: ['auth', 'getPermissions', params],
queryFn: async ({ signal }) => {
if (!authProvider) return Promise.resolve([]);
const permissions = await authProvider.getPermissions({
...params,
signal,
});
return permissions ?? null;
},
...queryOptions,
});
const onSuccessEvent = useInvariant(onSuccess ?? noop);
const onSettledEvent = useInvariant(onSettled ?? noop);
const onErrorEvent = useInvariant(
onError ??
((error: ErrorType) => {
if (process.env.NODE_ENV === 'development') {
console.error(error);
}
logoutIfAccessDenied(error);
})
);
useEffect(() => {
if (result.data === undefined || result.isFetching) return;
onSuccessEvent(result.data);
}, [onSuccessEvent, result.data, result.isFetching]);
useEffect(() => {
if (result.error == null || result.isFetching) return;
onErrorEvent(result.error);
}, [onErrorEvent, result.error, result.isFetching]);
useEffect(() => {
if (result.status === 'pending' || result.isFetching) return;
onSettledEvent(result.data, result.error);
}, [
onSettledEvent,
result.data,
result.error,
result.status,
result.isFetching,
]);
return useMemo(
() => ({
...result,
permissions: result.data,
}),
[result]
);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save