mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 10:55:57 +00:00
OpenAPI: Implement i18n
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'fumadocs-openapi': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement i18n
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'fumadocs-ui': minor
|
||||||
|
'@fumadocs/base-ui': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improved `defineI18nUI()` usage: allow language translations to be defined at root config.
|
||||||
@@ -98,6 +98,31 @@ paths:
|
|||||||
planter.list({ unwatered: true });
|
planter.list({ unwatered: true });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
|
||||||
|
Assuming you have configured [Internationalization](/docs/internationalization) at UI level:
|
||||||
|
|
||||||
|
```tsx title="layout.shared.tsx"
|
||||||
|
import { defineI18n } from 'fumadocs-core/i18n';
|
||||||
|
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||||
|
import { defineI18nOpenAPI } from 'fumadocs-openapi/i18n';
|
||||||
|
|
||||||
|
const i18n = defineI18n({
|
||||||
|
languages: ['en', 'cn'],
|
||||||
|
defaultLanguage: 'en',
|
||||||
|
});
|
||||||
|
|
||||||
|
export let i18nUI = defineI18nUI(i18n, {
|
||||||
|
cn: { displayName: 'Chinese' },
|
||||||
|
});
|
||||||
|
|
||||||
|
i18nUI = defineI18nOpenAPI(i18nUI, {
|
||||||
|
cn: {
|
||||||
|
titleRequestBody: '請求正文',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Media Adapters
|
## Media Adapters
|
||||||
|
|
||||||
You can create a media adapter to support other media types in API pages, a media adapter implements:
|
You can create a media adapter to support other media types in API pages, a media adapter implements:
|
||||||
|
|||||||
@@ -45,16 +45,14 @@ import { i18n } from '@/lib/i18n';
|
|||||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||||
|
|
||||||
// [!code ++:11]
|
// [!code ++:9]
|
||||||
export const i18nUI = defineI18nUI(i18n, {
|
export const i18nUI = defineI18nUI(i18n, {
|
||||||
translations: {
|
en: {
|
||||||
en: {
|
displayName: 'English',
|
||||||
displayName: 'English',
|
},
|
||||||
},
|
cn: {
|
||||||
cn: {
|
displayName: 'Chinese',
|
||||||
displayName: 'Chinese',
|
search: '搜尋文檔',
|
||||||
search: '搜尋文檔',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +69,6 @@ Pass translations to `<RootProvider />`:
|
|||||||
|
|
||||||
```tsx title="app/[lang]/layout.tsx"
|
```tsx title="app/[lang]/layout.tsx"
|
||||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
|
||||||
import { i18nUI } from '@/lib/layout.shared';
|
import { i18nUI } from '@/lib/layout.shared';
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|||||||
@@ -70,16 +70,14 @@ import { i18n } from '@/lib/i18n';
|
|||||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||||
|
|
||||||
// [!code ++:11]
|
// [!code ++:9]
|
||||||
export const i18nUI = defineI18nUI(i18n, {
|
export const i18nUI = defineI18nUI(i18n, {
|
||||||
translations: {
|
en: {
|
||||||
en: {
|
displayName: 'English',
|
||||||
displayName: 'English',
|
},
|
||||||
},
|
cn: {
|
||||||
cn: {
|
displayName: 'Chinese',
|
||||||
displayName: 'Chinese',
|
search: '搜尋文檔',
|
||||||
search: '搜尋文檔',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,16 +29,14 @@ import { i18n } from '@/lib/i18n';
|
|||||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||||
|
|
||||||
// [!code ++:11]
|
// [!code ++:9]
|
||||||
export const i18nUI = defineI18nUI(i18n, {
|
export const i18nUI = defineI18nUI(i18n, {
|
||||||
translations: {
|
en: {
|
||||||
en: {
|
displayName: 'English',
|
||||||
displayName: 'English',
|
},
|
||||||
},
|
cn: {
|
||||||
cn: {
|
displayName: 'Chinese',
|
||||||
displayName: 'Chinese',
|
search: '搜尋文檔',
|
||||||
search: '搜尋文檔',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,18 @@ import { i18n } from '@/lib/i18n';
|
|||||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||||
|
|
||||||
export const i18nUI = defineI18nUI(i18n, {
|
export const i18nUI = defineI18nUI(i18n, {
|
||||||
translations: {
|
en: {
|
||||||
en: {
|
displayName: 'English',
|
||||||
displayName: 'English',
|
},
|
||||||
},
|
cn: {
|
||||||
cn: {
|
displayName: 'Chinese',
|
||||||
displayName: 'Chinese',
|
toc: '目錄',
|
||||||
toc: '目錄',
|
search: '搜尋文檔',
|
||||||
search: '搜尋文檔',
|
lastUpdate: '最後更新於',
|
||||||
lastUpdate: '最後更新於',
|
searchNoResult: '沒有結果',
|
||||||
searchNoResult: '沒有結果',
|
previousPage: '上一頁',
|
||||||
previousPage: '上一頁',
|
nextPage: '下一頁',
|
||||||
nextPage: '下一頁',
|
chooseLanguage: '選擇語言',
|
||||||
chooseLanguage: '選擇語言',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,12 @@ function RootComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { provider } = defineI18nUI(i18n, {
|
const { provider } = defineI18nUI(i18n, {
|
||||||
translations: {
|
cn: {
|
||||||
cn: {
|
displayName: 'Chinese',
|
||||||
displayName: 'Chinese',
|
search: 'Translated Content',
|
||||||
search: 'Translated Content',
|
},
|
||||||
},
|
en: {
|
||||||
en: {
|
displayName: 'English',
|
||||||
displayName: 'English',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { i18n } from '@/lib/i18n';
|
|||||||
import { useRouter } from 'waku/router/client';
|
import { useRouter } from 'waku/router/client';
|
||||||
|
|
||||||
const { provider } = defineI18nUI(i18n, {
|
const { provider } = defineI18nUI(i18n, {
|
||||||
translations: {
|
en: {
|
||||||
en: {
|
displayName: 'English',
|
||||||
displayName: 'English',
|
},
|
||||||
},
|
cn: {
|
||||||
cn: {
|
displayName: 'Chinese',
|
||||||
displayName: 'Chinese',
|
search: 'Search (Translated)',
|
||||||
search: 'Search (Translated)',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,19 @@ export interface Translations {
|
|||||||
editOnGithub: string;
|
editOnGithub: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocaleItem {
|
export type TranslationsOption = {
|
||||||
|
[key: string]: string | TranslationsOption;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LocaleItem {
|
||||||
name: string;
|
name: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface I18nContextType {
|
interface I18nContextType {
|
||||||
|
text: Translations & TranslationsOption;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
onChange?: (v: string) => void;
|
onChange?: (v: string) => void;
|
||||||
text: Translations;
|
|
||||||
locales?: LocaleItem[];
|
locales?: LocaleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +47,11 @@ export const defaultTranslations: Translations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType>({
|
const I18nContext = createContext<I18nContextType>({
|
||||||
text: defaultTranslations,
|
text: { ...defaultTranslations },
|
||||||
});
|
});
|
||||||
|
|
||||||
export function I18nLabel(props: { label: keyof Translations }): string {
|
export function I18nLabel(props: { label: keyof Translations }): string {
|
||||||
const { text } = useI18n();
|
const text = useI18n().text;
|
||||||
|
|
||||||
return text[props.label];
|
return text[props.label];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ export interface I18nProviderProps {
|
|||||||
/**
|
/**
|
||||||
* Current locale
|
* Current locale
|
||||||
*/
|
*/
|
||||||
locale: string;
|
locale?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle changes to the locale, redirect user when not specified.
|
* Handle changes to the locale, redirect user when not specified.
|
||||||
@@ -70,7 +73,7 @@ export interface I18nProviderProps {
|
|||||||
/**
|
/**
|
||||||
* Translations of current locale
|
* Translations of current locale
|
||||||
*/
|
*/
|
||||||
translations?: Partial<Translations>;
|
translations?: TranslationsOption;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Available languages
|
* Available languages
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ export interface I18nUIConfig<Languages extends string> extends I18nConfig<Langu
|
|||||||
/**
|
/**
|
||||||
* get i18n config for Fumadocs UI `<RootProvider i18n={config} />`.
|
* get i18n config for Fumadocs UI `<RootProvider i18n={config} />`.
|
||||||
*/
|
*/
|
||||||
provider: (locale?: string) => I18nProviderProps;
|
provider: (locale?: Languages | (string & {})) => I18nProviderProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TranslationsConfig<Languages extends string> = {
|
||||||
|
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
||||||
|
};
|
||||||
|
|
||||||
export function defineI18nUI<Languages extends string>(
|
export function defineI18nUI<Languages extends string>(
|
||||||
config: I18nConfig<Languages>,
|
config: I18nConfig<Languages>,
|
||||||
options: {
|
options:
|
||||||
translations: {
|
| {
|
||||||
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
/**
|
||||||
};
|
* @deprecated you can directly define the translations in outer scope (the parent object of `translations`)
|
||||||
},
|
*/
|
||||||
|
translations: TranslationsConfig<Languages>;
|
||||||
|
}
|
||||||
|
| TranslationsConfig<Languages> = {},
|
||||||
): I18nUIConfig<Languages> {
|
): I18nUIConfig<Languages> {
|
||||||
const { translations } = options;
|
const translations = 'translations' in options ? options.translations : options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
|
"./i18n": "./dist/i18n.js",
|
||||||
"./playground": "./dist/playground/index.js",
|
"./playground": "./dist/playground/index.js",
|
||||||
"./playground/client": "./dist/playground/client.js",
|
"./playground/client": "./dist/playground/client.js",
|
||||||
"./requests/generators": "./dist/requests/generators/index.js",
|
"./requests/generators": "./dist/requests/generators/index.js",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"types:check": "tsc --noEmit"
|
"types:check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/deepmerge": "^3.2.1",
|
||||||
"@fumari/json-schema-ts": "^0.0.2",
|
"@fumari/json-schema-ts": "^0.0.2",
|
||||||
"@fumari/stf": "workspace:*",
|
"@fumari/stf": "workspace:*",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { I18nUIConfig } from 'fumadocs-ui/i18n';
|
||||||
|
import { deepmerge } from '@fastify/deepmerge';
|
||||||
|
import type { TranslationsOption } from 'fumadocs-ui/contexts/i18n';
|
||||||
|
|
||||||
|
export const defaultTranslations = {
|
||||||
|
// General
|
||||||
|
loading: 'loading...',
|
||||||
|
empty: 'Empty',
|
||||||
|
copy: 'Copy',
|
||||||
|
send: 'Send',
|
||||||
|
authorization: 'Authorization',
|
||||||
|
cookies: 'Cookies',
|
||||||
|
query: 'Query',
|
||||||
|
path: 'Path',
|
||||||
|
header: 'Header',
|
||||||
|
body: 'Body',
|
||||||
|
deprecated: 'Deprecated',
|
||||||
|
submit: 'Submit',
|
||||||
|
unsupported: 'Unsupported',
|
||||||
|
close: 'Close',
|
||||||
|
inputPlaceholder: 'Enter value',
|
||||||
|
|
||||||
|
// Request tabs
|
||||||
|
titleRequestTabs: 'Example Requests',
|
||||||
|
requestTabNameDefault: 'Default',
|
||||||
|
queryParameters: 'Query Parameters',
|
||||||
|
pathParameters: 'Path Parameters',
|
||||||
|
headerParameters: 'Header Parameters',
|
||||||
|
cookieParameters: 'Cookie Parameters',
|
||||||
|
|
||||||
|
// Operation sections
|
||||||
|
titleRequestBody: 'Request Body',
|
||||||
|
titleResponseBody: 'Response Body',
|
||||||
|
titleCallbacks: 'Callbacks',
|
||||||
|
|
||||||
|
// Auth scheme
|
||||||
|
authBasicTokenExample: 'Basic <token>',
|
||||||
|
authBearerTokenExample: 'Bearer <token>',
|
||||||
|
openIdConnect: 'OpenID Connect',
|
||||||
|
authTokenIn: 'In',
|
||||||
|
authScope: 'Scope',
|
||||||
|
|
||||||
|
// TypeScript panel
|
||||||
|
typeScriptDefinitions: 'TypeScript Definitions',
|
||||||
|
useTypeInTypeScript: 'Use the {name} type in TypeScript.',
|
||||||
|
|
||||||
|
// Schema info tags
|
||||||
|
schemaDefault: 'Default',
|
||||||
|
schemaMatch: 'Match',
|
||||||
|
schemaFormat: 'Format',
|
||||||
|
schemaMultipleOf: 'Multiple Of',
|
||||||
|
schemaRange: 'Range',
|
||||||
|
schemaLength: 'Length',
|
||||||
|
schemaProperties: 'Properties',
|
||||||
|
schemaItems: 'Items',
|
||||||
|
schemaValueIn: 'Value in',
|
||||||
|
schemaExample: 'Example',
|
||||||
|
|
||||||
|
// Response tabs
|
||||||
|
responseTabName: 'Example {key}',
|
||||||
|
responseTabNameDefault: 'Example',
|
||||||
|
|
||||||
|
// Playground
|
||||||
|
closeJsonEditor: 'Close JSON Editor',
|
||||||
|
openJsonEditor: 'Open JSON Editor',
|
||||||
|
accessToken: 'Access Token',
|
||||||
|
authorize: 'Authorize',
|
||||||
|
openIdUnsupported:
|
||||||
|
'OpenID Connect is not supported at the moment, you can still set an access token here.',
|
||||||
|
|
||||||
|
// Status info
|
||||||
|
statusBadRequest: 'Bad Request',
|
||||||
|
statusUnauthorized: 'Unauthorized',
|
||||||
|
statusForbidden: 'Forbidden',
|
||||||
|
statusNotFound: 'Not Found',
|
||||||
|
statusInternalServerError: 'Internal Server Error',
|
||||||
|
statusSuccessful: 'Successful',
|
||||||
|
statusError: 'Error',
|
||||||
|
statusNoDescription: 'No Description',
|
||||||
|
|
||||||
|
// OAuth dialog
|
||||||
|
obtainAccessToken: 'Obtain the access token for API.',
|
||||||
|
resourceOwnerPassword: 'Resource Owner Password Flow',
|
||||||
|
resourceOwnerPasswordDesc: 'Authenticate using username and password.',
|
||||||
|
clientCredentials: 'Client Credentials',
|
||||||
|
clientCredentialsDesc: 'Intended for the server-to-server authentication.',
|
||||||
|
authorizationCode: 'Authorization code',
|
||||||
|
authorizationCodeDesc: 'Authenticate with 3rd party services',
|
||||||
|
implicit: 'Implicit',
|
||||||
|
implicitDesc: 'Retrieve the access token directly.',
|
||||||
|
deviceAuthorization: 'Device Authorization',
|
||||||
|
deviceAuthorizationDesc: 'Authenticate with device.',
|
||||||
|
clientId: 'Client ID',
|
||||||
|
clientIdHint: 'The client ID of your OAuth application.',
|
||||||
|
clientSecret: 'Client Secret',
|
||||||
|
clientSecretHint: 'The client secret of your OAuth application.',
|
||||||
|
usernameField: 'Username',
|
||||||
|
passwordField: 'Password',
|
||||||
|
fetchingToken: 'Fetching token...',
|
||||||
|
|
||||||
|
// Server select
|
||||||
|
serverUrl: 'Server URL',
|
||||||
|
serverUrlDescription: 'The base URL of your API endpoint.',
|
||||||
|
serverUrlFieldPlaceholder: 'Enter Value',
|
||||||
|
|
||||||
|
// Schema UI
|
||||||
|
schemaShowArray: 'Array Item',
|
||||||
|
schemaHideArray: 'Array Item',
|
||||||
|
schemaFilterPropertiesPlaceholder: 'Filter Properties',
|
||||||
|
schemaFilterPropertiesEmpty: 'No property matching',
|
||||||
|
|
||||||
|
// Inputs (playground)
|
||||||
|
playgroundShowProperty: 'Show Property',
|
||||||
|
playgroundPropertyPlaceholder: 'Enter Property Name',
|
||||||
|
playgroundNewProperty: 'New',
|
||||||
|
playgroundNewItem: 'New Item',
|
||||||
|
playgroundRemoveItem: 'Remove Item',
|
||||||
|
playgroundSelectPlaceholder: 'Select',
|
||||||
|
playgroundSelected: 'Selected',
|
||||||
|
playgroundInputUpload: 'Upload',
|
||||||
|
playgroundInputUnset: 'Unset',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Translations = typeof defaultTranslations;
|
||||||
|
|
||||||
|
export function defineI18nOpenAPI<Languages extends string>(
|
||||||
|
config: I18nUIConfig<Languages>,
|
||||||
|
translations: Partial<Record<NoInfer<Languages>, Partial<Translations>>>,
|
||||||
|
): I18nUIConfig<Languages> {
|
||||||
|
const dm = deepmerge();
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
provider(locale = config.defaultLanguage) {
|
||||||
|
const out = config.provider(locale);
|
||||||
|
const data = translations[locale as Languages];
|
||||||
|
if (data) {
|
||||||
|
out.translations ??= {};
|
||||||
|
out.translations.openapi = dm(defaultTranslations, data) as TranslationsOption;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
|
|||||||
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
|
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
|
||||||
import type { ParameterObject } from '@/types';
|
import type { ParameterObject } from '@/types';
|
||||||
import { ClientCodeBlock } from '@/ui/components/codeblock';
|
import { ClientCodeBlock } from '@/ui/components/codeblock';
|
||||||
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
|
||||||
export interface FormValues extends Record<string, unknown> {
|
export interface FormValues extends Record<string, unknown> {
|
||||||
path: Record<string, unknown>;
|
path: Record<string, unknown>;
|
||||||
@@ -139,6 +140,7 @@ export default function PlaygroundClient({
|
|||||||
readOnly,
|
readOnly,
|
||||||
...rest
|
...rest
|
||||||
}: PlaygroundClientProps) {
|
}: PlaygroundClientProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const { example: exampleId, examples, setExampleData } = useExampleRequests();
|
const { example: exampleId, examples, setExampleData } = useExampleRequests();
|
||||||
const storageKeys = useStorageKey();
|
const storageKeys = useStorageKey();
|
||||||
const {
|
const {
|
||||||
@@ -268,7 +270,7 @@ export default function PlaygroundClient({
|
|||||||
className={cn(buttonVariants({ color: 'primary', size: 'sm' }), 'w-14 py-1.5')}
|
className={cn(buttonVariants({ color: 'primary', size: 'sm' }), 'w-14 py-1.5')}
|
||||||
disabled={testQuery.isLoading}
|
disabled={testQuery.isLoading}
|
||||||
>
|
>
|
||||||
{testQuery.isLoading ? <LoaderCircle className="size-4 animate-spin" /> : 'Send'}
|
{testQuery.isLoading ? <LoaderCircle className="size-4 animate-spin" /> : t.send}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{testQuery.data ? <ResultDisplay data={testQuery.data} reset={testQuery.reset} /> : null}
|
{testQuery.data ? <ResultDisplay data={testQuery.data} reset={testQuery.reset} /> : null}
|
||||||
@@ -304,9 +306,10 @@ function SecurityTabs({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const engine = useDataEngine();
|
const engine = useDataEngine();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
const result = (
|
const result = (
|
||||||
<CollapsiblePanel title="Authorization">
|
<CollapsiblePanel title={t.authorization}>
|
||||||
<Select value={securityId.toString()} onValueChange={(v) => setSecurityId(Number(v))}>
|
<Select value={securityId.toString()} onValueChange={(v) => setSecurityId(Number(v))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -367,6 +370,7 @@ const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
|
|||||||
|
|
||||||
function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parameters' | 'body'>) {
|
function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parameters' | 'body'>) {
|
||||||
const { renderParameterField, renderBodyField } = useApiContext().client.playground ?? {};
|
const { renderParameterField, renderBodyField } = useApiContext().client.playground ?? {};
|
||||||
|
const t = useTranslations();
|
||||||
const panels = useMemo(() => {
|
const panels = useMemo(() => {
|
||||||
return ParamTypes.map((type) => {
|
return ParamTypes.map((type) => {
|
||||||
const items = parameters.filter((v) => v.in === type);
|
const items = parameters.filter((v) => v.in === type);
|
||||||
@@ -377,10 +381,10 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
|
|||||||
key={type}
|
key={type}
|
||||||
title={
|
title={
|
||||||
{
|
{
|
||||||
header: 'Header',
|
header: t.header,
|
||||||
cookie: 'Cookies',
|
cookie: t.cookies,
|
||||||
query: 'Query',
|
query: t.query,
|
||||||
path: 'Path',
|
path: t.path,
|
||||||
}[type]
|
}[type]
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -410,13 +414,13 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
|
|||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [parameters, renderParameterField]);
|
}, [parameters, renderParameterField, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{panels}
|
{panels}
|
||||||
{body && (
|
{body && (
|
||||||
<CollapsiblePanel title="Body">
|
<CollapsiblePanel title={t.body}>
|
||||||
{renderBodyField ? renderBodyField('body', body) : <BodyInput field={body.schema} />}
|
{renderBodyField ? renderBodyField('body', body) : <BodyInput field={body.schema} />}
|
||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
)}
|
)}
|
||||||
@@ -427,6 +431,7 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
|
|||||||
function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||||
const field = useResolvedSchema(_field);
|
const field = useResolvedSchema(_field);
|
||||||
const [isJson, setIsJson] = useState(false);
|
const [isJson, setIsJson] = useState(false);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
|
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
|
||||||
|
|
||||||
@@ -444,7 +449,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
|||||||
onClick={() => setIsJson(false)}
|
onClick={() => setIsJson(false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Close JSON Editor
|
{t.closeJsonEditor}
|
||||||
</button>
|
</button>
|
||||||
<JsonInput fieldName={['body']} />
|
<JsonInput fieldName={['body']} />
|
||||||
</>
|
</>
|
||||||
@@ -468,7 +473,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setIsJson(true)}
|
onClick={() => setIsJson(true)}
|
||||||
>
|
>
|
||||||
Open JSON Editor
|
{t.openJsonEditor}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -490,6 +495,7 @@ function useAuthInputs(
|
|||||||
transform?: (fields: AuthField[]) => AuthField[],
|
transform?: (fields: AuthField[]) => AuthField[],
|
||||||
) {
|
) {
|
||||||
const storageKeys = useStorageKey();
|
const storageKeys = useStorageKey();
|
||||||
|
const t = useTranslations();
|
||||||
const inputs = useMemo(() => {
|
const inputs = useMemo(() => {
|
||||||
const result: AuthField[] = [];
|
const result: AuthField[] = [];
|
||||||
if (!securities) return result;
|
if (!securities) return result;
|
||||||
@@ -540,7 +546,7 @@ function useAuthInputs(
|
|||||||
children: (
|
children: (
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
|
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
|
||||||
Access Token
|
{t.accessToken}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FieldInput
|
<FieldInput
|
||||||
@@ -561,7 +567,7 @@ function useAuthInputs(
|
|||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Authorize
|
{t.authorize}
|
||||||
</OauthDialogTrigger>
|
</OauthDialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -576,7 +582,7 @@ function useAuthInputs(
|
|||||||
defaultValue: 'Bearer ',
|
defaultValue: 'Bearer ',
|
||||||
children: (
|
children: (
|
||||||
<FieldSet
|
<FieldSet
|
||||||
name="Authorization (header)"
|
name={`${t.authorization} (${t.header})`}
|
||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
isRequired
|
isRequired
|
||||||
field={{
|
field={{
|
||||||
@@ -613,17 +619,14 @@ function useAuthInputs(
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<FieldSet
|
<FieldSet
|
||||||
name="Authorization (header)"
|
name={`${t.authorization} (${t.header})`}
|
||||||
isRequired
|
isRequired
|
||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
field={{
|
field={{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-fd-muted-foreground text-xs">
|
<p className="text-fd-muted-foreground text-xs">{t.openIdUnsupported}</p>
|
||||||
OpenID Connect is not supported at the moment, you can still set an access token
|
|
||||||
here.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -631,7 +634,7 @@ function useAuthInputs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return transform ? transform(result) : result;
|
return transform ? transform(result) : result;
|
||||||
}, [securities, transform]);
|
}, [securities, transform, t]);
|
||||||
|
|
||||||
const mapInputs = (values: FormValues) => {
|
const mapInputs = (values: FormValues) => {
|
||||||
const cloned = structuredClone(values);
|
const cloned = structuredClone(values);
|
||||||
@@ -695,7 +698,8 @@ function Route({ route, ...props }: ComponentProps<'div'> & { route: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DefaultResultDisplay({ data, reset }: { data: FetchResult; reset: () => void }) {
|
function DefaultResultDisplay({ data, reset }: { data: FetchResult; reset: () => void }) {
|
||||||
const statusInfo = useMemo(() => getStatusInfo(data.status), [data.status]);
|
const t = useTranslations();
|
||||||
|
const statusInfo = useMemo(() => getStatusInfo(data.status, t), [data.status, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 mt-2 px-3 py-2 border-y bg-fd-secondary text-fd-secondary-foreground">
|
<div className="flex flex-col gap-3 mt-2 px-3 py-2 border-y bg-fd-secondary text-fd-secondary-foreground">
|
||||||
@@ -709,7 +713,7 @@ function DefaultResultDisplay({ data, reset }: { data: FetchResult; reset: () =>
|
|||||||
className={cn(buttonVariants({ size: 'sm', variant: 'outline' }))}
|
className={cn(buttonVariants({ size: 'sm', variant: 'outline' }))}
|
||||||
onClick={() => reset()}
|
onClick={() => reset()}
|
||||||
>
|
>
|
||||||
Close
|
{t.close}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-fd-muted-foreground">{data.status}</p>
|
<p className="text-sm text-fd-muted-foreground">{data.status}</p>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import type { ParsedSchema } from '@/utils/schema';
|
import type { ParsedSchema } from '@/utils/schema';
|
||||||
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
|
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
|
||||||
const fieldLabelVariants = cva('w-full inline-flex items-center gap-0.5');
|
const fieldLabelVariants = cva('w-full inline-flex items-center gap-0.5');
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export function ObjectInput({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hiddenProperties = isLazy ? schemaPropKeys.filter((key) => !_objectKeys.includes(key)) : [];
|
const hiddenProperties = isLazy ? schemaPropKeys.filter((key) => !_objectKeys.includes(key)) : [];
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -74,7 +76,7 @@ export function ObjectInput({
|
|||||||
{isLazy && hiddenProperties.length > 0 && (
|
{isLazy && hiddenProperties.length > 0 && (
|
||||||
<Select value="" onValueChange={onAppend}>
|
<Select value="" onValueChange={onAppend}>
|
||||||
<SelectTrigger className="col-span-full">
|
<SelectTrigger className="col-span-full">
|
||||||
<SelectValue placeholder="Show Property" />
|
<SelectValue placeholder={t.playgroundShowProperty} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{hiddenProperties.map((key) => (
|
{hiddenProperties.map((key) => (
|
||||||
@@ -92,7 +94,7 @@ export function ObjectInput({
|
|||||||
toolbar = (
|
toolbar = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove Item"
|
aria-label={t.playgroundRemoveItem}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
color: 'outline',
|
color: 'outline',
|
||||||
@@ -123,7 +125,7 @@ export function ObjectInput({
|
|||||||
<div className="flex gap-2 order-last col-span-full">
|
<div className="flex gap-2 order-last col-span-full">
|
||||||
<Input
|
<Input
|
||||||
value={nextName}
|
value={nextName}
|
||||||
placeholder="Enter Property Name"
|
placeholder={t.playgroundPropertyPlaceholder}
|
||||||
onChange={(e) => setNextName(e.target.value)}
|
onChange={(e) => setNextName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -141,7 +143,7 @@ export function ObjectInput({
|
|||||||
setNextName('');
|
setNextName('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
New
|
{t.playgroundNewProperty}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -186,6 +188,7 @@ export function FieldInput({
|
|||||||
}) {
|
}) {
|
||||||
const [value, setValue] = useFieldValue(fieldName);
|
const [value, setValue] = useFieldValue(fieldName);
|
||||||
const id = stringifyFieldKey(fieldName);
|
const id = stringifyFieldKey(fieldName);
|
||||||
|
const t = useTranslations();
|
||||||
if (field.type === 'null') return;
|
if (field.type === 'null') return;
|
||||||
|
|
||||||
if (field.type === 'string' && field.format === 'binary') {
|
if (field.type === 'string' && field.format === 'binary') {
|
||||||
@@ -202,11 +205,11 @@ export function FieldInput({
|
|||||||
>
|
>
|
||||||
{value instanceof File ? (
|
{value instanceof File ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-fd-muted-foreground text-xs">Selected</span>
|
<span className="text-fd-muted-foreground text-xs">{t.playgroundSelected}</span>
|
||||||
<span className="truncate w-0 flex-1 text-end">{value.name}</span>
|
<span className="truncate w-0 flex-1 text-end">{value.name}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-fd-muted-foreground">Upload</span>
|
<span className="text-fd-muted-foreground">{t.playgroundInputUpload}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -232,7 +235,7 @@ export function FieldInput({
|
|||||||
onValueChange={(v) => setValue(field.enum![Number(v)])}
|
onValueChange={(v) => setValue(field.enum![Number(v)])}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={id} {...props}>
|
<SelectTrigger id={id} {...props}>
|
||||||
<SelectValue placeholder="Select" />
|
<SelectValue placeholder={t.playgroundSelectPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{field.enum.map((item, i) => (
|
{field.enum.map((item, i) => (
|
||||||
@@ -240,7 +243,7 @@ export function FieldInput({
|
|||||||
{typeof item === 'string' ? item : JSON.stringify(item, null, 2)}
|
{typeof item === 'string' ? item : JSON.stringify(item, null, 2)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{!isRequired && <SelectItem value="-1">Unset</SelectItem>}
|
{!isRequired && <SelectItem value="-1">{t.playgroundInputUnset}</SelectItem>}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
@@ -258,7 +261,7 @@ export function FieldInput({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="true">True</SelectItem>
|
<SelectItem value="true">True</SelectItem>
|
||||||
<SelectItem value="false">False</SelectItem>
|
<SelectItem value="false">False</SelectItem>
|
||||||
{!isRequired && <SelectItem value="undefined">Unset</SelectItem>}
|
{!isRequired && <SelectItem value="undefined">{t.playgroundInputUnset}</SelectItem>}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
@@ -268,7 +271,7 @@ export function FieldInput({
|
|||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
placeholder="Enter value"
|
placeholder={t.inputPlaceholder}
|
||||||
type={isNumber ? 'number' : 'text'}
|
type={isNumber ? 'number' : 'text'}
|
||||||
step={field.type === 'integer' ? 1 : undefined}
|
step={field.type === 'integer' ? 1 : undefined}
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
@@ -521,6 +524,7 @@ function ArrayInput({
|
|||||||
const name = fieldName.at(-1) ?? '';
|
const name = fieldName.at(-1) ?? '';
|
||||||
const { generateDefault } = useSchemaUtils();
|
const { generateDefault } = useSchemaUtils();
|
||||||
const { items, insertItem, removeItem } = useArray(fieldName);
|
const { items, insertItem, removeItem } = useArray(fieldName);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} className={cn('flex flex-col gap-2', props.className)}>
|
<div {...props} className={cn('flex flex-col gap-2', props.className)}>
|
||||||
@@ -538,7 +542,7 @@ function ArrayInput({
|
|||||||
toolbar={
|
toolbar={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove Item"
|
aria-label={t.playgroundRemoveItem}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
color: 'outline',
|
color: 'outline',
|
||||||
@@ -566,7 +570,7 @@ function ArrayInput({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New Item
|
{t.playgroundNewItem}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Input, labelVariants } from '@/ui/components/input';
|
import { Input, labelVariants } from '@/ui/components/input';
|
||||||
import { useQuery } from '@/utils/use-query';
|
import { useQuery } from '@/utils/use-query';
|
||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/ui/components/select';
|
} from '@/ui/components/select';
|
||||||
import type { OAuth2SecurityScheme } from '@/types';
|
import type { OAuth2SecurityScheme } from '@/types';
|
||||||
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
|
||||||
type FlowType = keyof NonNullable<OAuth2SecurityScheme['flows']>;
|
type FlowType = keyof NonNullable<OAuth2SecurityScheme['flows']>;
|
||||||
|
|
||||||
@@ -51,33 +52,11 @@ interface ImplicitState {
|
|||||||
client_id: string;
|
client_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlowTypes = {
|
interface FlowInfo {
|
||||||
password: {
|
name: ReactNode;
|
||||||
name: 'Resource Owner Password Flow',
|
description: ReactNode;
|
||||||
description: 'Authenticate using username and password.',
|
supported: boolean;
|
||||||
supported: true,
|
}
|
||||||
},
|
|
||||||
clientCredentials: {
|
|
||||||
name: 'Client Credentials',
|
|
||||||
description: 'Intended for the server-to-server authentication.',
|
|
||||||
supported: true,
|
|
||||||
},
|
|
||||||
authorizationCode: {
|
|
||||||
name: 'Authorization code',
|
|
||||||
description: 'Authenticate with 3rd party services',
|
|
||||||
supported: true,
|
|
||||||
},
|
|
||||||
implicit: {
|
|
||||||
name: 'Implicit',
|
|
||||||
description: 'Retrieve the access token directly.',
|
|
||||||
supported: true,
|
|
||||||
},
|
|
||||||
deviceAuthorization: {
|
|
||||||
name: 'Device Authorization',
|
|
||||||
description: 'Authenticate with device.',
|
|
||||||
supported: false,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function OauthDialog({
|
export function OauthDialog({
|
||||||
scheme,
|
scheme,
|
||||||
@@ -90,7 +69,38 @@ export function OauthDialog({
|
|||||||
const [type, setType] = useState(() => {
|
const [type, setType] = useState(() => {
|
||||||
return Object.keys(scheme.flows!)[0] as FlowType;
|
return Object.keys(scheme.flows!)[0] as FlowType;
|
||||||
});
|
});
|
||||||
const { supported } = FlowTypes[type];
|
const t = useTranslations();
|
||||||
|
const allFlows: Record<FlowType, FlowInfo> = useMemo(
|
||||||
|
() => ({
|
||||||
|
password: {
|
||||||
|
name: t.resourceOwnerPassword,
|
||||||
|
description: t.resourceOwnerPasswordDesc,
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
clientCredentials: {
|
||||||
|
name: t.clientCredentials,
|
||||||
|
description: t.clientCredentialsDesc,
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
authorizationCode: {
|
||||||
|
name: t.authorizationCode,
|
||||||
|
description: t.authorizationCodeDesc,
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
implicit: {
|
||||||
|
name: t.implicit,
|
||||||
|
description: t.implicitDesc,
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
deviceAuthorization: {
|
||||||
|
name: t.deviceAuthorization,
|
||||||
|
description: t.deviceAuthorizationDesc,
|
||||||
|
supported: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
clientId: '',
|
clientId: '',
|
||||||
@@ -264,8 +274,8 @@ export function OauthDialog({
|
|||||||
{children}
|
{children}
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Authorization</DialogTitle>
|
<DialogTitle>{t.authorization}</DialogTitle>
|
||||||
<DialogDescription>Obtain the access token for API.</DialogDescription>
|
<DialogDescription>{t.obtainAccessToken}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
@@ -280,7 +290,7 @@ export function OauthDialog({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.keys(scheme.flows!).map((key) => {
|
{Object.keys(scheme.flows!).map((key) => {
|
||||||
const { name, description } = FlowTypes[key as FlowType];
|
const { name, description } = allFlows[key as FlowType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectItem key={key} value={key}>
|
<SelectItem key={key} value={key}>
|
||||||
@@ -297,14 +307,12 @@ export function OauthDialog({
|
|||||||
type === 'implicit') && (
|
type === 'implicit') && (
|
||||||
<fieldset className="flex flex-col gap-1.5">
|
<fieldset className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||||
Client ID
|
{t.clientId}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-fd-muted-foreground text-sm">
|
<p className="text-fd-muted-foreground text-sm">{t.clientIdHint}</p>
|
||||||
The client ID of your OAuth application.
|
|
||||||
</p>
|
|
||||||
<Input
|
<Input
|
||||||
id="client_id"
|
id="client_id"
|
||||||
placeholder="Enter value"
|
placeholder={t.inputPlaceholder}
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -315,14 +323,12 @@ export function OauthDialog({
|
|||||||
{(type === 'authorizationCode' || type === 'clientCredentials') && (
|
{(type === 'authorizationCode' || type === 'clientCredentials') && (
|
||||||
<fieldset className="flex flex-col gap-1.5">
|
<fieldset className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||||
Client Secret
|
{t.clientSecret}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-fd-muted-foreground text-sm">
|
<p className="text-fd-muted-foreground text-sm">{t.clientSecretHint}</p>
|
||||||
The client secret of your OAuth application.
|
|
||||||
</p>
|
|
||||||
<Input
|
<Input
|
||||||
id="client_secret"
|
id="client_secret"
|
||||||
placeholder="Enter value"
|
placeholder={t.inputPlaceholder}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -334,11 +340,11 @@ export function OauthDialog({
|
|||||||
<>
|
<>
|
||||||
<fieldset className="flex flex-col gap-1.5">
|
<fieldset className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="username" className={cn(labelVariants())}>
|
<label htmlFor="username" className={cn(labelVariants())}>
|
||||||
Username
|
{t.usernameField}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
placeholder="Enter value"
|
placeholder={t.inputPlaceholder}
|
||||||
type="text"
|
type="text"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -347,11 +353,11 @@ export function OauthDialog({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="flex flex-col gap-1.5">
|
<fieldset className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="password" className={cn(labelVariants())}>
|
<label htmlFor="password" className={cn(labelVariants())}>
|
||||||
Client Secret
|
{t.clientSecret}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
placeholder="Enter value"
|
placeholder={t.inputPlaceholder}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -360,7 +366,7 @@ export function OauthDialog({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{supported ? (
|
{allFlows[type].supported ? (
|
||||||
<>
|
<>
|
||||||
{error ? <p className="text-red-400 font-medium text-sm">{String(error)}</p> : null}
|
{error ? <p className="text-red-400 font-medium text-sm">{String(error)}</p> : null}
|
||||||
<button
|
<button
|
||||||
@@ -372,12 +378,12 @@ export function OauthDialog({
|
|||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{authCodeCallback.isLoading ? 'Fetching token...' : 'Submit'}
|
{authCodeCallback.isLoading ? t.fetchingToken : t.submit}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
|
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
|
||||||
Unsupported
|
{t.unsupported}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ import type { ServerVariableObject } from '@/types';
|
|||||||
import type { NoReference } from '@/utils/schema';
|
import type { NoReference } from '@/utils/schema';
|
||||||
import { StfProvider, useFieldValue, useListener, useStf } from '@fumari/stf';
|
import { StfProvider, useFieldValue, useListener, useStf } from '@fumari/stf';
|
||||||
import { EditIcon } from 'lucide-react';
|
import { EditIcon } from 'lucide-react';
|
||||||
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
|
||||||
export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>) {
|
export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>) {
|
||||||
const { servers } = useServerContext();
|
const { servers } = useServerContext();
|
||||||
const { server, setServer, setServerVariables } = useServerSelectContext();
|
const { server, setServer, setServerVariables } = useServerSelectContext();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
@@ -47,7 +49,7 @@ export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="px-2 py-0.5 -ms-2 font-medium rounded-lg border bg-fd-secondary text-fd-secondary-foreground shadow-sm">
|
<span className="px-2 py-0.5 -ms-2 font-medium rounded-lg border bg-fd-secondary text-fd-secondary-foreground shadow-sm">
|
||||||
{server?.name ?? 'Server URL'}
|
{server?.name ?? t.serverUrl}
|
||||||
</span>
|
</span>
|
||||||
<code className="truncate min-w-0 flex-1">
|
<code className="truncate min-w-0 flex-1">
|
||||||
{isMounted
|
{isMounted
|
||||||
@@ -55,14 +57,14 @@ export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>
|
|||||||
server ? resolveServerUrl(server.url, server.variables) : '/',
|
server ? resolveServerUrl(server.url, server.variables) : '/',
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
)
|
)
|
||||||
: 'loading...'}
|
: t.loading}
|
||||||
</code>
|
</code>
|
||||||
<EditIcon className="size-4" />
|
<EditIcon className="size-4" />
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Server URL</DialogTitle>
|
<DialogTitle>{t.serverUrl}</DialogTitle>
|
||||||
<DialogDescription>The base URL of your API endpoint.</DialogDescription>
|
<DialogDescription>{t.serverUrlDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Select value={server?.url} onValueChange={setServer}>
|
<Select value={server?.url} onValueChange={setServer}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -143,6 +145,7 @@ function Field({
|
|||||||
variable: NoReference<ServerVariableObject>;
|
variable: NoReference<ServerVariableObject>;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
const [value, setValue] = useFieldValue([fieldName], {
|
const [value, setValue] = useFieldValue([fieldName], {
|
||||||
compute(currentValue) {
|
compute(currentValue) {
|
||||||
return typeof currentValue === 'string' ? currentValue : undefined;
|
return typeof currentValue === 'string' ? currentValue : undefined;
|
||||||
@@ -171,7 +174,7 @@ function Field({
|
|||||||
id={fieldName}
|
id={fieldName}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder="Enter Value"
|
placeholder={t.serverUrlFieldPlaceholder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CircleCheck, CircleX } from 'lucide-react';
|
import { CircleCheck, CircleX } from 'lucide-react';
|
||||||
|
import type { Translations } from '@/i18n';
|
||||||
|
|
||||||
interface StatusInfo {
|
interface StatusInfo {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -6,45 +7,37 @@ interface StatusInfo {
|
|||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMap: Record<number, StatusInfo> = {
|
const statusKeys: Record<
|
||||||
400: { description: 'Bad Request', color: 'text-red-500', icon: CircleX },
|
number,
|
||||||
401: {
|
{ key: keyof Translations; color: string; icon: React.ElementType }
|
||||||
description: 'Unauthorized',
|
> = {
|
||||||
color: 'text-red-500',
|
400: { key: 'statusBadRequest', color: 'text-red-500', icon: CircleX },
|
||||||
icon: CircleX,
|
401: { key: 'statusUnauthorized', color: 'text-red-500', icon: CircleX },
|
||||||
},
|
403: { key: 'statusForbidden', color: 'text-red-500', icon: CircleX },
|
||||||
403: { description: 'Forbidden', color: 'text-red-500', icon: CircleX },
|
404: { key: 'statusNotFound', color: 'text-fd-muted-foreground', icon: CircleX },
|
||||||
404: {
|
500: { key: 'statusInternalServerError', color: 'text-red-500', icon: CircleX },
|
||||||
description: 'Not Found',
|
|
||||||
color: 'text-fd-muted-foreground',
|
|
||||||
icon: CircleX,
|
|
||||||
},
|
|
||||||
500: {
|
|
||||||
description: 'Internal Server Error',
|
|
||||||
color: 'text-red-500',
|
|
||||||
icon: CircleX,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getStatusInfo(status: number): StatusInfo {
|
export function getStatusInfo(status: number, t: Translations): StatusInfo {
|
||||||
if (status in statusMap) {
|
if (status in statusKeys) {
|
||||||
return statusMap[status];
|
const { key, color, icon } = statusKeys[status];
|
||||||
|
return { description: t[key], color, icon };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
return {
|
return {
|
||||||
description: 'Successful',
|
description: t.statusSuccessful,
|
||||||
color: 'text-green-500',
|
color: 'text-green-500',
|
||||||
icon: CircleCheck,
|
icon: CircleCheck,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 400) {
|
if (status >= 400) {
|
||||||
return { description: 'Error', color: 'text-red-500', icon: CircleX };
|
return { description: t.statusError, color: 'text-red-500', icon: CircleX };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: 'No Description',
|
description: t.statusNoDescription,
|
||||||
color: 'text-fd-muted-foreground',
|
color: 'text-fd-muted-foreground',
|
||||||
icon: CircleX,
|
icon: CircleX,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export interface RenderContext
|
|||||||
|
|
||||||
renderHeading: (
|
renderHeading: (
|
||||||
depth: number,
|
depth: number,
|
||||||
text: string,
|
text: string | ReactNode,
|
||||||
props?: HTMLAttributes<HTMLHeadingElement>,
|
props?: HTMLAttributes<HTMLHeadingElement> & { id?: string },
|
||||||
) => ReactNode;
|
) => ReactNode;
|
||||||
renderCodeBlock: (lang: string, code: string) => ReactNode;
|
renderCodeBlock: (lang: string, code: string) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,8 @@ export function createAPIPage(
|
|||||||
},
|
},
|
||||||
slugger,
|
slugger,
|
||||||
async renderHeading(depth, text, props) {
|
async renderHeading(depth, text, props) {
|
||||||
const id = slugger.slug(text);
|
const id = typeof text === 'string' ? slugger.slug(text) : props?.id;
|
||||||
|
if (!id) throw new Error("missing 'id' for non-string children");
|
||||||
|
|
||||||
if (options.renderHeading) {
|
if (options.renderHeading) {
|
||||||
return options.renderHeading({ id, children: text, ...props }, depth);
|
return options.renderHeading({ id, children: text, ...props }, depth);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
import { defaultTranslations, type Translations } from '@/i18n';
|
||||||
|
import { useI18n } from 'fumadocs-ui/contexts/i18n';
|
||||||
|
|
||||||
|
export function useTranslations(): Translations {
|
||||||
|
return (useI18n().text.openapi ?? defaultTranslations) as unknown as Translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAPII18nLabelProps {
|
||||||
|
label: keyof Translations;
|
||||||
|
/** Replace {key} placeholders in the translation string */
|
||||||
|
replacements?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a translated string. Use in server components so the label is resolved on the client from the current locale.
|
||||||
|
*/
|
||||||
|
export function I18nLabel({ label, replacements = {} }: OpenAPII18nLabelProps): string {
|
||||||
|
const text = useTranslations();
|
||||||
|
let value = text[label];
|
||||||
|
for (const [k, v] of Object.entries(replacements)) {
|
||||||
|
value = value.replaceAll(`{${k}}`, v);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||||
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
@@ -32,30 +33,33 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => {
|
||||||
<DialogPortal>
|
const t = useTranslations();
|
||||||
<DialogOverlay />
|
return (
|
||||||
<DialogPrimitive.Content
|
<DialogPortal>
|
||||||
ref={ref}
|
<DialogOverlay />
|
||||||
className={cn(
|
<DialogPrimitive.Content
|
||||||
'fixed left-1/2 top-1/2 z-50 flex flex-col gap-4 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border bg-fd-popover p-4 shadow-lg rounded-xl duration-200 data-[state=open]:animate-fd-dialog-in data-[state=closed]:animate-fd-dialog-out focus-visible:outline-none',
|
ref={ref}
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
aria-label="Close"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
'fixed left-1/2 top-1/2 z-50 flex flex-col gap-4 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border bg-fd-popover p-4 shadow-lg rounded-xl duration-200 data-[state=open]:animate-fd-dialog-in data-[state=closed]:animate-fd-dialog-out focus-visible:outline-none',
|
||||||
'absolute end-2 top-2 text-fd-muted-foreground/70',
|
className,
|
||||||
)}
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<X />
|
{children}
|
||||||
</DialogPrimitive.Close>
|
<DialogPrimitive.Close
|
||||||
</DialogPrimitive.Content>
|
aria-label={t.close}
|
||||||
</DialogPortal>
|
className={cn(
|
||||||
));
|
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||||
|
'absolute end-2 top-2 text-fd-muted-foreground/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
});
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/ui/components/select';
|
} from '@/ui/components/select';
|
||||||
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
|
||||||
export function CopyTypeScriptPanel({
|
export function CopyTypeScriptPanel({
|
||||||
name,
|
name,
|
||||||
@@ -25,6 +26,8 @@ export function CopyTypeScriptPanel({
|
|||||||
const [isChecked, onCopy] = useCopyButton(() => {
|
const [isChecked, onCopy] = useCopyButton(() => {
|
||||||
void navigator.clipboard.writeText(code);
|
void navigator.clipboard.writeText(code);
|
||||||
});
|
});
|
||||||
|
const t = useTranslations();
|
||||||
|
const useTypeText = t.useTypeInTypeScript.replace('{name}', name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -34,8 +37,8 @@ export function CopyTypeScriptPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm mb-2">TypeScript Definitions</p>
|
<p className="font-medium text-sm mb-2">{t.typeScriptDefinitions}</p>
|
||||||
<p className="text-xs text-fd-muted-foreground">Use the {name} type in TypeScript.</p>
|
<p className="text-xs text-fd-muted-foreground">{useTypeText}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
@@ -48,7 +51,7 @@ export function CopyTypeScriptPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isChecked ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
{isChecked ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||||
Copy
|
{t.copy}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ComponentProps, Fragment, type ReactNode } from 'react';
|
import { ComponentProps, Fragment, type ReactNode } from 'react';
|
||||||
import type {
|
import type {
|
||||||
CallbackObject,
|
CallbackObject,
|
||||||
MethodInformation,
|
MethodInformation,
|
||||||
@@ -11,6 +11,7 @@ import { Schema } from '../schema';
|
|||||||
import { UsageTabs } from '@/ui/operation/usage-tabs';
|
import { UsageTabs } from '@/ui/operation/usage-tabs';
|
||||||
import { Badge, MethodLabel } from '@/ui/components/method-label';
|
import { Badge, MethodLabel } from '@/ui/components/method-label';
|
||||||
import { CopyTypeScriptPanel, SelectTab, SelectTabs, SelectTabTrigger } from './client';
|
import { CopyTypeScriptPanel, SelectTab, SelectTabs, SelectTabTrigger } from './client';
|
||||||
|
import { I18nLabel } from '@/ui/client/i18n';
|
||||||
import {
|
import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionHeader,
|
AccordionHeader,
|
||||||
@@ -19,18 +20,13 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/ui/components/accordion';
|
} from '@/ui/components/accordion';
|
||||||
import { isMediaTypeSupported } from '@/requests/media/adapter';
|
import { isMediaTypeSupported } from '@/requests/media/adapter';
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { APIPlayground } from '@/playground';
|
import { APIPlayground } from '@/playground';
|
||||||
import { getExampleRequests, RequestTabs } from './request-tabs';
|
import { getExampleRequests, RequestTabs } from './request-tabs';
|
||||||
import { UsageTabsProviderLazy } from './usage-tabs/lazy';
|
import { UsageTabsProviderLazy } from './usage-tabs/lazy';
|
||||||
import { ServerProviderLazy } from '../contexts/api.lazy';
|
import { ServerProviderLazy } from '../contexts/api.lazy';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
const ParamTypes = {
|
const paramTypeKeys = ['path', 'query', 'header', 'cookie'] as const;
|
||||||
path: 'Path Parameters',
|
|
||||||
query: 'Query Parameters',
|
|
||||||
header: 'Header Parameters',
|
|
||||||
cookie: 'Cookie Parameters',
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function Operation({
|
export async function Operation({
|
||||||
type = 'operation',
|
type = 'operation',
|
||||||
@@ -80,7 +76,8 @@ export async function Operation({
|
|||||||
bodyNode = (
|
bodyNode = (
|
||||||
<SelectTabs defaultValue={items[0].value}>
|
<SelectTabs defaultValue={items[0].value}>
|
||||||
<div className="flex gap-2 items-center justify-between mt-10">
|
<div className="flex gap-2 items-center justify-between mt-10">
|
||||||
{ctx.renderHeading(headingLevel, 'Request Body', {
|
{ctx.renderHeading(headingLevel, <I18nLabel label="titleRequestBody" />, {
|
||||||
|
id: 'request-body',
|
||||||
className: 'my-0!',
|
className: 'my-0!',
|
||||||
})}
|
})}
|
||||||
{contentTypes.length > 1 ? (
|
{contentTypes.length > 1 ? (
|
||||||
@@ -130,7 +127,9 @@ export async function Operation({
|
|||||||
|
|
||||||
responseNode = (
|
responseNode = (
|
||||||
<>
|
<>
|
||||||
{ctx.renderHeading(headingLevel, 'Response Body')}
|
{ctx.renderHeading(headingLevel, <I18nLabel label="titleResponseBody" />, {
|
||||||
|
id: 'response-body',
|
||||||
|
})}
|
||||||
|
|
||||||
<Accordions type="multiple">
|
<Accordions type="multiple">
|
||||||
{statuses.map((status) => (
|
{statuses.map((status) => (
|
||||||
@@ -141,13 +140,15 @@ export async function Operation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameterNode = Object.entries(ParamTypes).map(([type, title]) => {
|
const parameterNode = paramTypeKeys.map((type) => {
|
||||||
const params = method.parameters?.filter((param) => param.in === type);
|
const params = method.parameters?.filter((param) => param.in === type);
|
||||||
if (!params || params.length === 0) return;
|
if (!params || params.length === 0) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={type}>
|
<Fragment key={type}>
|
||||||
{ctx.renderHeading(headingLevel, title)}
|
{ctx.renderHeading(headingLevel, <I18nLabel label={`${type}Parameters`} />, {
|
||||||
|
id: `parameters-${type}`,
|
||||||
|
})}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{params.map(
|
{params.map(
|
||||||
(param) =>
|
(param) =>
|
||||||
@@ -206,7 +207,8 @@ export async function Operation({
|
|||||||
authNode = (
|
authNode = (
|
||||||
<SelectTabs defaultValue={items[0].value}>
|
<SelectTabs defaultValue={items[0].value}>
|
||||||
<div className="flex items-start justify-between gap-2 mt-10">
|
<div className="flex items-start justify-between gap-2 mt-10">
|
||||||
{ctx.renderHeading(headingLevel, 'Authorization', {
|
{ctx.renderHeading(headingLevel, <I18nLabel label="authorization" />, {
|
||||||
|
id: 'authorization',
|
||||||
className: 'my-0!',
|
className: 'my-0!',
|
||||||
})}
|
})}
|
||||||
{items.length > 1 ? (
|
{items.length > 1 ? (
|
||||||
@@ -239,7 +241,8 @@ export async function Operation({
|
|||||||
callbacksNode = (
|
callbacksNode = (
|
||||||
<SelectTabs defaultValue={items[0].value}>
|
<SelectTabs defaultValue={items[0].value}>
|
||||||
<div className="flex justify-between gap-2 items-end mt-10">
|
<div className="flex justify-between gap-2 items-end mt-10">
|
||||||
{ctx.renderHeading(headingLevel, 'Callbacks', {
|
{ctx.renderHeading(headingLevel, <I18nLabel label="titleCallbacks" />, {
|
||||||
|
id: 'callbacks',
|
||||||
className: 'my-0!',
|
className: 'my-0!',
|
||||||
})}
|
})}
|
||||||
{callbacks.length > 1 ? (
|
{callbacks.length > 1 ? (
|
||||||
@@ -483,16 +486,20 @@ function AuthScheme({
|
|||||||
if (scheme.type === 'http' || scheme.type === 'oauth2') {
|
if (scheme.type === 'http' || scheme.type === 'oauth2') {
|
||||||
return (
|
return (
|
||||||
<AuthProperty
|
<AuthProperty
|
||||||
name="Authorization"
|
name={<I18nLabel label="authorization" />}
|
||||||
type={
|
type={
|
||||||
scheme.type === 'http' && scheme.scheme === 'basic' ? `Basic <token>` : 'Bearer <token>'
|
scheme.type === 'http' && scheme.scheme === 'basic' ? (
|
||||||
|
<I18nLabel label="authBasicTokenExample" />
|
||||||
|
) : (
|
||||||
|
<I18nLabel label="authBearerTokenExample" />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
deprecated={scheme.deprecated}
|
deprecated={scheme.deprecated}
|
||||||
scopes={scopes}
|
scopes={scopes}
|
||||||
>
|
>
|
||||||
{scheme.description && ctx.renderMarkdown(scheme.description)}
|
{scheme.description && ctx.renderMarkdown(scheme.description)}
|
||||||
<p>
|
<p>
|
||||||
In: <code>header</code>
|
<I18nLabel label="authTokenIn" />: <code>header</code>
|
||||||
</p>
|
</p>
|
||||||
</AuthProperty>
|
</AuthProperty>
|
||||||
);
|
);
|
||||||
@@ -508,7 +515,7 @@ function AuthScheme({
|
|||||||
>
|
>
|
||||||
{scheme.description && ctx.renderMarkdown(scheme.description)}
|
{scheme.description && ctx.renderMarkdown(scheme.description)}
|
||||||
<p>
|
<p>
|
||||||
In: <code>{scheme.in}</code>
|
<I18nLabel label="authTokenIn" />: <code>{scheme.in}</code>
|
||||||
</p>
|
</p>
|
||||||
</AuthProperty>
|
</AuthProperty>
|
||||||
);
|
);
|
||||||
@@ -517,7 +524,7 @@ function AuthScheme({
|
|||||||
if (scheme.type === 'openIdConnect') {
|
if (scheme.type === 'openIdConnect') {
|
||||||
return (
|
return (
|
||||||
<AuthProperty
|
<AuthProperty
|
||||||
name="OpenID Connect"
|
name={<I18nLabel label="openIdConnect" />}
|
||||||
type="<token>"
|
type="<token>"
|
||||||
deprecated={scheme.deprecated}
|
deprecated={scheme.deprecated}
|
||||||
scopes={scopes}
|
scopes={scopes}
|
||||||
@@ -536,23 +543,28 @@ function AuthProperty({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<'div'> & {
|
}: ComponentProps<'div'> & {
|
||||||
name: string;
|
name: ReactNode;
|
||||||
type: string;
|
type: ReactNode;
|
||||||
deprecated?: boolean;
|
deprecated?: boolean;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('text-sm border-t my-4 first:border-t-0', className)}>
|
<div className={cn('text-sm border-t my-4 first:border-t-0', className)}>
|
||||||
<div className="flex flex-wrap items-center gap-3 not-prose">
|
<div className="flex flex-wrap items-center gap-3 not-prose">
|
||||||
<span className="font-medium font-mono text-fd-primary">{name}</span>
|
<span className="font-medium font-mono text-fd-primary">{name}</span>
|
||||||
<span className="text-sm font-mono text-fd-muted-foreground">{type}</span>
|
<span className="text-sm font-mono text-fd-muted-foreground">{type}</span>
|
||||||
{deprecated && <Badge color="red">Deprecated</Badge>}
|
{deprecated && (
|
||||||
|
<Badge color="red" className="text-xs">
|
||||||
|
<I18nLabel label="deprecated" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="prose-no-margin pt-2.5 empty:hidden">
|
<div className="prose-no-margin pt-2.5 empty:hidden">
|
||||||
{props.children}
|
{props.children}
|
||||||
{scopes.length > 0 && (
|
{scopes.length > 0 && (
|
||||||
<p>
|
<p>
|
||||||
Scope: <code>{scopes.join(', ')}</code>
|
<I18nLabel label="authScope" />: <code>{scopes.join(', ')}</code>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { MethodInformation, RenderContext } from '@/types';
|
import type { MethodInformation, RenderContext } from '@/types';
|
||||||
import { getPreferredType, type NoReference, pickExample } from '@/utils/schema';
|
import { getPreferredType, type NoReference, pickExample } from '@/utils/schema';
|
||||||
|
import { I18nLabel } from '@/ui/client/i18n';
|
||||||
import {
|
import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionHeader,
|
AccordionHeader,
|
||||||
@@ -165,16 +166,16 @@ function renderRequestTabsDefault(
|
|||||||
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
|
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Body
|
<I18nLabel label="titleRequestBody" />
|
||||||
<code className="text-xs text-fd-muted-foreground ms-auto">
|
<code className="text-xs text-fd-muted-foreground ms-auto">
|
||||||
{requestData.bodyMediaType}
|
{requestData.bodyMediaType}
|
||||||
</code>
|
</code>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
cookie: 'Cookie',
|
cookie: <I18nLabel label="cookieParameters" />,
|
||||||
header: 'Header',
|
header: <I18nLabel label="headerParameters" />,
|
||||||
query: 'Query Parameters',
|
query: <I18nLabel label="queryParameters" />,
|
||||||
path: 'Path Parameters',
|
path: <I18nLabel label="pathParameters" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -213,7 +214,7 @@ function renderRequestTabsDefault(
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TabsTrigger key={item.id} value={item.id}>
|
<TabsTrigger key={item.id} value={item.id}>
|
||||||
{item.name}
|
{item.id === '_default' ? <I18nLabel label="requestTabNameDefault" /> : item.name}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -227,12 +228,18 @@ function renderRequestTabsDefault(
|
|||||||
} else if (items.length === 1) {
|
} else if (items.length === 1) {
|
||||||
children = renderItem(items[0]);
|
children = renderItem(items[0]);
|
||||||
} else {
|
} else {
|
||||||
children = <p className="text-fd-muted-foreground text-xs">Empty</p>;
|
children = (
|
||||||
|
<p className="text-fd-muted-foreground text-xs">
|
||||||
|
<I18nLabel label="empty" />
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 rounded-xl border prose-no-margin bg-fd-card text-fd-card-foreground shadow-md">
|
<div className="p-3 rounded-xl border prose-no-margin bg-fd-card text-fd-card-foreground shadow-md">
|
||||||
<p className="font-semibold border-b pb-2">Example Requests</p>
|
<p className="font-semibold border-b pb-2">
|
||||||
|
<I18nLabel label="titleRequestTabs" />
|
||||||
|
</p>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
import { sample } from 'openapi-sampler';
|
import { sample } from 'openapi-sampler';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { I18nLabel } from '@/ui/client/i18n';
|
||||||
|
|
||||||
export interface ResponseTab {
|
export interface ResponseTab {
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +33,7 @@ interface ResponseExample {
|
|||||||
*/
|
*/
|
||||||
sample: unknown;
|
sample: unknown;
|
||||||
|
|
||||||
label: string;
|
label: ReactNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* description (in Markdown)
|
* description (in Markdown)
|
||||||
@@ -64,10 +65,8 @@ export function ResponseTabs({
|
|||||||
tab.examples ??= [];
|
tab.examples ??= [];
|
||||||
|
|
||||||
for (const [key, sample] of Object.entries(responseOfType.examples)) {
|
for (const [key, sample] of Object.entries(responseOfType.examples)) {
|
||||||
const title = sample?.summary || `Example ${key}`;
|
|
||||||
|
|
||||||
tab.examples.push({
|
tab.examples.push({
|
||||||
label: title,
|
label: sample?.summary ?? <I18nLabel label="responseTabName" replacements={{ key }} />,
|
||||||
sample: sample.value,
|
sample: sample.value,
|
||||||
description: sample?.description,
|
description: sample?.description,
|
||||||
});
|
});
|
||||||
@@ -75,7 +74,7 @@ export function ResponseTabs({
|
|||||||
} else if (responseOfType?.example || responseOfType?.schema) {
|
} else if (responseOfType?.example || responseOfType?.schema) {
|
||||||
tab.examples ??= [];
|
tab.examples ??= [];
|
||||||
tab.examples.push({
|
tab.examples.push({
|
||||||
label: 'Example',
|
label: <I18nLabel label="responseTabNameDefault" />,
|
||||||
sample: responseOfType.example ?? sample(responseOfType.schema as object),
|
sample: responseOfType.example ?? sample(responseOfType.schema as object),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,12 +102,12 @@ function renderResponseTabsDefault(
|
|||||||
async function renderResponse(tab: ResponseTab) {
|
async function renderResponse(tab: ResponseTab) {
|
||||||
const { examples = [] } = tab;
|
const { examples = [] } = tab;
|
||||||
|
|
||||||
let slot: ReactNode = 'Empty';
|
let slot: ReactNode = <I18nLabel label="empty" />;
|
||||||
if (examples.length > 1) {
|
if (examples.length > 1) {
|
||||||
slot = (
|
slot = (
|
||||||
<Accordions type="single" className="pt-2" defaultValue={examples[0].label}>
|
<Accordions type="single" className="pt-2" defaultValue="0">
|
||||||
{examples.map((example, i) => (
|
{examples.map((example, i) => (
|
||||||
<AccordionItem key={i} value={example.label}>
|
<AccordionItem key={i} value={i.toString()}>
|
||||||
<AccordionHeader>
|
<AccordionHeader>
|
||||||
<AccordionTrigger>{example.label}</AccordionTrigger>
|
<AccordionTrigger>{example.label}</AccordionTrigger>
|
||||||
</AccordionHeader>
|
</AccordionHeader>
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
|
||||||
import type { InfoTag, SchemaDataObjectProperty, SchemaUIGeneratedData } from '@/ui/schema';
|
import { useTranslations } from '@/ui/client/i18n';
|
||||||
|
import type {
|
||||||
|
InfoTag,
|
||||||
|
SchemaData,
|
||||||
|
SchemaDataObjectProperty,
|
||||||
|
SchemaUIGeneratedData,
|
||||||
|
} from '@/ui/schema';
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -139,23 +145,7 @@ function SchemaUIProperty({
|
|||||||
$ref: $type,
|
$ref: $type,
|
||||||
});
|
});
|
||||||
} else if (schema.type === 'array') {
|
} else if (schema.type === 'array') {
|
||||||
if (variant === 'expand')
|
if (variant === 'expand') return <ArrayItemCollapsible schema={schema} />;
|
||||||
return (
|
|
||||||
<Collapsible className="my-2">
|
|
||||||
<CollapsibleTrigger
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ color: 'secondary', size: 'sm' }),
|
|
||||||
'group px-3 py-2 data-[state=open]:rounded-b-none',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Array Item
|
|
||||||
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="-mt-px bg-fd-card px-3 rounded-lg rounded-tl-none border shadow-sm">
|
|
||||||
<SchemaUIProperty name="" $type={schema.item.$type} variant="expand" />
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
|
|
||||||
type = renderRef({
|
type = renderRef({
|
||||||
pathName: name,
|
pathName: name,
|
||||||
@@ -168,8 +158,8 @@ function SchemaUIProperty({
|
|||||||
{schema.description}
|
{schema.description}
|
||||||
{schema.infoTags && schema.infoTags.length > 0 && (
|
{schema.infoTags && schema.infoTags.length > 0 && (
|
||||||
<div className="flex flex-row gap-2 flex-wrap my-2 not-prose empty:hidden">
|
<div className="flex flex-row gap-2 flex-wrap my-2 not-prose empty:hidden">
|
||||||
{schema.infoTags.map((tag) => (
|
{schema.infoTags.map((tag, i) => (
|
||||||
<InfoTag key={tag.label} tag={tag} />
|
<InfoTag key={i} tag={tag} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,6 +173,28 @@ function SchemaUIProperty({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ArrayItemCollapsible({ schema }: { schema: Extract<SchemaData, { type: 'array' }> }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible className="my-2" open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ color: 'secondary', size: 'sm' }),
|
||||||
|
'group px-3 py-2 data-[state=open]:rounded-b-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{open ? t.schemaHideArray : t.schemaShowArray}
|
||||||
|
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="-mt-px bg-fd-card px-3 rounded-lg rounded-tl-none border shadow-sm">
|
||||||
|
<SchemaUIProperty name="" $type={schema.item.$type} variant="expand" />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ObjectSearchProps {
|
interface ObjectSearchProps {
|
||||||
properties: SchemaDataObjectProperty[];
|
properties: SchemaDataObjectProperty[];
|
||||||
container?: ComponentProps<'div'>;
|
container?: ComponentProps<'div'>;
|
||||||
@@ -194,6 +206,7 @@ function ObjectSearch({ properties, container, open }: ObjectSearchProps) {
|
|||||||
const deferredValue = useDeferredValue(search);
|
const deferredValue = useDeferredValue(search);
|
||||||
const firstItemRef = useRef<SchemaDataObjectProperty>(null);
|
const firstItemRef = useRef<SchemaDataObjectProperty>(null);
|
||||||
const prevProperties = useRef(properties);
|
const prevProperties = useRef(properties);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
if (prevProperties.current !== properties) {
|
if (prevProperties.current !== properties) {
|
||||||
prevProperties.current = properties;
|
prevProperties.current = properties;
|
||||||
@@ -214,7 +227,7 @@ function ObjectSearch({ properties, container, open }: ObjectSearchProps) {
|
|||||||
value={search}
|
value={search}
|
||||||
data-object-search-input=""
|
data-object-search-input=""
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Filter Properties"
|
placeholder={t.schemaFilterPropertiesPlaceholder}
|
||||||
className="text-sm ps-2 py-2 flex-1 outline-none placeholder:text-fd-muted-foreground"
|
className="text-sm ps-2 py-2 flex-1 outline-none placeholder:text-fd-muted-foreground"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && open) {
|
if (e.key === 'Enter' && open) {
|
||||||
@@ -244,6 +257,7 @@ function ObjectSearchContent({
|
|||||||
firstItemRef: RefObject<SchemaDataObjectProperty | null>;
|
firstItemRef: RefObject<SchemaDataObjectProperty | null>;
|
||||||
properties: SchemaDataObjectProperty[];
|
properties: SchemaDataObjectProperty[];
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const search = rawSearch.trim().toLowerCase();
|
const search = rawSearch.trim().toLowerCase();
|
||||||
return search.length > 0
|
return search.length > 0
|
||||||
@@ -256,7 +270,7 @@ function ObjectSearchContent({
|
|||||||
if (filtered.length === 0)
|
if (filtered.length === 0)
|
||||||
return (
|
return (
|
||||||
<p className="text-fd-muted-foreground text-sm px-2">
|
<p className="text-fd-muted-foreground text-sm px-2">
|
||||||
No property matching{' '}
|
{t.schemaFilterPropertiesEmpty}{' '}
|
||||||
<span className="text-fd-foreground font-medium">{`"${rawSearch}"`}</span>
|
<span className="text-fd-foreground font-medium">{`"${rawSearch}"`}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -504,6 +518,7 @@ function Property({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: PropertyProps) {
|
}: PropertyProps) {
|
||||||
|
const t = useTranslations();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -530,7 +545,7 @@ function Property({
|
|||||||
)}
|
)}
|
||||||
{deprecated && (
|
{deprecated && (
|
||||||
<Badge color="yellow" className="ms-auto text-xs">
|
<Badge color="yellow" className="ms-auto text-xs">
|
||||||
Deprecated
|
{t.deprecated}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
|
|||||||
import { mergeAllOf } from '@/utils/merge-schema';
|
import { mergeAllOf } from '@/utils/merge-schema';
|
||||||
import type { SchemaUIProps } from '@/ui/schema/client';
|
import type { SchemaUIProps } from '@/ui/schema/client';
|
||||||
import { SchemaUILazy } from '@/ui/schema/lazy';
|
import { SchemaUILazy } from '@/ui/schema/lazy';
|
||||||
|
import { I18nLabel } from '../client/i18n';
|
||||||
|
|
||||||
export interface FieldBase {
|
export interface FieldBase {
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
@@ -17,7 +18,7 @@ export interface FieldBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InfoTag {
|
export interface InfoTag {
|
||||||
label: string;
|
label: ReactNode;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,19 +102,25 @@ export function generateSchemaUI(
|
|||||||
const fields: InfoTag[] = [];
|
const fields: InfoTag[] = [];
|
||||||
|
|
||||||
if (schema.default !== undefined) {
|
if (schema.default !== undefined) {
|
||||||
fields.push({ label: 'Default', value: JSON.stringify(schema.default) });
|
fields.push({
|
||||||
|
label: <I18nLabel label="schemaDefault" />,
|
||||||
|
value: JSON.stringify(schema.default),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.pattern) {
|
if (schema.pattern) {
|
||||||
fields.push({ label: 'Match', value: schema.pattern });
|
fields.push({ label: <I18nLabel label="schemaMatch" />, value: schema.pattern });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.format) {
|
if (schema.format) {
|
||||||
fields.push({ label: 'Format', value: schema.format });
|
fields.push({ label: <I18nLabel label="schemaFormat" />, value: schema.format });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.multipleOf) {
|
if (schema.multipleOf) {
|
||||||
fields.push({ label: 'Multiple Of', value: schema.multipleOf.toString() });
|
fields.push({
|
||||||
|
label: <I18nLabel label="schemaMultipleOf" />,
|
||||||
|
value: schema.multipleOf.toString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let range = formatRange(
|
let range = formatRange(
|
||||||
@@ -123,10 +130,10 @@ export function generateSchemaUI(
|
|||||||
schema.maximum,
|
schema.maximum,
|
||||||
schema.exclusiveMaximum,
|
schema.exclusiveMaximum,
|
||||||
);
|
);
|
||||||
if (range) fields.push({ label: 'Range', value: range });
|
if (range) fields.push({ label: <I18nLabel label="schemaRange" />, value: range });
|
||||||
|
|
||||||
range = formatRange('length', schema.minLength, undefined, schema.maxLength, undefined);
|
range = formatRange('length', schema.minLength, undefined, schema.maxLength, undefined);
|
||||||
if (range) fields.push({ label: 'Length', value: range });
|
if (range) fields.push({ label: <I18nLabel label="schemaLength" />, value: range });
|
||||||
|
|
||||||
range = formatRange(
|
range = formatRange(
|
||||||
'properties',
|
'properties',
|
||||||
@@ -135,21 +142,24 @@ export function generateSchemaUI(
|
|||||||
schema.maxProperties,
|
schema.maxProperties,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
if (range) fields.push({ label: 'Properties', value: range });
|
if (range) fields.push({ label: <I18nLabel label="schemaProperties" />, value: range });
|
||||||
|
|
||||||
range = formatRange('items', schema.minItems, undefined, schema.maxItems, undefined);
|
range = formatRange('items', schema.minItems, undefined, schema.maxItems, undefined);
|
||||||
if (range) fields.push({ label: 'Items', value: range });
|
if (range) fields.push({ label: <I18nLabel label="schemaItems" />, value: range });
|
||||||
|
|
||||||
if (schema.enum) {
|
if (schema.enum) {
|
||||||
fields.push({
|
fields.push({
|
||||||
label: 'Value in',
|
label: <I18nLabel label="schemaValueIn" />,
|
||||||
value: schema.enum.map((value) => JSON.stringify(value)).join(' | '),
|
value: schema.enum.map((value) => JSON.stringify(value)).join(' | '),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showExample && schema.examples) {
|
if (showExample && schema.examples) {
|
||||||
for (const example of schema.examples) {
|
for (const example of schema.examples) {
|
||||||
fields.push({ label: 'Example', value: JSON.stringify(example, null, 2) });
|
fields.push({
|
||||||
|
label: <I18nLabel label="schemaExample" />,
|
||||||
|
value: JSON.stringify(example, null, 2),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
format: 'esm',
|
format: 'esm',
|
||||||
target: 'es2023',
|
target: 'es2023',
|
||||||
entry: [
|
entry: [
|
||||||
'./src/index.ts',
|
'./src/{index,i18n}.ts',
|
||||||
'./src/ui/index.ts',
|
'./src/ui/index.ts',
|
||||||
'./src/ui/base.tsx',
|
'./src/ui/base.tsx',
|
||||||
'./src/ui/client/index.tsx',
|
'./src/ui/client/index.tsx',
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ export interface I18nUIConfig<Languages extends string> extends I18nConfig<Langu
|
|||||||
provider: (locale?: Languages | (string & {})) => I18nProviderProps;
|
provider: (locale?: Languages | (string & {})) => I18nProviderProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TranslationsConfig<Languages extends string> = {
|
||||||
|
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
||||||
|
};
|
||||||
|
|
||||||
export function defineI18nUI<Languages extends string>(
|
export function defineI18nUI<Languages extends string>(
|
||||||
config: I18nConfig<Languages>,
|
config: I18nConfig<Languages>,
|
||||||
options: {
|
options:
|
||||||
translations: {
|
| {
|
||||||
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
/**
|
||||||
};
|
* @deprecated you can directly define the translations in outer scope (the parent object of `translations`)
|
||||||
},
|
*/
|
||||||
|
translations: TranslationsConfig<Languages>;
|
||||||
|
}
|
||||||
|
| TranslationsConfig<Languages> = {},
|
||||||
): I18nUIConfig<Languages> {
|
): I18nUIConfig<Languages> {
|
||||||
const { translations } = options;
|
const translations = 'translations' in options ? options.translations : options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
Generated
+3
@@ -2409,6 +2409,9 @@ importers:
|
|||||||
|
|
||||||
packages/openapi:
|
packages/openapi:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fastify/deepmerge':
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.2.1
|
||||||
'@fumari/json-schema-ts':
|
'@fumari/json-schema-ts':
|
||||||
specifier: ^0.0.2
|
specifier: ^0.0.2
|
||||||
version: 0.0.2([email protected])
|
version: 0.0.2([email protected])
|
||||||
|
|||||||
Reference in New Issue
Block a user