@ -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. |
@ -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" |
||||||
|
} |
||||||
|
} |
After Width: | Height: | Size: 4.0 KiB |
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" |
||||||
|
} |
||||||
|
} |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1 @@ |
|||||||
|
console.log("sss") |
@ -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] |
||||||
|
); |
||||||
|
} |