mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(ui): migrate files 1/2 (#251)
This commit is contained in:
parent
9e30b41028
commit
46d1fee353
37 changed files with 1011 additions and 10 deletions
|
@ -28,5 +28,8 @@
|
|||
"engines": {
|
||||
"node": ">=14.15.0",
|
||||
"pnpm": ">=6"
|
||||
},
|
||||
"alias": {
|
||||
"html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
"stylelint": "stylelint \"src/**/*.scss\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/phrases": "^0.1.0",
|
||||
"@logto/schemas": "^0.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"url": "https://github.com/logto-io/logto/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/schemas": "^0.1.0",
|
||||
"@silverhand/essentials": "^1.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -8,13 +8,20 @@
|
|||
"precommit": "lint-staged",
|
||||
"start": "parcel src/index.html",
|
||||
"check": "tsc --noEmit",
|
||||
"build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall",
|
||||
"lint": "eslint --ext .ts --ext .tsx src",
|
||||
"build": "rm -rf dist && parcel build src/index.html --no-autoinstall",
|
||||
"stylelint": "stylelint \"src/**/*.scss\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/phrases": "^0.1.0",
|
||||
"@logto/schemas": "^0.1.0",
|
||||
"classnames": "^2.3.1",
|
||||
"i18next": "^21.6.11",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
"ky": "^0.29.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^11.15.4",
|
||||
"react-router-dom": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/core": "^2.3.1",
|
||||
|
@ -25,6 +32,7 @@
|
|||
"@silverhand/ts-config-react": "^0.8.1",
|
||||
"@types/react": "^17.0.14",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-router-dom": "^5.3.2",
|
||||
"eslint": "^8.1.0",
|
||||
"lint-staged": "^11.1.1",
|
||||
"parcel": "^2.3.1",
|
||||
|
@ -33,6 +41,9 @@
|
|||
"prettier": "^2.3.2",
|
||||
"stylelint": "^13.13.1"
|
||||
},
|
||||
"alias": {
|
||||
"@/*": "./src/$1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand/react"
|
||||
},
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.app {
|
||||
color: #aaa;
|
||||
}
|
|
@ -1,7 +1,30 @@
|
|||
import React from 'react';
|
||||
import { Route, Switch, BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import * as styles from './App.module.scss';
|
||||
import AppContent from './components/AppContent';
|
||||
import useTheme from './hooks/use-theme';
|
||||
import initI18n from './i18n/init';
|
||||
import Consent from './pages/Consent';
|
||||
import Register from './pages/Register';
|
||||
import SignIn from './pages/SignIn';
|
||||
import './scss/normalized.scss';
|
||||
|
||||
export const App = () => {
|
||||
return <h1 className={styles.app}>Hello world!</h1>;
|
||||
void initI18n();
|
||||
|
||||
const App = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AppContent theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route exact path="/sign-in" component={SignIn} />
|
||||
<Route exact path="/sign-in/consent" component={Consent} />
|
||||
<Route exact path="/register" component={Register} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
</AppContent >
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
59
packages/ui-new/src/__mocks__/react-i18next.js
vendored
Normal file
59
packages/ui-new/src/__mocks__/react-i18next.js
vendored
Normal file
|
@ -0,0 +1,59 @@
|
|||
// Copied from https://github.com/i18next/react-i18next/blob/c533ffe092470b58769586681abfc2cf4ecf0b56/example/test-jest/src/__mocks__/react-i18next.js
|
||||
/* eslint-disable unicorn/prefer-module */
|
||||
const React = require('react');
|
||||
const reactI18next = require('react-i18next');
|
||||
|
||||
const hasChildren = (node) => node && (node.children || (node.props && node.props.children));
|
||||
|
||||
const getChildren = (node) =>
|
||||
node && node.children ? node.children : node.props && node.props.children;
|
||||
|
||||
const renderNodes = (reactNodes) => {
|
||||
if (typeof reactNodes === 'string') {
|
||||
return reactNodes;
|
||||
}
|
||||
|
||||
return Object.keys(reactNodes).map((key, i) => {
|
||||
const child = reactNodes[key];
|
||||
const isElement = React.isValidElement(child);
|
||||
|
||||
if (typeof child === 'string') {
|
||||
return child;
|
||||
}
|
||||
|
||||
if (hasChildren(child)) {
|
||||
const inner = renderNodes(getChildren(child));
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return React.cloneElement(child, { ...child.props, key: i }, inner);
|
||||
}
|
||||
|
||||
if (typeof child === 'object' && !isElement) {
|
||||
return Object.keys(child).reduce((string, childKey) => `${string}${child[childKey]}`, '');
|
||||
}
|
||||
|
||||
return child;
|
||||
});
|
||||
};
|
||||
|
||||
const useMock = [(k) => k, {}];
|
||||
useMock.t = (k) => k;
|
||||
useMock.i18n = {};
|
||||
|
||||
const withTranslation = (Component) => (props) => <Component t={(k) => k} {...props} />;
|
||||
|
||||
module.exports = {
|
||||
// This mock makes sure any components using the translate HoC receive the t function as a prop
|
||||
withTranslation: () => withTranslation,
|
||||
Trans: ({ children }) =>
|
||||
Array.isArray(children) ? renderNodes(children) : renderNodes([children]),
|
||||
Translation: ({ children }) => children((k) => k, { i18n: {} }),
|
||||
useTranslation: () => useMock,
|
||||
|
||||
// Mock if needed
|
||||
I18nextProvider: reactI18next.I18nextProvider,
|
||||
initReactI18next: reactI18next.initReactI18next,
|
||||
setDefaults: reactI18next.setDefaults,
|
||||
getDefaults: reactI18next.getDefaults,
|
||||
setI18n: reactI18next.setI18n,
|
||||
getI18n: reactI18next.getI18n,
|
||||
};
|
9
packages/ui-new/src/apis/consent.ts
Normal file
9
packages/ui-new/src/apis/consent.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import ky from 'ky';
|
||||
|
||||
export const consent = async () => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
return ky.post('/api/session/consent').json<Response>();
|
||||
};
|
16
packages/ui-new/src/apis/register.ts
Normal file
16
packages/ui-new/src/apis/register.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import ky from 'ky';
|
||||
|
||||
export const register = async (username: string, password: string) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
return ky
|
||||
.post('/api/session/register', {
|
||||
json: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
};
|
16
packages/ui-new/src/apis/sign-in.ts
Normal file
16
packages/ui-new/src/apis/sign-in.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import ky from 'ky';
|
||||
|
||||
export const signInBasic = async (username: string, password: string) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
return ky
|
||||
.post('/api/session', {
|
||||
json: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
};
|
71
packages/ui-new/src/components/AppContent/index.module.scss
Normal file
71
packages/ui-new/src/components/AppContent/index.module.scss
Normal file
|
@ -0,0 +1,71 @@
|
|||
.content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--color-background);
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.universal {
|
||||
/* Color */
|
||||
--color-gradient: linear-gradient(12.07deg, #3c4ce3 8.81%, #717ce0 93.49%);
|
||||
--color-button-background: #3c4ce3;
|
||||
--color-button-background-disabled: #626fe8;
|
||||
--color-button-background-hover: #2234df;
|
||||
--color-button-text: #f5f5f5;
|
||||
--color-button-text-disabled: #eee;
|
||||
--color-error: #ff6b66;
|
||||
--color-error-background: #{rgba(#ff6b66, 0.15)};
|
||||
--color-error-border: #{rgba(#ff6b66, 0.35)};
|
||||
|
||||
/* Shadow */
|
||||
--shadow-button: 2px 2px 8px rgb(60 76 227 / 25%);
|
||||
|
||||
/* Transition */
|
||||
--transition-default-function: 0.2s ease-in-out;
|
||||
--transition-default-control: background var(--transition-default-function), color var(--transition-default-function);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Color */
|
||||
--color-heading: #f3f6f9;
|
||||
--color-body: #dadae0;
|
||||
--color-secondary: #b3b7ba;
|
||||
--color-placeholder: #888;
|
||||
--color-control-background: #45484a;
|
||||
--color-control-background-disabled: #65686a;
|
||||
--color-background: #101419;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-card: 2px 2px 24px rgb(59 61 63 / 25%);
|
||||
--shadow-control: 1px 1px 2px rgb(221 221 221 / 25%);
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Color */
|
||||
--color-heading: #333;
|
||||
--color-body: #555;
|
||||
--color-secondary: #888;
|
||||
--color-placeholder: #aaa;
|
||||
--color-control-background: #f5f8fa;
|
||||
--color-control-background-disabled: #eaeaea;
|
||||
--color-background: #fdfdff;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-card: 2px 2px 24px rgb(187 189 191 / 20%);
|
||||
--shadow-control: 1px 1px 2px rgb(221 221 221 / 25%);
|
||||
}
|
||||
|
||||
$font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
||||
|
||||
.mobile {
|
||||
--font-headline: 600 40px/56px #{$font-family};
|
||||
--font-heading-1: 600 28px/39px #{$font-family};
|
||||
--font-heading-2: 600 20px/28px #{$font-family};
|
||||
--font-heading-3: 600 16px/22.4px #{$font-family};
|
||||
--font-control: 500 16px/22.4px #{$font-family};
|
||||
--font-body: 400 12px/16px #{$font-family};
|
||||
--font-body-bold: 500 12px/16px #{$font-family};
|
||||
}
|
21
packages/ui-new/src/components/AppContent/index.tsx
Normal file
21
packages/ui-new/src/components/AppContent/index.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export type Props = {
|
||||
theme: Theme;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AppContent = ({ children, theme }: Props) => {
|
||||
return (
|
||||
<div className={classNames(styles.content, styles.universal, styles.mobile, styles[theme])}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppContent;
|
26
packages/ui-new/src/components/Button/index.module.scss
Normal file
26
packages/ui-new/src/components/Button/index.module.scss
Normal file
|
@ -0,0 +1,26 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: _.unit(3) _.unit(8);
|
||||
border-radius: 12px;
|
||||
background: var(--color-button-background);
|
||||
color: var(--color-button-text);
|
||||
font: var(--font-control);
|
||||
box-shadow: var(--shadow-button);
|
||||
transition: var(--transition-default-control);
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&:disabled {
|
||||
background: var(--color-button-background-disabled);
|
||||
color: var(--color-button-text-disabled);
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--color-button-background-hover);
|
||||
}
|
||||
}
|
25
packages/ui-new/src/components/Button/index.tsx
Normal file
25
packages/ui-new/src/components/Button/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
value?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
};
|
||||
|
||||
const Button = ({ isDisabled, className, value, onClick }: Props) => {
|
||||
return (
|
||||
<input
|
||||
disabled={isDisabled}
|
||||
className={classNames(styles.button, className)}
|
||||
type="button"
|
||||
value={value}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
23
packages/ui-new/src/components/Input/index.module.scss
Normal file
23
packages/ui-new/src/components/Input/index.module.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: _.unit(3) _.unit(5);
|
||||
border-radius: _.unit(2);
|
||||
background: var(--color-control-background);
|
||||
color: var(--color-heading);
|
||||
font: var(--font-control);
|
||||
transition: var(--transition-default-control);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-placeholder);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--color-control-background-disabled);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
43
packages/ui-new/src/components/Input/index.tsx
Normal file
43
packages/ui-new/src/components/Input/index.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
autoComplete?: AutoCompleteType;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
type?: InputType;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
name,
|
||||
autoComplete,
|
||||
isDisabled,
|
||||
className,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
return (
|
||||
<input
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
className={classNames(styles.input, className)}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value}
|
||||
autoComplete={autoComplete}
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
13
packages/ui-new/src/components/MessageBox/index.module.scss
Normal file
13
packages/ui-new/src/components/MessageBox/index.module.scss
Normal file
|
@ -0,0 +1,13 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.messageBox {
|
||||
font: var(--font-body-bold);
|
||||
padding: _.unit(2) _.unit(5);
|
||||
border-radius: _.unit();
|
||||
|
||||
&.error {
|
||||
color: var(--color-error);
|
||||
background: var(--color-error-background);
|
||||
border: 1px solid var(--color-error-border);
|
||||
}
|
||||
}
|
15
packages/ui-new/src/components/MessageBox/index.tsx
Normal file
15
packages/ui-new/src/components/MessageBox/index.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const MessageBox = ({ className, children }: Props) => {
|
||||
return <div className={classNames(styles.messageBox, styles.error, className)}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MessageBox;
|
12
packages/ui-new/src/components/TextLink/index.module.scss
Normal file
12
packages/ui-new/src/components/TextLink/index.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.link {
|
||||
color: var(--color-button-background);
|
||||
font: var(--font-body-bold);
|
||||
transition: var(--transition-default-control);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-button-background-hover);
|
||||
}
|
||||
}
|
20
packages/ui-new/src/components/TextLink/index.tsx
Normal file
20
packages/ui-new/src/components/TextLink/index.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactChild } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
className?: string;
|
||||
children: ReactChild;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const TextLink = ({ className, children, href }: Props) => {
|
||||
return (
|
||||
<a className={classNames(styles.link, className)} href={href}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextLink;
|
52
packages/ui-new/src/hooks/use-api.ts
Normal file
52
packages/ui-new/src/hooks/use-api.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { RequestErrorBody } from '@logto/schemas';
|
||||
import { HTTPError } from 'ky';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
type UseApi<T extends any[], U> = {
|
||||
result?: U;
|
||||
loading: boolean;
|
||||
error: RequestErrorBody | null;
|
||||
run: (...args: T) => Promise<void>;
|
||||
};
|
||||
|
||||
function useApi<Args extends any[], Response>(
|
||||
api: (...args: Args) => Promise<Response>
|
||||
): UseApi<Args, Response> {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<RequestErrorBody | null>(null);
|
||||
const [result, setResult] = useState<Response>();
|
||||
|
||||
const run = useCallback(
|
||||
async (...args: Args) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await api(...args);
|
||||
setResult(result);
|
||||
setLoading(false);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
const kyError = await error.response.json<RequestErrorBody>();
|
||||
setError(kyError);
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
result,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
export default useApi;
|
24
packages/ui-new/src/hooks/use-theme.ts
Normal file
24
packages/ui-new/src/hooks/use-theme.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { Theme } from '@/components/AppContent';
|
||||
|
||||
const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getThemeBySystemConfiguration = (): Theme => (darkThemeWatchMedia.matches ? 'dark' : 'light');
|
||||
|
||||
export default function useTheme() {
|
||||
const [theme, setTheme] = useState(getThemeBySystemConfiguration());
|
||||
|
||||
useEffect(() => {
|
||||
const changeTheme = () => {
|
||||
setTheme(getThemeBySystemConfiguration());
|
||||
};
|
||||
|
||||
darkThemeWatchMedia.addEventListener('change', changeTheme);
|
||||
|
||||
return () => {
|
||||
darkThemeWatchMedia.removeEventListener('change', changeTheme);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
18
packages/ui-new/src/i18n/init.ts
Normal file
18
packages/ui-new/src/i18n/init.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import resources from '@logto/phrases';
|
||||
import i18next from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
const initI18n = async () =>
|
||||
i18next
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default initI18n;
|
76
packages/ui-new/src/include.d/dom.d.ts
vendored
Normal file
76
packages/ui-new/src/include.d/dom.d.ts
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types
|
||||
type InputType =
|
||||
| 'button'
|
||||
| 'checkbox'
|
||||
| 'color'
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'email'
|
||||
| 'file'
|
||||
| 'hidden'
|
||||
| 'image'
|
||||
| 'month'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'radio'
|
||||
| 'range'
|
||||
| 'reset'
|
||||
| 'search'
|
||||
| 'submit'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'url'
|
||||
| 'week';
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
|
||||
type AutoCompleteType =
|
||||
| 'name'
|
||||
| 'honorific-prefix'
|
||||
| 'given-name'
|
||||
| 'additional-name'
|
||||
| 'family-name'
|
||||
| 'honorific-suffix'
|
||||
| 'nickname'
|
||||
| 'username'
|
||||
| 'new-password'
|
||||
| 'current-password'
|
||||
| 'one-time-code'
|
||||
| 'organization-title'
|
||||
| 'organization'
|
||||
| 'street-address'
|
||||
| 'address-line1'
|
||||
| 'address-line2'
|
||||
| 'address-line3'
|
||||
| 'address-level4'
|
||||
| 'address-level3'
|
||||
| 'address-level2'
|
||||
| 'address-level1'
|
||||
| 'country'
|
||||
| 'country-name'
|
||||
| 'postal-code'
|
||||
| 'cc-name'
|
||||
| 'cc-given-name'
|
||||
| 'cc-additional-name'
|
||||
| 'cc-family-name'
|
||||
| 'cc-number'
|
||||
| 'cc-exp'
|
||||
| 'cc-exp-month'
|
||||
| 'cc-exp-year'
|
||||
| 'cc-csc'
|
||||
| 'cc-type'
|
||||
| 'transaction-currency'
|
||||
| 'transaction-amount'
|
||||
| 'language'
|
||||
| 'bday'
|
||||
| 'bday-day'
|
||||
| 'bday-month'
|
||||
| 'bday-year'
|
||||
| 'sex'
|
||||
| 'url'
|
||||
| 'photo';
|
||||
|
||||
// TO-DO: remove me
|
||||
interface Body {
|
||||
json<T>(): Promise<T>;
|
||||
}
|
11
packages/ui-new/src/include.d/react-i18next.d.ts
vendored
Normal file
11
packages/ui-new/src/include.d/react-i18next.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
// https://react.i18next.com/latest/typescript#create-a-declaration-file
|
||||
|
||||
// eslint-disable-next-line import/no-unassigned-import
|
||||
import 'react-i18next';
|
||||
import en from '@logto/phrases/lib/locales/en.js';
|
||||
|
||||
declare module 'react-i18next' {
|
||||
interface CustomTypeOptions {
|
||||
resources: typeof en;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { App } from './App';
|
||||
import App from './App';
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
ReactDOM.render(<App />, app);
|
||||
|
|
24
packages/ui-new/src/pages/Consent/index.tsx
Normal file
24
packages/ui-new/src/pages/Consent/index.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { consent } from '@/apis/consent';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
const Consent = () => {
|
||||
const { t } = useTranslation();
|
||||
const { result, run: asyncConsent } = useApi(consent);
|
||||
|
||||
useEffect(() => {
|
||||
void asyncConsent();
|
||||
}, [asyncConsent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return <div>{t('sign_in.loading')}</div>;
|
||||
};
|
||||
|
||||
export default Consent;
|
50
packages/ui-new/src/pages/Register/index.module.scss
Normal file
50
packages/ui-new/src/pages/Register/index.module.scss
Normal file
|
@ -0,0 +1,50 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
height: 100%;
|
||||
@include _.flex-colomn;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
@include _.flex-colomn;
|
||||
|
||||
> * {
|
||||
margin-bottom: _.unit(1.5);
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-heading-1);
|
||||
color: var(--color-heading);
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: _.unit(-6);
|
||||
}
|
||||
|
||||
.box,
|
||||
> input:not([type='button']) {
|
||||
margin-top: _.unit(3);
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
> input[type='button'] {
|
||||
margin-top: _.unit(12);
|
||||
}
|
||||
|
||||
.haveAccount {
|
||||
position: absolute;
|
||||
bottom: _.unit(10);
|
||||
}
|
||||
|
||||
.prefix {
|
||||
font: var(--font-body-bold);
|
||||
color: var(--color-placeholder);
|
||||
margin-right: _.unit(0.5);
|
||||
}
|
||||
}
|
23
packages/ui-new/src/pages/Register/index.test.tsx
Normal file
23
packages/ui-new/src/pages/Register/index.test.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
import Register from '@/pages/Register';
|
||||
|
||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<Register />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
const { queryByText, getByText } = render(<Register />);
|
||||
expect(queryByText('register.create_account')).not.toBeNull();
|
||||
expect(queryByText('register.have_account')).not.toBeNull();
|
||||
|
||||
const submit = getByText('register.action');
|
||||
fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(register).toBeCalled();
|
||||
expect(queryByText('register.loading')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
75
packages/ui-new/src/pages/Register/index.tsx
Normal file
75
packages/ui-new/src/pages/Register/index.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
import Button from '@/components/Button';
|
||||
import Input from '@/components/Input';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const Register: FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const { loading, error, result, run: asyncRegister } = useApi(register);
|
||||
|
||||
const signUp: FormEventHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
await asyncRegister(username, password);
|
||||
},
|
||||
[username, password, asyncRegister]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.wrapper)}>
|
||||
<form className={classNames(styles.form)}>
|
||||
<div className={styles.title}>{t('register.create_account')}</div>
|
||||
<Input
|
||||
name="username"
|
||||
isDisabled={loading}
|
||||
placeholder={t('sign_in.username')}
|
||||
value={username}
|
||||
onChange={setUsername} // TODO: account validation
|
||||
/>
|
||||
<Input
|
||||
name="password"
|
||||
isDisabled={loading}
|
||||
placeholder={t('sign_in.password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword} // TODO: password validation
|
||||
/>
|
||||
{error && (
|
||||
<MessageBox className={styles.box}>
|
||||
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
|
||||
</MessageBox>
|
||||
)}
|
||||
<Button
|
||||
isDisabled={loading}
|
||||
value={loading ? t('register.loading') : t('register.action')}
|
||||
onClick={signUp}
|
||||
/>
|
||||
|
||||
<div className={styles.haveAccount}>
|
||||
<span className={styles.prefix}>{t('register.have_account')}</span>
|
||||
<TextLink href="/sign-in">{t('sign_in.action')}</TextLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
51
packages/ui-new/src/pages/SignIn/index.module.scss
Normal file
51
packages/ui-new/src/pages/SignIn/index.module.scss
Normal file
|
@ -0,0 +1,51 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
@mixin flex-colomn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
height: 100%;
|
||||
@include flex-colomn;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
@include flex-colomn;
|
||||
|
||||
> * {
|
||||
margin-bottom: _.unit(1.5);
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-heading-1);
|
||||
color: var(--color-heading);
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: _.unit(-6);
|
||||
}
|
||||
|
||||
.box,
|
||||
> input:not([type='button']) {
|
||||
margin-top: _.unit(3);
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
> input[type='button'] {
|
||||
margin-top: _.unit(12);
|
||||
}
|
||||
|
||||
.createAccount {
|
||||
position: absolute;
|
||||
bottom: _.unit(10);
|
||||
}
|
||||
}
|
22
packages/ui-new/src/pages/SignIn/index.test.tsx
Normal file
22
packages/ui-new/src/pages/SignIn/index.test.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
import SignIn from '@/pages/SignIn';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<SignIn />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
const { queryByText, getByText } = render(<SignIn />);
|
||||
expect(queryByText('Sign in to Logto')).not.toBeNull();
|
||||
|
||||
const submit = getByText('sign_in.action');
|
||||
fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInBasic).toBeCalled();
|
||||
expect(queryByText('sign_in.loading')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
76
packages/ui-new/src/pages/SignIn/index.tsx
Normal file
76
packages/ui-new/src/pages/SignIn/index.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import Input from '@/components/Input';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const SignIn: FC = () => {
|
||||
// TODO: Consider creating cross page data modal
|
||||
const { t, i18n } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const { loading, error, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||
|
||||
const signInHandler: FormEventHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
await asyncSignInBasic(username, password);
|
||||
},
|
||||
[username, password, asyncSignInBasic]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.wrapper)}>
|
||||
<form className={classNames(styles.form)}>
|
||||
<div className={styles.title}>Sign in to Logto</div>
|
||||
<Input
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
isDisabled={loading}
|
||||
placeholder={t('sign_in.username')}
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
/>
|
||||
<Input
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
isDisabled={loading}
|
||||
placeholder={t('sign_in.password')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
/>
|
||||
{error && (
|
||||
<MessageBox className={styles.box}>
|
||||
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
|
||||
</MessageBox>
|
||||
)}
|
||||
<Button
|
||||
isDisabled={loading}
|
||||
value={loading ? t('sign_in.loading') : t('sign_in.action')}
|
||||
onClick={signInHandler}
|
||||
/>
|
||||
<TextLink className={styles.createAccount} href="/register">
|
||||
{t('register.create_account')}
|
||||
</TextLink>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
10
packages/ui-new/src/scss/_underscore.scss
Normal file
10
packages/ui-new/src/scss/_underscore.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
@function unit($factor: 1, $unit: 'px') {
|
||||
@return #{$factor * 4}#{$unit};
|
||||
}
|
||||
|
||||
@mixin flex-colomn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
14
packages/ui-new/src/scss/normalized.scss
Normal file
14
packages/ui-new/src/scss/normalized.scss
Normal file
|
@ -0,0 +1,14 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config-react/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
|
|
|
@ -20,6 +20,8 @@ importers:
|
|||
|
||||
packages/console:
|
||||
specifiers:
|
||||
'@logto/phrases': ^0.1.0
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@parcel/core': ^2.3.1
|
||||
'@parcel/transformer-sass': ^2.3.1
|
||||
'@silverhand/eslint-config': ^0.8.1
|
||||
|
@ -39,6 +41,8 @@ importers:
|
|||
stylelint: ^13.13.1
|
||||
typescript: ^4.5.5
|
||||
dependencies:
|
||||
'@logto/phrases': link:../phrases
|
||||
'@logto/schemas': link:../schemas
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2_react@17.0.2
|
||||
devDependencies:
|
||||
|
@ -166,6 +170,7 @@ importers:
|
|||
|
||||
packages/phrases:
|
||||
specifiers:
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@silverhand/eslint-config': ^0.8.1
|
||||
'@silverhand/essentials': ^1.1.4
|
||||
'@silverhand/ts-config': ^0.8.1
|
||||
|
@ -174,6 +179,7 @@ importers:
|
|||
prettier: ^2.3.2
|
||||
typescript: ^4.5.5
|
||||
dependencies:
|
||||
'@logto/schemas': link:../schemas
|
||||
'@silverhand/essentials': 1.1.4
|
||||
devDependencies:
|
||||
'@silverhand/eslint-config': 0.8.1_b07be603d0ceb19daeedad1772e0f2c4
|
||||
|
@ -309,6 +315,8 @@ importers:
|
|||
|
||||
packages/ui-new:
|
||||
specifiers:
|
||||
'@logto/phrases': ^0.1.0
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@parcel/core': ^2.3.1
|
||||
'@parcel/transformer-sass': ^2.3.1
|
||||
'@silverhand/eslint-config': ^0.8.1
|
||||
|
@ -317,7 +325,12 @@ importers:
|
|||
'@silverhand/ts-config-react': ^0.8.1
|
||||
'@types/react': ^17.0.14
|
||||
'@types/react-dom': ^17.0.9
|
||||
'@types/react-router-dom': ^5.3.2
|
||||
classnames: ^2.3.1
|
||||
eslint: ^8.1.0
|
||||
i18next: ^21.6.11
|
||||
i18next-browser-languagedetector: ^6.1.3
|
||||
ky: ^0.29.0
|
||||
lint-staged: ^11.1.1
|
||||
parcel: ^2.3.1
|
||||
postcss: ^8.4.6
|
||||
|
@ -325,10 +338,20 @@ importers:
|
|||
prettier: ^2.3.2
|
||||
react: ^17.0.2
|
||||
react-dom: ^17.0.2
|
||||
react-i18next: ^11.15.4
|
||||
react-router-dom: ^5.2.0
|
||||
stylelint: ^13.13.1
|
||||
dependencies:
|
||||
'@logto/phrases': link:../phrases
|
||||
'@logto/schemas': link:../schemas
|
||||
classnames: 2.3.1
|
||||
i18next: 21.6.11
|
||||
i18next-browser-languagedetector: 6.1.3
|
||||
ky: 0.29.0
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2_react@17.0.2
|
||||
react-i18next: 11.15.4_3fb644aa30122a07f960d67fa51d6dc1
|
||||
react-router-dom: 5.3.0_react@17.0.2
|
||||
devDependencies:
|
||||
'@parcel/core': 2.3.1
|
||||
'@parcel/transformer-sass': 2.3.1_@parcel+core@2.3.1
|
||||
|
@ -338,6 +361,7 @@ importers:
|
|||
'@silverhand/ts-config-react': 0.8.1_typescript@4.5.5
|
||||
'@types/react': 17.0.37
|
||||
'@types/react-dom': 17.0.11
|
||||
'@types/react-router-dom': 5.3.2
|
||||
eslint: 8.4.1
|
||||
lint-staged: 11.2.6
|
||||
parcel: 2.3.1_postcss@8.4.6
|
||||
|
@ -9248,12 +9272,24 @@ packages:
|
|||
'@babel/runtime': 7.16.3
|
||||
dev: false
|
||||
|
||||
/i18next-browser-languagedetector/6.1.3:
|
||||
resolution: {integrity: sha512-T+oGXHXtrur14CGnZZ7qQ07X38XJQEI00b/4ILrtO6xPbwTlQ1wtMZC2H+tBULixHuVUXv8LKbxfjyITJkezUg==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.16.3
|
||||
dev: false
|
||||
|
||||
/i18next/20.6.1:
|
||||
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.16.3
|
||||
dev: false
|
||||
|
||||
/i18next/21.6.11:
|
||||
resolution: {integrity: sha512-tJ2+o0lVO+fhi8bPkCpBAeY1SgkqmQm5NzgPWCQssBrywJw98/o+Kombhty5nxQOpHtvMmsxcOopczUiH6bJxQ==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.16.3
|
||||
dev: false
|
||||
|
||||
/iconv-lite/0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
@ -11324,6 +11360,11 @@ packages:
|
|||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/ky/0.29.0:
|
||||
resolution: {integrity: sha512-01TBSOqlHmLfcQhHseugGHLxPtU03OyZWaLDWt5MfzCkijG6xWFvAQPhKVn0cR2MMjYvBP9keQ8A3+rQEhLO5g==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/lerna/4.0.0:
|
||||
resolution: {integrity: sha512-DD/i1znurfOmNJb0OBw66NmNqiM8kF6uIrzrJ0wGE3VNdzeOhz9ziWLYiRaZDGGwgbcjOo6eIfcx9O5Qynz+kg==}
|
||||
engines: {node: '>= 10.18.0'}
|
||||
|
@ -14817,6 +14858,27 @@ packages:
|
|||
react: 17.0.2
|
||||
dev: false
|
||||
|
||||
/react-i18next/11.15.4_3fb644aa30122a07f960d67fa51d6dc1:
|
||||
resolution: {integrity: sha512-jKJNAcVcbPGK+yrTcXhLblgPY16n6NbpZZL3Mk8nswj1v3ayIiUBVDU09SgqnT+DluyQBS97hwSvPU5yVFG0yg==}
|
||||
peerDependencies:
|
||||
i18next: '>= 19.0.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.16.3
|
||||
html-escaper: 2.0.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 21.6.11
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2_react@17.0.2
|
||||
dev: false
|
||||
|
||||
/react-is/16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
|
|
Loading…
Reference in a new issue