diff --git a/package.json b/package.json index 4474cdbfd..c1a6482fa 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "pnpm": ">=6" }, "alias": { - "html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js" + "html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js", + "react-hook-form": "react-hook-form/dist/index.esm.mjs" } } diff --git a/packages/console/package.json b/packages/console/package.json index df99c94b7..b1d050b85 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -25,7 +25,9 @@ "lodash.kebabcase": "^4.1.1", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-hook-form": "^7.27.1", "react-i18next": "^11.15.4", + "react-modal": "^3.14.4", "react-router-dom": "^6.2.2", "swr": "^1.2.2" }, @@ -39,6 +41,7 @@ "@types/lodash.kebabcase": "^4.1.6", "@types/react": "^17.0.14", "@types/react-dom": "^17.0.9", + "@types/react-modal": "^3.13.1", "eslint": "^8.10.0", "lint-staged": "^11.1.1", "parcel": "^2.3.1", diff --git a/packages/console/src/components/AppContent/index.module.scss b/packages/console/src/components/AppContent/index.module.scss index 116f89113..8e03131ff 100644 --- a/packages/console/src/components/AppContent/index.module.scss +++ b/packages/console/src/components/AppContent/index.module.scss @@ -1,9 +1,6 @@ .app { position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; + inset: 0; display: flex; flex-direction: column; } @@ -34,8 +31,11 @@ --color-neutral-90: #e0e3e3; --color-on-secondary-container: #201c00; --color-component-caption: #747778; + --color-component-text: #191c1d; --color-outline: #78767f; --color-table-row-selected: rgba(0, 0, 0, 2%); + --color-text-default: #202223; + --color-foggy: #888; } $font-family: 'SF UI Text', 'SF Pro Display', sans-serif; @@ -43,9 +43,11 @@ $font-family: 'SF UI Text', 'SF Pro Display', sans-serif; .web { --font-title-medium: 500 16px/24px #{$font-family}; --font-heading-small: 600 24px/32px #{$font-family}; + --font-heading: 600 16px/24px #{$font-family}; --font-body: normal 16px/22px #{$font-family}; --font-small-text: normal 12px/16px #{$font-family}; --font-caption: normal 14px/20px #{$font-family}; + --font-caption-bold: 600 14px/20px #{$font-family}; --font-button: 500 14px/20px #{$font-family}; --font-subhead-2: 500 14px/20px #{$font-family}; } diff --git a/packages/console/src/components/AppContent/index.tsx b/packages/console/src/components/AppContent/index.tsx index ba24055e8..5fe800617 100644 --- a/packages/console/src/components/AppContent/index.tsx +++ b/packages/console/src/components/AppContent/index.tsx @@ -1,5 +1,4 @@ -import classNames from 'classnames'; -import React, { ReactNode } from 'react'; +import React, { ReactNode, useEffect } from 'react'; import * as styles from './index.module.scss'; @@ -11,7 +10,16 @@ type Props = { }; const AppContent = ({ children, theme }: Props) => { - return
{children}
; + useEffect(() => { + const classes = [styles.web, styles[theme]].filter((value): value is string => Boolean(value)); + document.body.classList.add(...classes); + + return () => { + document.body.classList.remove(...classes); + }; + }, [theme]); + + return
{children}
; }; export default AppContent; diff --git a/packages/console/src/components/Card/index.tsx b/packages/console/src/components/Card/index.tsx index 58594e067..88c103d23 100644 --- a/packages/console/src/components/Card/index.tsx +++ b/packages/console/src/components/Card/index.tsx @@ -1,13 +1,15 @@ +import classNames from 'classnames'; import React, { ReactNode } from 'react'; import * as styles from './index.module.scss'; type Props = { children: ReactNode; + className?: string; }; -const Card = ({ children }: Props) => { - return
{children}
; +const Card = ({ children, className }: Props) => { + return
{children}
; }; export default Card; diff --git a/packages/console/src/components/FormField/index.module.scss b/packages/console/src/components/FormField/index.module.scss new file mode 100644 index 000000000..0bcb5a0e9 --- /dev/null +++ b/packages/console/src/components/FormField/index.module.scss @@ -0,0 +1,21 @@ +@use '@/scss/underscore' as _; + +.headline { + display: flex; + justify-content: space-between; + margin-bottom: _.unit(1); + + &:not(:first-child) { + margin-top: _.unit(6); + } + + .title { + font: var(--font-caption-bold); + color: var(--color-component-text); + } + + .required { + font: var(--font-body); + color: var(--color-component-caption); + } +} diff --git a/packages/console/src/components/FormField/index.tsx b/packages/console/src/components/FormField/index.tsx new file mode 100644 index 000000000..f3a7ff148 --- /dev/null +++ b/packages/console/src/components/FormField/index.tsx @@ -0,0 +1,27 @@ +import { I18nKey } from '@logto/phrases'; +import React, { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './index.module.scss'; + +type Props = { + title: I18nKey; + children: ReactNode; + isRequired?: boolean; +}; + +const FormField = ({ title, children, isRequired }: Props) => { + const { t } = useTranslation(); + + return ( + <> +
+
{t(title)}
+ {isRequired &&
{t('admin_console.form.required')}
} +
+ {children} + + ); +}; + +export default FormField; diff --git a/packages/console/src/components/RadioGroup/Radio.tsx b/packages/console/src/components/RadioGroup/Radio.tsx new file mode 100644 index 000000000..56aade654 --- /dev/null +++ b/packages/console/src/components/RadioGroup/Radio.tsx @@ -0,0 +1,40 @@ +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; + +import * as styles from './index.module.scss'; + +const Check = () => ( + + + + +); + +export type Props = { + value: string; + title: string; + name?: string; + children?: ReactNode; + isChecked?: boolean; + onClick?: () => void; +}; + +const Radio = ({ value, title, name, children, isChecked, onClick }: Props) => { + return ( +
+ +
+
{title}
+ +
+ {children} +
+ ); +}; + +export default Radio; diff --git a/packages/console/src/components/RadioGroup/index.module.scss b/packages/console/src/components/RadioGroup/index.module.scss new file mode 100644 index 000000000..8ecc4485e --- /dev/null +++ b/packages/console/src/components/RadioGroup/index.module.scss @@ -0,0 +1,68 @@ +@use '@/scss/underscore' as _; + +.radioGroup { + display: flex; + + &:not(:first-child) { + margin-top: _.unit(3); + } + + svg { + opacity: 0%; + } + + + > .radio { + position: relative; + flex: 1; + max-width: 220px; + padding: _.unit(5); + display: flex; + flex-direction: column; + border-radius: _.unit(4); + border: 1px solid var(--color-neutral-90); + outline: none; + user-select: none; + cursor: pointer; + + &:not(:first-child) { + margin-left: _.unit(6); + } + + &.checked { + border-color: var(--color-primary); + outline: 1px solid var(--color-primary); + + svg { + opacity: 100%; + } + } + + .headline { + flex: 1; + display: flex; + justify-content: space-between; + + &.center { + align-items: center; + } + + > *:not(:first-child) { + margin-left: _.unit(3); + } + } + + .title { + font: var(--font-heading); + color: var(--color-component-text); + } + + input[type='radio'] { + appearance: none; + position: absolute; + margin: 0; + width: 0; + height: 0; + } + } +} diff --git a/packages/console/src/components/RadioGroup/index.tsx b/packages/console/src/components/RadioGroup/index.tsx new file mode 100644 index 000000000..98ee75f2f --- /dev/null +++ b/packages/console/src/components/RadioGroup/index.tsx @@ -0,0 +1,34 @@ +import React, { Children, cloneElement, isValidElement, ReactNode } from 'react'; + +import Radio, { Props as RadioProps } from './Radio'; +import * as styles from './index.module.scss'; + +type Props = { + name: string; + children: ReactNode; + value: string; + onChange?: (value: string) => void; +}; + +const RadioGroup = ({ name, children, value, onChange }: Props) => { + return ( +
+ {Children.map(children, (child) => { + if (!isValidElement(child) || child.type !== Radio) { + return child; + } + + return cloneElement(child, { + name, + isChecked: value === child.props.value, + onClick: () => { + onChange?.(child.props.value); + }, + }); + })} +
+ ); +}; + +export default RadioGroup; +export { default as Radio } from './Radio'; diff --git a/packages/console/src/icons/Close.tsx b/packages/console/src/icons/Close.tsx new file mode 100644 index 000000000..41a5d67ee --- /dev/null +++ b/packages/console/src/icons/Close.tsx @@ -0,0 +1,12 @@ +import React, { SVGProps } from 'react'; + +const Close = (props: SVGProps) => ( + + + +); + +export default Close; diff --git a/packages/console/src/index.tsx b/packages/console/src/index.tsx index 09a8551e3..1ccb3cb4a 100644 --- a/packages/console/src/index.tsx +++ b/packages/console/src/index.tsx @@ -1,7 +1,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import ReactModal from 'react-modal'; import App from './App'; const app = document.querySelector('#app'); +ReactModal.setAppElement('#app'); ReactDOM.render(, app); diff --git a/packages/console/src/pages/Applications/components/CreateForm/index.module.scss b/packages/console/src/pages/Applications/components/CreateForm/index.module.scss new file mode 100644 index 000000000..171303508 --- /dev/null +++ b/packages/console/src/pages/Applications/components/CreateForm/index.module.scss @@ -0,0 +1,22 @@ +@use '@/scss/underscore' as _; + +.card { + padding: _.unit(8); +} + +.headline { + display: flex; + justify-content: space-between; + + > *:not(:first-child) { + margin-left: _.unit(3); + } + + > svg { + cursor: pointer; + } +} + +.form { + margin-top: _.unit(8); +} diff --git a/packages/console/src/pages/Applications/components/CreateForm/index.tsx b/packages/console/src/pages/Applications/components/CreateForm/index.tsx new file mode 100644 index 000000000..0829cde10 --- /dev/null +++ b/packages/console/src/pages/Applications/components/CreateForm/index.tsx @@ -0,0 +1,62 @@ +import { ApplicationType } from '@logto/schemas'; +import React from 'react'; +import { useController, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import Card from '@/components/Card'; +import CardTitle from '@/components/CardTitle'; +import FormField from '@/components/FormField'; +import RadioGroup, { Radio } from '@/components/RadioGroup'; +import Close from '@/icons/Close'; +import { applicationTypeI18nKey } from '@/types/applications'; + +import TypeDescription from '../TypeDescription'; +import * as styles from './index.module.scss'; + +type FormData = { + type: ApplicationType; + name: string; + description?: string; +}; + +type Props = { + onClose?: () => void; +}; + +const CreateForm = ({ onClose }: Props) => { + const { handleSubmit, control } = useForm(); + const { + field: { onChange, value }, + } = useController({ name: 'type', control }); + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const onSubmit = handleSubmit((data) => { + console.log(data); + }); + + return ( + +
+ + +
+
+ + + {Object.values(ApplicationType).map((value) => ( + + + + ))} + + + +
+
+ ); +}; + +export default CreateForm; diff --git a/packages/console/src/pages/Applications/components/TypeDescription/index.module.scss b/packages/console/src/pages/Applications/components/TypeDescription/index.module.scss new file mode 100644 index 000000000..bc9495a78 --- /dev/null +++ b/packages/console/src/pages/Applications/components/TypeDescription/index.module.scss @@ -0,0 +1,17 @@ +@use '@/scss/underscore' as _; + +.subtitle, +.description { + margin-top: _.unit(3); + flex: 2; +} + +.subtitle { + font: var(--font-body); + color: var(--color-text-default); +} + +.description { + font: var(--font-caption); + color: var(--color-foggy); +} diff --git a/packages/console/src/pages/Applications/components/TypeDescription/index.tsx b/packages/console/src/pages/Applications/components/TypeDescription/index.tsx new file mode 100644 index 000000000..61b6b39d7 --- /dev/null +++ b/packages/console/src/pages/Applications/components/TypeDescription/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import * as styles from './index.module.scss'; + +type Props = { + subtitle: string; + description: string; +}; + +const TypeDescription = ({ subtitle, description }: Props) => { + return ( + <> +
{subtitle}
+
{description}
+ + ); +}; + +export default TypeDescription; diff --git a/packages/console/src/pages/Applications/index.tsx b/packages/console/src/pages/Applications/index.tsx index 671305431..e29f75c41 100644 --- a/packages/console/src/pages/Applications/index.tsx +++ b/packages/console/src/pages/Applications/index.tsx @@ -1,6 +1,7 @@ import { Application } from '@logto/schemas'; -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import Modal from 'react-modal'; import useSWR from 'swr'; import Button from '@/components/Button'; @@ -9,12 +10,15 @@ import CardTitle from '@/components/CardTitle'; import CopyToClipboard from '@/components/CopyToClipboard'; import ImagePlaceholder from '@/components/ImagePlaceholder'; import ItemPreview from '@/components/ItemPreview'; +import * as modalStyles from '@/scss/modal.module.scss'; import { RequestError } from '@/swr'; import { applicationTypeI18nKey } from '@/types/applications'; +import CreateForm from './components/CreateForm'; import * as styles from './index.module.scss'; const Applications = () => { + const [isCreateFormOpen, setIsCreateFormOpen] = useState(false); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { data, error } = useSWR('/api/applications'); const isLoading = !data && !error; @@ -23,7 +27,23 @@ const Applications = () => {
-
@@ -48,7 +68,7 @@ const Applications = () => { diff --git a/packages/console/src/scss/modal.module.scss b/packages/console/src/scss/modal.module.scss new file mode 100644 index 000000000..c32410973 --- /dev/null +++ b/packages/console/src/scss/modal.module.scss @@ -0,0 +1,13 @@ +.content { + position: absolute; + left: 50%; + top: 50%; + transform: translateX(-50%, -50%); + outline: none; +} + +.overlay { + position: fixed; + background: rgba(0, 0, 0, 40%); + inset: 0; +} diff --git a/packages/console/src/types/applications.ts b/packages/console/src/types/applications.ts index 2542799eb..dfa0dcd53 100644 --- a/packages/console/src/types/applications.ts +++ b/packages/console/src/types/applications.ts @@ -1,8 +1,7 @@ -import { AdminConsoleKey } from '@logto/phrases'; import { ApplicationType } from '@logto/schemas'; -export const applicationTypeI18nKey: Record = { +export const applicationTypeI18nKey = Object.freeze({ [ApplicationType.Native]: 'applications.type.native', [ApplicationType.SPA]: 'applications.type.spa', - [ApplicationType.Traditional]: 'applications.type.tranditional', -}; + [ApplicationType.Traditional]: 'applications.type.traditional', +} as const); diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 8cad89c16..8b9e04278 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -19,6 +19,9 @@ const translation = { copying: 'Copying', copied: 'Copied', }, + form: { + required: 'Required', + }, tab_sections: { overview: 'Overview', resource_management: 'Resource Management', @@ -44,11 +47,24 @@ const translation = { 'Setup a mobile, single page or traditional application to use Logto for authentication.', create: 'Create Application', application_name: 'Application Name', + select_application_type: 'Select an application type', client_id: 'Client ID', type: { - native: 'Native App', - spa: 'Single Page App', - tranditional: 'Tranditional Web App', + native: { + title: 'Native', + subtitle: 'Mobile, desktop, CLI and smart device apps running natively.', + description: 'E.g.: iOS, Electron, Apple TV apps', + }, + spa: { + title: 'Single Page App', + subtitle: 'A JavaScript front-end app that uses an API.', + description: 'E.g.: Angular, React, Vue', + }, + traditional: { + title: 'Tranditional Web', + subtitle: 'Traditional web app using redirects.', + description: 'E.g.: Node.js, Express, ASP.NET, Java, PHP', + }, }, }, api_resources: { diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 03e4a2c1f..2a35b6ce9 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -21,6 +21,9 @@ const translation = { copying: '拷贝中', copied: '已拷贝', }, + form: { + required: '必填', + }, tab_sections: { overview: '概览', resource_management: '资源管理', @@ -46,11 +49,24 @@ const translation = { 'Setup a mobile, single page or traditional application to use Logto for authentication.', create: 'Create Application', application_name: 'Application Name', + select_application_type: 'Select an application type', client_id: 'Client ID', type: { - native: 'Native App', - spa: 'Single Page App', - tranditional: 'Tranditional Web App', + native: { + title: 'Native', + subtitle: 'Mobile, desktop, CLI and smart device apps running natively.', + description: 'E.g.: iOS, Electron, Apple TV apps', + }, + spa: { + title: 'Single Page App', + subtitle: 'A JavaScript front-end app that uses an API.', + description: 'E.g.: Angular, React, Vue', + }, + traditional: { + title: 'Tranditional Web', + subtitle: 'Traditional web app using redirects.', + description: 'E.g.: Node.js, Express, ASP.NET, Java, PHP', + }, }, }, api_resources: { diff --git a/packages/ui/src/components/AppContent/index.module.scss b/packages/ui/src/components/AppContent/index.module.scss index 604eb2dae..17bc48658 100644 --- a/packages/ui/src/components/AppContent/index.module.scss +++ b/packages/ui/src/components/AppContent/index.module.scss @@ -1,9 +1,6 @@ .content { position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; + inset: 0; background: var(--color-background); color: var(--color-body); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 413cfd9f7..3224953ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,7 @@ importers: '@types/lodash.kebabcase': ^4.1.6 '@types/react': ^17.0.14 '@types/react-dom': ^17.0.9 + '@types/react-modal': ^3.13.1 classnames: ^2.3.1 eslint: ^8.10.0 i18next: ^21.6.12 @@ -44,7 +45,9 @@ importers: prettier: ^2.3.2 react: ^17.0.2 react-dom: ^17.0.2 + react-hook-form: ^7.27.1 react-i18next: ^11.15.4 + react-modal: ^3.14.4 react-router-dom: ^6.2.2 stylelint: ^13.13.1 swr: ^1.2.2 @@ -59,7 +62,9 @@ importers: lodash.kebabcase: 4.1.1 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 + react-hook-form: 7.27.1_react@17.0.2 react-i18next: 11.15.4_2c37a602a29bb6bd53f3de707a8cfcc5 + react-modal: 3.14.4_react-dom@17.0.2+react@17.0.2 react-router-dom: 6.2.2_react-dom@17.0.2+react@17.0.2 swr: 1.2.2_react@17.0.2 devDependencies: @@ -72,6 +77,7 @@ importers: '@types/lodash.kebabcase': 4.1.6 '@types/react': 17.0.37 '@types/react-dom': 17.0.11 + '@types/react-modal': 3.13.1 eslint: 8.10.0 lint-staged: 11.2.6 parcel: 2.3.1_postcss@8.4.6 @@ -3566,6 +3572,12 @@ packages: '@types/react': 17.0.37 dev: true + /@types/react-modal/3.13.1: + resolution: {integrity: sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==} + dependencies: + '@types/react': 17.0.37 + dev: true + /@types/react-router-dom/5.3.2: resolution: {integrity: sha512-ELEYRUie2czuJzaZ5+ziIp9Hhw+juEw8b7C11YNA4QdLCVbQ3qLi2l4aq8XnlqM7V31LZX8dxUuFUCrzHm6sqQ==} dependencies: @@ -6004,6 +6016,10 @@ packages: clone-regexp: 2.2.0 dev: true + /exenv/1.2.2: + resolution: {integrity: sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=} + dev: false + /exit/0.1.2: resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=} engines: {node: '>= 0.8.0'} @@ -11202,6 +11218,15 @@ packages: scheduler: 0.20.2 dev: false + /react-hook-form/7.27.1_react@17.0.2: + resolution: {integrity: sha512-N3a7A6zIQ8DJeThisVZGtOUabTbJw+7DHJidmB9w8m3chckv2ZWKb5MHps9d2pPJqmCDoWe53Bos56bYmJms5w==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 + dependencies: + react: 17.0.2 + dev: false + /react-i18next/11.15.4_2c37a602a29bb6bd53f3de707a8cfcc5: resolution: {integrity: sha512-jKJNAcVcbPGK+yrTcXhLblgPY16n6NbpZZL3Mk8nswj1v3ayIiUBVDU09SgqnT+DluyQBS97hwSvPU5yVFG0yg==} peerDependencies: @@ -11251,6 +11276,25 @@ packages: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true + /react-lifecycles-compat/3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + + /react-modal/3.14.4_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-8surmulejafYCH9wfUmFyj4UfbSJwjcgbS9gf3oOItu4Hwd6ivJyVBETI0yHRhpJKCLZMUtnhzk76wXTsNL6Qg==} + engines: {node: '>=8'} + peerDependencies: + react: ^0.14.0 || ^15.0.0 || ^16 || ^17 + react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 + dependencies: + exenv: 1.2.2 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-lifecycles-compat: 3.0.4 + warning: 4.0.3 + dev: false + /react-refresh/0.9.0: resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==} engines: {node: '>=0.10.0'} @@ -13245,6 +13289,12 @@ packages: makeerror: 1.0.12 dev: true + /warning/4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + dependencies: + loose-envify: 1.4.0 + dev: false + /wcwidth/1.0.1: resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=} dependencies:
} />