@ -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] |
||||
); |
||||
} |