mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): guide modal v2
This commit is contained in:
parent
0fcea5ae5e
commit
466933a490
21 changed files with 835 additions and 2 deletions
|
@ -156,6 +156,8 @@
|
|||
{
|
||||
"files": [
|
||||
"*.d.ts",
|
||||
"**/assets/docs/guides/types.ts",
|
||||
"**/assets/docs/guides/**/index.ts",
|
||||
"**/mdx-components/*/index.tsx"
|
||||
],
|
||||
"rules": {
|
||||
|
|
49
packages/console/src/assets/docs/guides/README.md
Normal file
49
packages/console/src/assets/docs/guides/README.md
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Guides
|
||||
|
||||
> This directory is a part of the guide v2 project. The sibling directory `tutorial` will be removed once the guide v2 project is complete.
|
||||
|
||||
This directory serves as the home for all guides related to the console. Every guide should have a directory with the following structure:
|
||||
|
||||
```
|
||||
[target]-name
|
||||
├── assets
|
||||
│ └── image-name.png
|
||||
├── index.ts
|
||||
├── logo.svg
|
||||
└── README.mdx
|
||||
```
|
||||
|
||||
The `README.mdx` file contains the actual guide content. The `assets` directory contains all images used in the guide. The `index.ts` file exports the guide's metadata, which is used to display, sort and filter the guides in the console.
|
||||
|
||||
## Write a guide
|
||||
|
||||
### Create the guide directory
|
||||
|
||||
The guide directory should be named `[target]-name`, where `[target]` is the target of the guide in kebab-case (see `types.ts` for a list of all targets) and `name` is the name of the guide. The name should be kebab-cased and should not contain any special characters.
|
||||
|
||||
For example, a guide for the `MachineToMachine` target with the name `General` should be placed in the directory `machine-to-machine-general`; a guide for the `SPA` target with the name `React` should be placed in the directory `spa-react`.
|
||||
|
||||
> **Note**
|
||||
> The directory name will be the unique identifier of the guide.
|
||||
|
||||
### Create the guide metadata
|
||||
|
||||
The guide metadata should be the default export of the `index.ts` file. It should be an object with the `GuideMetadata` type in `types.ts` as its type.
|
||||
|
||||
### Write the guide content
|
||||
|
||||
The guide content is written in [MDX](https://mdxjs.com/), which is a combination of Markdown and JSX. This allows us to use React components in the guide content.
|
||||
|
||||
### Add the logo
|
||||
|
||||
The logo should be placed in the guide directory and named `logo.svg`. It will be displayed in the guide list and other places where the guide is referenced.
|
||||
|
||||
### Add images and other assets
|
||||
|
||||
Images and other assets (if any) should be placed in the `assets` directory of the guide. They can then be referenced in the guide content.
|
||||
|
||||
### Update metadata
|
||||
|
||||
Since Parcel doesn't support dynamic import (see [#112](https://github.com/parcel-bundler/parcel/issues/112) [#125](https://github.com/parcel-bundler/parcel/issues/125)), we need to run `node generate-metadata.js` to update the metadata in `index.ts`, thus we can use it in the guide components with React lazy loading.
|
||||
|
||||
This may be fixed by replacing Parcel with something else.
|
59
packages/console/src/assets/docs/guides/generate-metadata.js
Normal file
59
packages/console/src/assets/docs/guides/generate-metadata.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const entries = await fs.readdir('.');
|
||||
const directories = entries.filter((entry) => !entry.includes('.'));
|
||||
|
||||
const metadata = directories
|
||||
.map((directory) => {
|
||||
if (!existsSync(`${directory}/README.mdx`)) {
|
||||
console.warn(`No README.mdx file found in ${directory} directory, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(`${directory}/index.ts`)) {
|
||||
console.warn(`No index.ts file found in ${directory} directory, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add `.png` later
|
||||
const logo = ['logo.svg'].find((logo) => existsSync(`${directory}/${logo}`));
|
||||
|
||||
return {
|
||||
name: directory,
|
||||
logo,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const camelCase = (value) => value.replaceAll(/-./g, (x) => x[1].toUpperCase());
|
||||
const filename = 'index.ts';
|
||||
|
||||
await fs.writeFile(
|
||||
filename,
|
||||
"// This is a generated file, don't update manually.\n\nimport { lazy } from 'react';\n\nimport { type Guide } from './types';\n"
|
||||
);
|
||||
|
||||
for (const { name } of metadata) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.appendFile(filename, `import ${camelCase(name)} from './${name}/index';\n`);
|
||||
}
|
||||
|
||||
await fs.appendFile(filename, '\n');
|
||||
await fs.appendFile(filename, 'const guides: Readonly<Guide[]> = Object.freeze([');
|
||||
|
||||
for (const { name, logo } of metadata) {
|
||||
fs.appendFile(
|
||||
filename,
|
||||
`
|
||||
{
|
||||
id: '${name}',
|
||||
logo: ${logo ? `lazy(async () => import('./${name}/${logo}'))` : 'undefined'},
|
||||
Component: lazy(async () => import('./${name}/README.mdx')),
|
||||
metadata: ${camelCase(name)},
|
||||
},
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.appendFile(filename, ']);\n\nexport default guides;\n');
|
17
packages/console/src/assets/docs/guides/index.ts
Normal file
17
packages/console/src/assets/docs/guides/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
// This is a generated file, don't update manually.
|
||||
|
||||
import { lazy } from 'react';
|
||||
|
||||
import spaReact from './spa-react/index';
|
||||
import { type Guide } from './types';
|
||||
|
||||
const guides: Readonly<Guide[]> = Object.freeze([
|
||||
{
|
||||
id: 'spa-react',
|
||||
logo: lazy(async () => import('./spa-react/logo.svg')),
|
||||
Component: lazy(async () => import('./spa-react/README.mdx')),
|
||||
metadata: spaReact,
|
||||
},
|
||||
]);
|
||||
|
||||
export default guides;
|
189
packages/console/src/assets/docs/guides/spa-react/README.mdx
Normal file
189
packages/console/src/assets/docs/guides/spa-react/README.mdx
Normal file
|
@ -0,0 +1,189 @@
|
|||
import UriInputField from '@mdx/components/UriInputField';
|
||||
import Tabs from '@mdx/components/Tabs';
|
||||
import TabItem from '@mdx/components/TabItem';
|
||||
import InlineNotification from '@/ds-components/InlineNotification';
|
||||
import Steps from '@/mdx-components-v2/Steps';
|
||||
import Step from '@/mdx-components-v2/Step';
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step
|
||||
title="Add Logto SDK as a dependency"
|
||||
subtitle="Please select your favorite package manager"
|
||||
>
|
||||
<Tabs>
|
||||
<TabItem value="npm" label="npm">
|
||||
|
||||
```bash
|
||||
npm i @logto/react
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yarn" label="Yarn">
|
||||
|
||||
```bash
|
||||
yarn add @logto/react
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pnpm" label="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @logto/react
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Init LogtoClient"
|
||||
subtitle="1 step"
|
||||
>
|
||||
|
||||
Import and use `LogtoProvider` to provide a Logto context:
|
||||
|
||||
<pre>
|
||||
<code className="language-tsx">
|
||||
{`import { LogtoProvider, LogtoConfig } from '@logto/react';
|
||||
|
||||
const config: LogtoConfig = {
|
||||
endpoint: '${props.endpoint}',${props.alternativeEndpoint ? ` // or "${props.alternativeEndpoint}"` : ''}
|
||||
appId: '${props.appId}',
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<LogtoProvider config={config}>
|
||||
<YourAppContent />
|
||||
</LogtoProvider>
|
||||
);`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Sign in"
|
||||
subtitle="3 steps"
|
||||
>
|
||||
|
||||
<InlineNotification>
|
||||
In the following steps, we assume your app is running on <code>http://localhost:3000</code>.
|
||||
</InlineNotification>
|
||||
|
||||
### Configure Redirect URI
|
||||
|
||||
First, let’s enter your redirect URI. E.g. `http://localhost:3000/callback`.
|
||||
|
||||
<UriInputField
|
||||
appId={props.appId}
|
||||
isSingle={!props.isCompact}
|
||||
name="redirectUris"
|
||||
title="application_details.redirect_uri"
|
||||
/>
|
||||
|
||||
### Implement a sign-in button
|
||||
|
||||
We provide two hooks `useHandleSignInCallback()` and `useLogto()` which can help you easily manage the authentication flow.
|
||||
|
||||
Go back to your IDE/editor, use the following code to implement the sign-in button:
|
||||
|
||||
<pre>
|
||||
<code className="language-tsx">
|
||||
{`import { useLogto } from '@logto/react';
|
||||
|
||||
const SignIn = () => {
|
||||
const { signIn, isAuthenticated } = useLogto();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <div>Signed in</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => signIn('${props.redirectUris[0] ?? 'http://localhost:3000/callback'}')}>
|
||||
Sign In
|
||||
</button>
|
||||
);
|
||||
};`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
### Handle redirect
|
||||
|
||||
We're almost there! In the last step, we use `http://localhost:3000/callback` as the Redirect URI, and now we need to handle it properly.
|
||||
|
||||
First let's create a callback component:
|
||||
|
||||
```tsx
|
||||
import { useHandleSignInCallback } from '@logto/react';
|
||||
|
||||
const Callback = () => {
|
||||
const { isLoading } = useHandleSignInCallback(() => {
|
||||
// Navigate to root path when finished
|
||||
});
|
||||
|
||||
// When it's working in progress
|
||||
if (isLoading) {
|
||||
return <div>Redirecting...</div>;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Finally insert the code below to create a `/callback` route which does NOT require authentication:
|
||||
|
||||
```tsx
|
||||
// Assuming react-router
|
||||
<Route path="/callback" element={<Callback />} />
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Sign out"
|
||||
subtitle="1 step"
|
||||
>
|
||||
|
||||
Calling `.signOut()` will clear all the Logto data in memory and localStorage if they exist.
|
||||
|
||||
After signing out, it'll be great to redirect user back to your website. Let's add `http://localhost:3000` as the Post Sign-out URI below, and use it as the parameter when calling `.signOut()`.
|
||||
|
||||
<UriInputField
|
||||
appId={props.appId}
|
||||
isSingle={!props.isCompact}
|
||||
name="postLogoutRedirectUris"
|
||||
title="application_details.post_sign_out_redirect_uri"
|
||||
/>
|
||||
|
||||
### Implement a sign-out button
|
||||
|
||||
<pre>
|
||||
<code className="language-tsx">
|
||||
{`const SignOut = () => {
|
||||
const { signOut } = useLogto();
|
||||
|
||||
return (
|
||||
<button onClick={() => signOut('${
|
||||
props.postLogoutRedirectUris[0] ?? 'http://localhost:3000'
|
||||
}')}>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
};`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Further readings"
|
||||
subtitle="4 articles"
|
||||
>
|
||||
|
||||
- [Customize sign-in experience](https://docs.logto.io/docs/tutorials/get-started/customize-sign-in-experience)
|
||||
- [Enable SMS or email passcode sign-in](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-sms-or-email-passwordless-sign-in)
|
||||
- [Enable social sign-in](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-social-sign-in)
|
||||
- [Protect your API](https://docs.logto.io/docs/recipes/protect-your-api)
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
12
packages/console/src/assets/docs/guides/spa-react/index.ts
Normal file
12
packages/console/src/assets/docs/guides/spa-react/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { ApplicationType } from '@logto/schemas';
|
||||
|
||||
import { type GuideMetadata } from '../types';
|
||||
|
||||
const metadata: Readonly<GuideMetadata> = Object.freeze({
|
||||
name: 'React',
|
||||
description: 'React is a JavaScript library for building user interfaces.',
|
||||
target: ApplicationType.SPA,
|
||||
language: 'javascript',
|
||||
});
|
||||
|
||||
export default metadata;
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
|
||||
<title>React Logo</title>
|
||||
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
|
||||
<g stroke="#61dafb" stroke-width="1" fill="none">
|
||||
<ellipse rx="11" ry="4.2"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 366 B |
42
packages/console/src/assets/docs/guides/types.ts
Normal file
42
packages/console/src/assets/docs/guides/types.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { type ApplicationType } from '@logto/schemas';
|
||||
import { type MDXProps } from 'mdx/types';
|
||||
import { type LazyExoticComponent, type FunctionComponent } from 'react';
|
||||
|
||||
type ProgrammingLanguage =
|
||||
| 'javascript'
|
||||
| 'typescript'
|
||||
| 'go'
|
||||
| 'java'
|
||||
| 'swift'
|
||||
| 'kotlin'
|
||||
| 'php'
|
||||
| 'python'
|
||||
| 'agnostic';
|
||||
|
||||
/**
|
||||
* The guide metadata type. The directory name that the metadata is in will be the
|
||||
* unique identifier of the guide.
|
||||
*/
|
||||
export type GuideMetadata = {
|
||||
/** The name of the target (framework, language, or platform) that the guide is for. */
|
||||
name: string;
|
||||
/** The short description of the guide, should be less than 100 characters. */
|
||||
description?: string;
|
||||
/**
|
||||
* The target resource of the guide.
|
||||
*
|
||||
* For example, if the guide is for application creation, the target should be `ApplicationType`,
|
||||
* and an application of the target type should be created.
|
||||
*/
|
||||
target: ApplicationType | 'API';
|
||||
/** The programming language of the guide. If it doesn't apply, set it to `agnostic`. */
|
||||
language: ProgrammingLanguage;
|
||||
};
|
||||
|
||||
/** The guide instance to build in the console. */
|
||||
export type Guide = {
|
||||
id: string;
|
||||
logo?: LazyExoticComponent<FunctionComponent>;
|
||||
Component: LazyExoticComponent<FunctionComponent<MDXProps>>;
|
||||
metadata: Readonly<GuideMetadata>;
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
import { yes } from '@silverhand/essentials';
|
||||
|
||||
export const isProduction = process.env.NODE_ENV === 'production';
|
||||
export const isCloud = yes(process.env.IS_CLOUD);
|
||||
export const adminEndpoint = process.env.ADMIN_ENDPOINT;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { Nullable } from '@silverhand/essentials';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const useScroll = (contentRef: Nullable<HTMLDivElement>) => {
|
||||
const useScroll = (contentRef: Nullable<HTMLElement>) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
|
|
5
packages/console/src/mdx-components-v2/README.md
Normal file
5
packages/console/src/mdx-components-v2/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# MDX components v2
|
||||
|
||||
This directory contains the source code for the new app creation guide MDX components. We'll progressively adopt the new design and delete `mdx-components` once we're done.
|
||||
|
||||
The core component is `<Steps />` which renders the guide content.
|
|
@ -0,0 +1,15 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
border-radius: 16px;
|
||||
background: var(--color-layer-1);
|
||||
padding: _.unit(5) _.unit(6);
|
||||
width: 100%;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(4);
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
}
|
32
packages/console/src/mdx-components-v2/Step/index.tsx
Normal file
32
packages/console/src/mdx-components-v2/Step/index.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { type ReactNode, forwardRef, type Ref } from 'react';
|
||||
|
||||
import Index from '@/components/Index';
|
||||
import CardTitle from '@/ds-components/CardTitle';
|
||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
index: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Step({ title, subtitle, index, children }: Props, ref?: Ref<HTMLDivElement>) {
|
||||
return (
|
||||
<section ref={ref} className={styles.wrapper}>
|
||||
<header>
|
||||
<Index index={index + 1} />
|
||||
<CardTitle
|
||||
size="medium"
|
||||
title={<DangerousRaw>{title}</DangerousRaw>}
|
||||
subtitle={<DangerousRaw>{subtitle}</DangerousRaw>}
|
||||
/>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef<HTMLDivElement, Props>(Step);
|
|
@ -0,0 +1,44 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navigationAnchor {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
margin-right: _.unit(4);
|
||||
width: 220px;
|
||||
|
||||
> :not(:last-child) {
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 858px;
|
||||
flex-grow: 1;
|
||||
|
||||
> :not(:last-child) {
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
}
|
||||
|
||||
.stepper {
|
||||
font: var(--font-title-2);
|
||||
color: var(--color-text);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
padding: _.unit(3) _.unit(4);
|
||||
|
||||
&.active {
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
}
|
92
packages/console/src/mdx-components-v2/Steps/index.tsx
Normal file
92
packages/console/src/mdx-components-v2/Steps/index.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { type Nullable } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import React, { useRef, type ReactElement, useEffect, useState } from 'react';
|
||||
|
||||
import useScroll from '@/hooks/use-scroll';
|
||||
|
||||
import { type Props as StepProps } from '../Step';
|
||||
import type Step from '../Step';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
children: Array<ReactElement<StepProps, typeof Step>>;
|
||||
};
|
||||
|
||||
/** Find the first scrollable element in the parent chain. */
|
||||
const findScrollableElement = (element: Nullable<HTMLElement>): Nullable<HTMLElement> => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { overflowY } = window.getComputedStyle(element);
|
||||
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return element;
|
||||
}
|
||||
|
||||
return findScrollableElement(element.parentElement);
|
||||
};
|
||||
|
||||
export default function Steps({ children }: Props) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const stepReferences = useRef<Array<Nullable<HTMLElement>>>([]);
|
||||
const { scrollTop } = useScroll(findScrollableElement(contentRef.current));
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
// Make sure the step references length matches the number of children.
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
stepReferences.current = stepReferences.current.slice(0, children.length);
|
||||
}, [children]);
|
||||
|
||||
useEffect(() => {
|
||||
// Get the active index by comparing the scroll position of the content
|
||||
// with the top of each step in reverse order because the last satisfied
|
||||
// step is the active one.
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
const reversedActiveIndex = stepReferences.current
|
||||
.slice()
|
||||
.reverse()
|
||||
.findIndex((stepRef) => {
|
||||
if (!stepRef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const elementScrollTop = stepRef.getBoundingClientRect().top;
|
||||
// Don't try to understand it, feel it.
|
||||
return elementScrollTop <= 280;
|
||||
});
|
||||
|
||||
setActiveIndex(reversedActiveIndex === -1 ? -1 : children.length - reversedActiveIndex - 1);
|
||||
}, [children.length, scrollTop]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.navigationAnchor}>
|
||||
<nav className={styles.navigation}>
|
||||
{children.map((component, index) => (
|
||||
<div
|
||||
key={component.props.title}
|
||||
className={classNames(styles.stepper, index === activeIndex && styles.active)}
|
||||
>
|
||||
{index + 1}. {component.props.title}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div ref={contentRef} className={styles.content}>
|
||||
{children.map((component, index) =>
|
||||
React.cloneElement(component, {
|
||||
key: component.props.title,
|
||||
index,
|
||||
ref: (element: HTMLDivElement) => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
stepReferences.current[index] = element;
|
||||
},
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
import type { Application } from '@logto/schemas';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import { isProduction } from '@/consts/env';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import GuideV2 from '../GuideV2';
|
||||
|
||||
import Guide from '.';
|
||||
|
||||
type Props = {
|
||||
|
@ -26,7 +29,12 @@ function GuideModal({ app, onClose }: Props) {
|
|||
className={modalStyles.fullScreen}
|
||||
onRequestClose={closeModal}
|
||||
>
|
||||
<Guide app={app} onClose={closeModal} />
|
||||
{/* Switch to v2 once migration is complete. */}
|
||||
{isProduction ? (
|
||||
<Guide app={app} onClose={closeModal} />
|
||||
) : (
|
||||
<GuideV2 app={app} onClose={closeModal} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-layer-1);
|
||||
height: 64px;
|
||||
padding: 0 _.unit(6);
|
||||
|
||||
.separator {
|
||||
@include _.vertical-bar;
|
||||
height: 20px;
|
||||
margin: 0 _.unit(5) 0 _.unit(4);
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.githubToolTipAnchor {
|
||||
margin-right: _.unit(4);
|
||||
}
|
||||
|
||||
.githubIcon {
|
||||
div {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.requestSdkButton {
|
||||
margin-right: _.unit(15);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import Close from '@/assets/icons/close.svg';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CardTitle from '@/ds-components/CardTitle';
|
||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import Spacer from '@/ds-components/Spacer';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
appName: string;
|
||||
isCompact?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function GuideHeaderV2({ appName, isCompact = false, onClose }: Props) {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
{isCompact && (
|
||||
<>
|
||||
<CardTitle
|
||||
size="small"
|
||||
title={<DangerousRaw>{appName}</DangerousRaw>}
|
||||
subtitle="applications.guide.header_description"
|
||||
/>
|
||||
<Spacer />
|
||||
<IconButton size="large" onClick={onClose}>
|
||||
<Close className={styles.closeIcon} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<>
|
||||
<IconButton size="large" onClick={onClose}>
|
||||
<Close className={styles.closeIcon} />
|
||||
</IconButton>
|
||||
<div className={styles.separator} />
|
||||
<CardTitle
|
||||
size="small"
|
||||
title={<DangerousRaw>{appName}</DangerousRaw>}
|
||||
subtitle="applications.guide.header_description"
|
||||
/>
|
||||
<Spacer />
|
||||
<Button
|
||||
className={styles.requestSdkButton}
|
||||
type="outline"
|
||||
title={<DangerousRaw>Request additional SDK</DangerousRaw>}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuideHeaderV2;
|
|
@ -0,0 +1,44 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-base);
|
||||
height: 100vh;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
padding: _.unit(6) _.unit(6) max(10vh, 120px);
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markdownContent {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: _.unit(4);
|
||||
background-color: var(--color-bg-float);
|
||||
|
||||
.layout {
|
||||
margin: 0 auto;
|
||||
max-width: 858px;
|
||||
|
||||
> button {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import { DomainStatus, type Application } from '@logto/schemas';
|
||||
import { MDXProvider } from '@mdx-js/react';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, Suspense } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CodeEditor from '@/ds-components/CodeEditor';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
import DetailsSummary from '@/mdx-components/DetailsSummary';
|
||||
import { applyDomain } from '@/utils/domain';
|
||||
|
||||
import GuideHeaderV2 from '../GuideHeaderV2';
|
||||
import StepsSkeleton from '../StepsSkeleton';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
app?: Application;
|
||||
isCompact?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function GuideV2({ app, isCompact, onClose }: Props) {
|
||||
const { tenantEndpoint } = useContext(AppDataContext);
|
||||
const { data: customDomain } = useCustomDomain();
|
||||
const isCustomDomainActive = customDomain?.status === DomainStatus.Active;
|
||||
const guide = guides.find(({ id }) => id === 'spa-react');
|
||||
|
||||
if (!app || !guide) {
|
||||
throw new Error('Invalid app or guide');
|
||||
}
|
||||
|
||||
const GuideComponent = guide.Component;
|
||||
const { id: appId, secret: appSecret, name: appName, oidcClientMetadata } = app;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<GuideHeaderV2 appName={appName} isCompact={isCompact} onClose={onClose} />
|
||||
<div className={styles.content}>
|
||||
<MDXProvider
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const [, language] = /language-(\w+)/.exec(String(className ?? '')) ?? [];
|
||||
|
||||
return language ? (
|
||||
<CodeEditor isReadonly language={language} value={String(children).trimEnd()} />
|
||||
) : (
|
||||
<code>{String(children).trimEnd()}</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, ...props }) => (
|
||||
<TextLink {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</TextLink>
|
||||
),
|
||||
details: DetailsSummary,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<StepsSkeleton />}>
|
||||
{tenantEndpoint && (
|
||||
<GuideComponent
|
||||
appId={appId}
|
||||
appSecret={appSecret}
|
||||
endpoint={
|
||||
isCustomDomainActive
|
||||
? applyDomain(tenantEndpoint.toString(), customDomain.domain)
|
||||
: tenantEndpoint
|
||||
}
|
||||
alternativeEndpoint={conditional(isCustomDomainActive && tenantEndpoint)}
|
||||
redirectUris={oidcClientMetadata.redirectUris}
|
||||
postLogoutRedirectUris={oidcClientMetadata.postLogoutRedirectUris}
|
||||
isCompact={isCompact}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</MDXProvider>
|
||||
<nav className={styles.actionBar}>
|
||||
<div className={styles.layout}>
|
||||
<Button size="large" title="cloud.sie.finish_and_done" type="primary" />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuideV2;
|
|
@ -178,6 +178,20 @@
|
|||
--color-guide-dropdown-border: var(--color-border);
|
||||
--color-skeleton-shimmer-rgb: 255, 255, 255; // rgb of Layer-1
|
||||
--color-specific-tag-upsell: var(--color-primary-50);
|
||||
|
||||
// Background
|
||||
--color-bg-body-base: var(--color-neutral-95);
|
||||
--color-bg-body: var(--color-neutral-100);
|
||||
--color-bg-layer-1: var(--color-static-white);
|
||||
--color-bg-layer-2: var(--color-neutral-95);
|
||||
--color-bg-body-overlay: var(--color-neutral-100);
|
||||
--color-bg-float-base: var(--color-neutral-variant-90);
|
||||
--color-bg-float: var(--color-neutral-100);
|
||||
--color-bg-float-overlay: var(--color-neutral-100);
|
||||
--color-bg-mask: rgba(0, 0, 0, 40%); // 4% --color-neutral-0
|
||||
--color-bg-toast: var(--color-neutral-20);
|
||||
--color-bg-state-unselected: var(--color-neutral-90);
|
||||
--color-bg-state-disabled: rgba(25, 28, 29, 8%); // 8% --color-neutral-10
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
|
@ -360,4 +374,21 @@
|
|||
--color-guide-dropdown-border: var(--color-neutral-variant-70);
|
||||
--color-skeleton-shimmer-rgb: 42, 44, 50; // rgb of Layer-1
|
||||
--color-specific-tag-upsell: var(--color-primary-70);
|
||||
|
||||
// Background
|
||||
--color-bg-body-base: var(--color-neutral-100);
|
||||
--color-bg-body: var(--color-surface);
|
||||
--color-bg-body-overlay: var(--color-surface-2);
|
||||
--color-bg-layer-1:
|
||||
linear-gradient(0deg, rgba(202, 190, 255, 8%), rgba(202, 190, 255, 8%)),
|
||||
linear-gradient(0deg, rgba(196, 199, 199, 2%), rgba(196, 199, 199, 2%)),
|
||||
#191c1d;
|
||||
--color-bg-layer-2: var(--color-surface-4);
|
||||
--color-bg-float-base: var(--color-neutral-100);
|
||||
--color-bg-float: var(--color-surface-3);
|
||||
--color-bg-float-overlay: var(--color-surface-4);
|
||||
--color-bg-mask: rgba(0, 0, 0, 60%); // 60% --color-neutral-100;
|
||||
--color-bg-toast: var(--color-neutral-80);
|
||||
--color-bg-state-unselected: var(--color-neutral-90);
|
||||
--color-bg-state-disabled: rgba(247, 248, 248, 8%); // 8% --color-neutral-10
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue