mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md
-->
<!-- ELLIPSIS_HIDDEN -->
----
> [!IMPORTANT]
> This pull request updates the Stack Auth documentation structure,
enhances navigation and layout functionalities, and introduces new
components for improved user experience.
>
> - **Behavior**:
> - Introduces `PlatformRedirect` component in `platform-redirect.tsx`
for redirecting users to their preferred platform.
> - Adds `usePlatformPreference` hook in `use-platform-preference.ts`
for managing platform preferences.
> - Updates `getSmartRedirectUrl()` in `navigation-utils.ts` to use
`getSmartPlatformRedirect()`.
> - **Layout and Navigation**:
> - Enhances sidebar functionality with collapsible sections in
`docs.tsx` and `sidebar-context.tsx`.
> - Adds `DocsSidebarCollapseTrigger` in `docs.tsx` for sidebar
collapse/expand functionality.
> - Updates `SharedHeader` in `shared-header.tsx` to include
platform-aware navigation links.
> - **Documentation Structure**:
> - Updates `meta.json` files in `templates` to reflect new
documentation structure.
> - Renames `overview.mdx` to `index.mdx` in `sdk` and `components`
directories.
> - Adds detailed documentation for `Team`, `TeamUser`, and
`ContactChannel` in respective `.mdx` files.
>
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 21e55737cb. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>
<!-- ELLIPSIS_HIDDEN -->
---------
Co-authored-by: Stack-Bot <madison@stack-auth.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
957 lines
34 KiB
TypeScript
957 lines
34 KiB
TypeScript
'use client';
|
||
|
||
import { ArrowRight, Check, Code, Copy, Play, Send, Settings, Sparkles, Zap } from 'lucide-react';
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { useAPIPageContext } from './api-page-wrapper';
|
||
import { Button } from './button';
|
||
|
||
// Types for OpenAPI specification
|
||
type OpenAPISchema = {
|
||
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null',
|
||
properties?: Record<string, OpenAPISchema>,
|
||
items?: OpenAPISchema,
|
||
required?: string[],
|
||
example?: unknown,
|
||
description?: string,
|
||
$ref?: string,
|
||
allOf?: OpenAPISchema[],
|
||
oneOf?: OpenAPISchema[],
|
||
anyOf?: OpenAPISchema[],
|
||
enum?: unknown[],
|
||
format?: string,
|
||
minimum?: number,
|
||
maximum?: number,
|
||
minLength?: number,
|
||
maxLength?: number,
|
||
pattern?: string,
|
||
additionalProperties?: boolean | OpenAPISchema,
|
||
}
|
||
|
||
type OpenAPISpec = {
|
||
openapi: string,
|
||
info: {
|
||
title: string,
|
||
version: string,
|
||
description?: string,
|
||
},
|
||
servers: Array<{
|
||
url: string,
|
||
description?: string,
|
||
}>,
|
||
paths: Record<string, Record<string, OpenAPIOperation>>,
|
||
webhooks?: Record<string, Record<string, OpenAPIOperation>>,
|
||
components?: {
|
||
schemas?: Record<string, OpenAPISchema>,
|
||
securitySchemes?: Record<string, unknown>,
|
||
},
|
||
}
|
||
|
||
type OpenAPIOperation = {
|
||
summary?: string,
|
||
description?: string,
|
||
operationId?: string,
|
||
tags?: string[],
|
||
parameters?: OpenAPIParameter[],
|
||
requestBody?: {
|
||
required?: boolean,
|
||
content: Record<string, {
|
||
schema: OpenAPISchema,
|
||
}>,
|
||
},
|
||
responses: Record<string, {
|
||
description: string,
|
||
content?: Record<string, {
|
||
schema: OpenAPISchema,
|
||
}>,
|
||
}>,
|
||
security?: Array<Record<string, string[]>>,
|
||
}
|
||
|
||
type OpenAPIParameter = {
|
||
name: string,
|
||
in: 'query' | 'path' | 'header' | 'cookie',
|
||
required?: boolean,
|
||
description?: string,
|
||
schema: OpenAPISchema,
|
||
example?: unknown,
|
||
}
|
||
|
||
type EnhancedAPIPageProps = {
|
||
document: string,
|
||
operations: Array<{
|
||
path: string,
|
||
method: string,
|
||
}>,
|
||
description?: string,
|
||
}
|
||
|
||
type RequestState = {
|
||
parameters: Record<string, unknown>,
|
||
headers: Record<string, string>,
|
||
body: string,
|
||
response: {
|
||
status?: number,
|
||
data?: unknown,
|
||
headers?: Record<string, string>,
|
||
loading: boolean,
|
||
error?: string,
|
||
timestamp?: number,
|
||
duration?: number,
|
||
},
|
||
}
|
||
|
||
const HTTP_METHOD_COLORS = {
|
||
GET: 'from-blue-500 to-blue-600 text-white shadow-blue-500/25',
|
||
POST: 'from-green-500 to-green-600 text-white shadow-green-500/25',
|
||
PUT: 'from-orange-500 to-orange-600 text-white shadow-orange-500/25',
|
||
PATCH: 'from-yellow-500 to-yellow-600 text-white shadow-yellow-500/25',
|
||
DELETE: 'from-red-500 to-red-600 text-white shadow-red-500/25',
|
||
} as const;
|
||
|
||
|
||
export function EnhancedAPIPage({ document, operations, description }: EnhancedAPIPageProps) {
|
||
const apiContext = useAPIPageContext();
|
||
|
||
// Use default functions if API context is not available
|
||
const { sharedHeaders, reportError } = apiContext || {
|
||
sharedHeaders: {},
|
||
reportError: () => {}
|
||
};
|
||
|
||
const [spec, setSpec] = useState<OpenAPISpec | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [requestState, setRequestState] = useState<RequestState>({
|
||
parameters: {},
|
||
headers: {},
|
||
body: '{}',
|
||
response: {
|
||
loading: false,
|
||
},
|
||
});
|
||
|
||
// Update request headers when shared headers change
|
||
useEffect(() => {
|
||
setRequestState(prev => ({ ...prev, headers: sharedHeaders }));
|
||
}, [sharedHeaders]);
|
||
|
||
// Helper function to generate example data from OpenAPI schema
|
||
const generateExampleFromSchema = useCallback((schema: OpenAPISchema, spec?: OpenAPISpec): unknown => {
|
||
//console.log('Processing schema:', JSON.stringify(schema, null, 2));
|
||
|
||
// Handle $ref references first
|
||
if (schema.$ref) {
|
||
//console.log('Found $ref:', schema.$ref);
|
||
const refPath = schema.$ref.replace('#/', '').split('/');
|
||
let refSchema: OpenAPISchema | undefined = spec as unknown as OpenAPISchema;
|
||
for (const part of refPath) {
|
||
refSchema = (refSchema as Record<string, unknown>)[part] as OpenAPISchema;
|
||
}
|
||
return generateExampleFromSchema(refSchema!, spec);
|
||
}
|
||
|
||
// Handle allOf (merge all schemas)
|
||
if (schema.allOf?.length) {
|
||
//console.log('Found allOf with', schema.allOf.length, 'schemas');
|
||
const merged: Record<string, unknown> = {};
|
||
for (const subSchema of schema.allOf) {
|
||
const subExample = generateExampleFromSchema(subSchema, spec);
|
||
if (typeof subExample === 'object' && subExample !== null) {
|
||
Object.assign(merged, subExample);
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
// Handle oneOf/anyOf (use first schema)
|
||
if (schema.oneOf?.length || schema.anyOf?.length) {
|
||
const schemas = schema.oneOf || schema.anyOf;
|
||
//console.log('Found oneOf/anyOf with', schemas?.length, 'schemas');
|
||
return generateExampleFromSchema(schemas![0], spec);
|
||
}
|
||
|
||
// Handle object type - prioritize this over top-level examples
|
||
if (schema.type === 'object' && schema.properties) {
|
||
//console.log('Processing object with properties:', Object.keys(schema.properties));
|
||
const example: Record<string, unknown> = {};
|
||
|
||
Object.entries(schema.properties).forEach(([key, prop]: [string, OpenAPISchema]) => {
|
||
//console.log(`Processing property ${key}:`, prop);
|
||
|
||
if (prop.example !== undefined) {
|
||
example[key] = prop.example;
|
||
} else {
|
||
// Just use the field name as the value
|
||
example[key] = key;
|
||
}
|
||
});
|
||
|
||
//console.log('Generated object example:', example);
|
||
return example;
|
||
}
|
||
|
||
// Handle direct examples only for non-object types
|
||
if (schema.example !== undefined) {
|
||
//console.log('Found direct example:', schema.example);
|
||
return schema.example;
|
||
}
|
||
|
||
// Handle array type
|
||
if (schema.type === 'array') {
|
||
if (schema.items) {
|
||
const itemExample = generateExampleFromSchema(schema.items, spec);
|
||
return [itemExample];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// For primitive types, return empty string
|
||
return "";
|
||
}, []);
|
||
|
||
// Auto-populate request body based on OpenAPI schema
|
||
useEffect(() => {
|
||
if (operations.length > 0 && spec) {
|
||
const firstOperation = operations[0];
|
||
const operation = spec.paths[firstOperation.path][firstOperation.method.toLowerCase()];
|
||
|
||
if (operation.requestBody?.content['application/json']?.schema) {
|
||
const { schema: jsonSchema } = operation.requestBody.content['application/json'];
|
||
//console.log('OpenAPI Schema for', firstOperation.path, ':', jsonSchema);
|
||
const exampleBody = generateExampleFromSchema(jsonSchema, spec);
|
||
//console.log('Generated example body:', exampleBody);
|
||
setRequestState(prev => ({
|
||
...prev,
|
||
body: JSON.stringify(exampleBody, null, 2)
|
||
}));
|
||
}
|
||
}
|
||
}, [spec, operations, generateExampleFromSchema]);
|
||
|
||
// Load OpenAPI specification
|
||
useEffect(() => {
|
||
const loadSpec = async () => {
|
||
try {
|
||
setLoading(true);
|
||
// Remove "public/" prefix since Next.js serves public files from root
|
||
const documentPath = document.startsWith('public/') ? document.slice(7) : document;
|
||
const response = await fetch(`/${documentPath}`);
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load OpenAPI spec: ${response.statusText}`);
|
||
}
|
||
const specData = await response.json();
|
||
setSpec(specData);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to load API specification');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
loadSpec().catch(err => {
|
||
setError(err instanceof Error ? err.message : 'Failed to load API specification');
|
||
});
|
||
}, [document]);
|
||
|
||
const copyToClipboard = useCallback(async (text: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (err) {
|
||
console.error('Failed to copy to clipboard:', err);
|
||
}
|
||
}, []);
|
||
|
||
const executeRequest = useCallback(async (operation: OpenAPIOperation, path: string, method: string) => {
|
||
const startTime = Date.now();
|
||
setRequestState(prev => ({
|
||
...prev,
|
||
response: { ...prev.response, loading: true, error: undefined, timestamp: startTime }
|
||
}));
|
||
|
||
try {
|
||
const baseUrl = spec?.servers[0]?.url || '';
|
||
let url = baseUrl + path;
|
||
|
||
// Replace path parameters
|
||
const pathParams = operation.parameters?.filter(p => p.in === 'path') || [];
|
||
pathParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : `{${param.name}}`;
|
||
url = url.replace(`{${param.name}}`, stringValue);
|
||
});
|
||
|
||
// Add query parameters
|
||
const queryParams = operation.parameters?.filter(p => p.in === 'query') || [];
|
||
const searchParams = new URLSearchParams();
|
||
queryParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
if (value !== undefined && value !== '') {
|
||
searchParams.append(param.name, String(value));
|
||
}
|
||
});
|
||
if (searchParams.toString()) {
|
||
url += '?' + searchParams.toString();
|
||
}
|
||
|
||
// Filter out empty headers
|
||
const filteredHeaders = Object.fromEntries(
|
||
Object.entries(requestState.headers).filter(([key, value]) => key && value)
|
||
);
|
||
|
||
const requestOptions: RequestInit = {
|
||
method: method.toUpperCase(),
|
||
headers: filteredHeaders,
|
||
};
|
||
|
||
if (['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) && requestState.body) {
|
||
requestOptions.body = requestState.body;
|
||
}
|
||
|
||
const response = await fetch(url, requestOptions);
|
||
const responseData = await response.json().catch(() => ({}));
|
||
const endTime = Date.now();
|
||
|
||
// Report error to the wrapper for smart error handling
|
||
if (!response.ok) {
|
||
reportError(response.status, responseData);
|
||
}
|
||
|
||
setRequestState(prev => ({
|
||
...prev,
|
||
response: {
|
||
loading: false,
|
||
status: response.status,
|
||
data: responseData,
|
||
headers: Object.fromEntries(response.headers.entries()),
|
||
timestamp: startTime,
|
||
duration: endTime - startTime,
|
||
}
|
||
}));
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : 'Request failed';
|
||
|
||
// Report network errors as well
|
||
reportError(0, { message: errorMessage });
|
||
|
||
setRequestState(prev => ({
|
||
...prev,
|
||
response: {
|
||
loading: false,
|
||
error: errorMessage,
|
||
timestamp: startTime,
|
||
duration: Date.now() - startTime,
|
||
}
|
||
}));
|
||
}
|
||
}, [spec, requestState.parameters, requestState.headers, requestState.body, reportError]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-24">
|
||
<div className="flex flex-col items-center gap-4">
|
||
<div className="relative">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-fd-muted"></div>
|
||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-t-fd-primary absolute top-0"></div>
|
||
</div>
|
||
<p className="text-fd-muted-foreground text-sm text-center leading-relaxed m-0">Loading API specification...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !spec) {
|
||
return (
|
||
<div className="max-w-2xl mx-auto mt-12">
|
||
<div className="border border-red-200 dark:border-red-800 rounded-xl p-8 bg-gradient-to-br from-red-50 to-red-100/50 dark:from-red-900/20 dark:to-red-800/20">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||
<span className="text-red-600 dark:text-red-400 text-xl leading-none">⚠️</span>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-red-800 dark:text-red-300 leading-tight m-0">
|
||
Failed to load API specification
|
||
</h3>
|
||
</div>
|
||
<p className="text-red-600 dark:text-red-400 leading-relaxed m-0">{error || 'Unknown error occurred'}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-fd-background">
|
||
{/* Operations */}
|
||
{operations.map(({ path, method }) => {
|
||
const operation = spec.paths[path][method.toLowerCase()];
|
||
return (
|
||
<ModernAPIPlayground
|
||
key={`${method}-${path}`}
|
||
operation={operation}
|
||
path={path}
|
||
method={method.toUpperCase()}
|
||
spec={spec}
|
||
requestState={requestState}
|
||
setRequestState={setRequestState}
|
||
onExecute={() => {
|
||
executeRequest(operation, path, method)
|
||
.catch(error => console.error('Failed to execute request:', error));
|
||
}}
|
||
onCopy={(text: string) => {
|
||
copyToClipboard(text)
|
||
.catch(error => console.error('Failed to copy to clipboard:', error));
|
||
}}
|
||
description={description || operation.description}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Modern API Playground Component
|
||
function ModernAPIPlayground({
|
||
operation,
|
||
path,
|
||
method,
|
||
spec,
|
||
requestState,
|
||
setRequestState,
|
||
onExecute,
|
||
onCopy,
|
||
description,
|
||
}: {
|
||
operation: OpenAPIOperation,
|
||
path: string,
|
||
method: string,
|
||
spec: OpenAPISpec,
|
||
requestState: RequestState,
|
||
setRequestState: React.Dispatch<React.SetStateAction<RequestState>>,
|
||
onExecute: () => void,
|
||
onCopy: (text: string) => void,
|
||
description?: string,
|
||
}) {
|
||
const [copied, setCopied] = useState(false);
|
||
const [activeCodeTab, setActiveCodeTab] = useState<'curl' | 'javascript' | 'python'>('curl');
|
||
const methodColorClass = HTTP_METHOD_COLORS[method.toUpperCase() as keyof typeof HTTP_METHOD_COLORS];
|
||
|
||
const handleCopy = async (text: string) => {
|
||
onCopy(text);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const generateCurlCommand = useCallback(() => {
|
||
const baseUrl = spec.servers[0]?.url || '';
|
||
let url = baseUrl + path;
|
||
|
||
// Replace path parameters
|
||
const pathParams = operation.parameters?.filter(p => p.in === 'path') || [];
|
||
pathParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : `{${param.name}}`;
|
||
url = url.replace(`{${param.name}}`, stringValue);
|
||
});
|
||
|
||
// Add query parameters
|
||
const queryParams = operation.parameters?.filter(p => p.in === 'query') || [];
|
||
const searchParams = new URLSearchParams();
|
||
queryParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
if (value !== undefined && value !== '') {
|
||
searchParams.append(param.name, String(value));
|
||
}
|
||
});
|
||
if (searchParams.toString()) {
|
||
url += '?' + searchParams.toString();
|
||
}
|
||
|
||
let curlCommand = `curl -X ${method} "${url}"`;
|
||
|
||
// Add headers (only non-empty ones)
|
||
Object.entries(requestState.headers).forEach(([key, value]) => {
|
||
if (key && value) {
|
||
curlCommand += ` \\\n -H "${key}: ${value}"`;
|
||
}
|
||
});
|
||
|
||
// Add body for POST/PUT/PATCH
|
||
if (['POST', 'PUT', 'PATCH'].includes(method) && requestState.body !== '{}') {
|
||
curlCommand += ` \\\n -d '${requestState.body}'`;
|
||
}
|
||
|
||
return curlCommand;
|
||
}, [operation, path, method, spec, requestState]);
|
||
|
||
const generateJavaScriptCode = useCallback(() => {
|
||
const baseUrl = spec.servers[0]?.url || '';
|
||
let url = baseUrl + path;
|
||
|
||
// Replace path parameters
|
||
const pathParams = operation.parameters?.filter(p => p.in === 'path') || [];
|
||
pathParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : `{${param.name}}`;
|
||
url = url.replace(`{${param.name}}`, stringValue);
|
||
});
|
||
|
||
// Add query parameters
|
||
const queryParams = operation.parameters?.filter(p => p.in === 'query') || [];
|
||
const searchParams = new URLSearchParams();
|
||
queryParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
if (value !== undefined && value !== '') {
|
||
searchParams.append(param.name, String(value));
|
||
}
|
||
});
|
||
if (searchParams.toString()) {
|
||
url += '?' + searchParams.toString();
|
||
}
|
||
|
||
const headers = Object.fromEntries(
|
||
Object.entries(requestState.headers).filter(([key, value]) => key && value)
|
||
);
|
||
|
||
let jsCode = `const response = await fetch("${url}", {\n method: "${method}"`;
|
||
|
||
if (Object.keys(headers).length > 0) {
|
||
jsCode += `,\n headers: ${JSON.stringify(headers, null, 4).replace(/^/gm, ' ')}`;
|
||
}
|
||
|
||
if (['POST', 'PUT', 'PATCH'].includes(method) && requestState.body !== '{}') {
|
||
jsCode += `,\n body: ${requestState.body}`;
|
||
}
|
||
|
||
jsCode += `\n});\n\nconst data = await response.json();\nconsole.log(data);`;
|
||
|
||
return jsCode;
|
||
}, [operation, path, method, spec, requestState]);
|
||
|
||
const generatePythonCode = useCallback(() => {
|
||
const baseUrl = spec.servers[0]?.url || '';
|
||
let url = baseUrl + path;
|
||
|
||
// Replace path parameters
|
||
const pathParams = operation.parameters?.filter(p => p.in === 'path') || [];
|
||
pathParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
const stringValue = typeof value === 'string' || typeof value === 'number' ? String(value) : `{${param.name}}`;
|
||
url = url.replace(`{${param.name}}`, stringValue);
|
||
});
|
||
|
||
// Add query parameters
|
||
const queryParams = operation.parameters?.filter(p => p.in === 'query') || [];
|
||
const searchParams = new URLSearchParams();
|
||
queryParams.forEach(param => {
|
||
const value = requestState.parameters[param.name];
|
||
if (value !== undefined && value !== '') {
|
||
searchParams.append(param.name, String(value));
|
||
}
|
||
});
|
||
if (searchParams.toString()) {
|
||
url += '?' + searchParams.toString();
|
||
}
|
||
|
||
const headers = Object.fromEntries(
|
||
Object.entries(requestState.headers).filter(([key, value]) => key && value)
|
||
);
|
||
|
||
let pythonCode = `import requests\nimport json\n\n`;
|
||
pythonCode += `url = "${url}"\n`;
|
||
|
||
if (Object.keys(headers).length > 0) {
|
||
pythonCode += `headers = ${JSON.stringify(headers, null, 2).replace(/"/g, "'")}\n`;
|
||
}
|
||
|
||
if (['POST', 'PUT', 'PATCH'].includes(method) && requestState.body !== '{}') {
|
||
pythonCode += `data = ${requestState.body}\n\n`;
|
||
pythonCode += `response = requests.${method.toLowerCase()}(url${Object.keys(headers).length > 0 ? ', headers=headers' : ''}${requestState.body !== '{}' ? ', json=data' : ''})\n`;
|
||
} else {
|
||
pythonCode += `\nresponse = requests.${method.toLowerCase()}(url${Object.keys(headers).length > 0 ? ', headers=headers' : ''})\n`;
|
||
}
|
||
|
||
pythonCode += `print(response.json())`;
|
||
|
||
return pythonCode;
|
||
}, [operation, path, method, spec, requestState]);
|
||
|
||
const getCodeExample = () => {
|
||
switch (activeCodeTab) {
|
||
case 'curl': {
|
||
return generateCurlCommand();
|
||
}
|
||
case 'javascript': {
|
||
return generateJavaScriptCode();
|
||
}
|
||
case 'python': {
|
||
return generatePythonCode();
|
||
}
|
||
default: {
|
||
return generateCurlCommand();
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="pb-8">
|
||
{/* Header Section */}
|
||
<div className="mb-8 border-b border-fd-border pb-8">
|
||
<div className="flex items-start justify-between gap-8">
|
||
<div className="flex-1 min-w-0">
|
||
{/* Method Badge and Title Row */}
|
||
<div className="flex items-center gap-4 mb-6">
|
||
<span className={`inline-flex items-center justify-center px-3 py-1 rounded-md bg-gradient-to-r ${methodColorClass} font-mono font-bold text-sm tracking-wider leading-none min-w-[70px] h-7`}>
|
||
{method}
|
||
</span>
|
||
<div className="h-8 w-px bg-fd-border"></div>
|
||
<div className="text-2xl font-bold text-fd-foreground leading-none">
|
||
{operation.summary || 'API Endpoint'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Endpoint Path */}
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className="text-xs text-fd-muted-foreground font-semibold uppercase tracking-wider leading-none">
|
||
ENDPOINT
|
||
</span>
|
||
<code className="text-fd-foreground font-mono text-sm bg-fd-muted px-3 py-2 rounded-md border leading-none font-medium">
|
||
{path}
|
||
</code>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
{description && (
|
||
<div className="mt-6">
|
||
<p className="text-fd-muted-foreground text-base leading-relaxed">
|
||
{description}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Try It Button */}
|
||
<div className="flex-shrink-0">
|
||
<Button
|
||
onClick={onExecute}
|
||
disabled={requestState.response.loading}
|
||
className="w-[140px] py-3 bg-fd-primary text-fd-primary-foreground font-semibold rounded-lg border-0 shadow-sm hover:shadow-md transition-all duration-200 flex items-center justify-center"
|
||
>
|
||
{requestState.response.loading ? (
|
||
<>
|
||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2" />
|
||
Sending...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Play className="w-4 h-4 mr-2" />
|
||
Try it out
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content - Stacked Layout */}
|
||
<div className="space-y-8">
|
||
{/* Request Panel */}
|
||
<div className="bg-fd-card border border-fd-border rounded-lg">
|
||
<div className="px-6 py-4 border-b border-fd-border bg-fd-muted/30">
|
||
<div className="flex items-center gap-2">
|
||
<Send className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||
<div className="font-semibold text-fd-foreground text-base leading-none">Request</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-6">
|
||
{/* Parameters */}
|
||
{operation.parameters && operation.parameters.length > 0 && (
|
||
<ParametersSection
|
||
parameters={operation.parameters}
|
||
values={requestState.parameters}
|
||
onChange={(params) => setRequestState(prev => ({ ...prev, parameters: params }))}
|
||
/>
|
||
)}
|
||
|
||
{/* Request Body */}
|
||
{operation.requestBody && (
|
||
<RequestBodySection
|
||
requestBody={operation.requestBody}
|
||
value={requestState.body}
|
||
onChange={(body) => setRequestState(prev => ({ ...prev, body }))}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Response Panel */}
|
||
<ResponsePanel response={requestState.response} />
|
||
|
||
{/* Code Examples */}
|
||
<div className="bg-fd-card border border-fd-border rounded-lg">
|
||
<div className="px-6 py-4 border-b border-fd-border bg-fd-muted/30">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Code className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||
<div className="font-semibold text-fd-foreground text-base leading-none">Code Examples</div>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
handleCopy(getCodeExample())
|
||
.catch(error => {
|
||
console.error('Failed to copy code example', error);
|
||
});
|
||
}}
|
||
>
|
||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Language Tabs */}
|
||
<div className="flex border-b border-fd-border">
|
||
{[
|
||
{ id: 'curl', label: 'cURL' },
|
||
{ id: 'javascript', label: 'JavaScript' },
|
||
{ id: 'python', label: 'Python' },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveCodeTab(tab.id as 'curl' | 'javascript' | 'python')}
|
||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors leading-none ${
|
||
activeCodeTab === tab.id
|
||
? 'border-fd-primary text-fd-primary bg-fd-primary/5'
|
||
: 'border-transparent text-fd-muted-foreground hover:text-fd-foreground'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Code Content */}
|
||
<div className="p-6">
|
||
<pre className="bg-fd-muted rounded-lg p-4 text-sm font-mono overflow-x-auto whitespace-pre-wrap break-words m-0">
|
||
<code className="text-fd-foreground leading-relaxed">{getCodeExample()}</code>
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Parameters Section - Clean design
|
||
function ParametersSection({
|
||
parameters,
|
||
values,
|
||
onChange,
|
||
}: {
|
||
parameters: OpenAPIParameter[],
|
||
values: Record<string, unknown>,
|
||
onChange: (values: Record<string, unknown>) => void,
|
||
}) {
|
||
const groupedParams = parameters.reduce((acc, param) => {
|
||
if (!(param.in in acc)) acc[param.in] = [];
|
||
acc[param.in].push(param);
|
||
return acc;
|
||
}, {} as Record<string, OpenAPIParameter[]>);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="font-semibold text-fd-foreground flex items-center gap-2 leading-none">
|
||
<Settings className="w-4 h-4" />
|
||
Parameters
|
||
</div>
|
||
{Object.entries(groupedParams).map(([type, params]) => (
|
||
<div key={type} className="space-y-3">
|
||
<div className="text-xs font-medium text-fd-muted-foreground uppercase tracking-wider leading-none">
|
||
{type} Parameters
|
||
</div>
|
||
<div className="space-y-4">
|
||
{params.map((param) => (
|
||
<div key={param.name}>
|
||
{/* Single line with all info */}
|
||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||
<span className="text-sm font-semibold text-fd-foreground leading-none">
|
||
{param.name}
|
||
</span>
|
||
{param.schema.type && (
|
||
<span className="text-xs bg-fd-muted text-fd-muted-foreground px-2 py-0.5 rounded font-mono leading-none">
|
||
{param.schema.type}
|
||
</span>
|
||
)}
|
||
{param.required && (
|
||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded dark:bg-red-900/30 dark:text-red-300 leading-none">
|
||
required
|
||
</span>
|
||
)}
|
||
{param.description && (
|
||
<>
|
||
<span className="text-fd-muted-foreground">-</span>
|
||
<span className="text-xs text-fd-muted-foreground leading-relaxed">
|
||
{param.description}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Input Field */}
|
||
<input
|
||
type={param.schema.type === 'number' ? 'number' : 'text'}
|
||
placeholder={param.example ? String(param.example) : `Enter ${param.name}`}
|
||
value={String(values[param.name] || '')}
|
||
onChange={(e) => onChange({ ...values, [param.name]: e.target.value })}
|
||
className="w-full px-3 py-2 border border-fd-border rounded-md bg-fd-background text-fd-foreground text-sm focus:outline-none focus:ring-2 focus:ring-fd-primary focus:border-fd-primary"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Request Body Section - Clean design
|
||
function RequestBodySection({
|
||
requestBody,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
requestBody: OpenAPIOperation['requestBody'],
|
||
value: string,
|
||
onChange: (value: string) => void,
|
||
}) {
|
||
if (!requestBody) return null;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="font-semibold text-fd-foreground leading-none">Request Body</div>
|
||
<textarea
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder="Enter JSON request body"
|
||
rows={8}
|
||
className="w-full px-3 py-2 border border-fd-border rounded-lg bg-fd-background text-fd-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-fd-primary focus:border-fd-primary"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Response Panel - Clean design
|
||
function ResponsePanel({ response }: { response: RequestState['response'] }) {
|
||
if (response.loading) {
|
||
return (
|
||
<div className="bg-fd-card border border-fd-border rounded-lg">
|
||
<div className="px-6 py-4 border-b border-fd-border bg-fd-muted/30">
|
||
<div className="flex items-center gap-2">
|
||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||
<div className="font-semibold text-fd-foreground text-base leading-none">Response</div>
|
||
</div>
|
||
</div>
|
||
<div className="p-12 flex flex-col items-center justify-center">
|
||
<div className="relative mb-4">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-fd-muted"></div>
|
||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-purple-500 absolute top-0"></div>
|
||
</div>
|
||
<p className="text-fd-muted-foreground text-sm text-center leading-relaxed m-0">Sending request...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (response.error) {
|
||
return (
|
||
<div className="bg-fd-card border border-fd-border rounded-lg">
|
||
<div className="px-6 py-4 border-b border-fd-border bg-fd-muted/30">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-red-600 dark:text-red-400 text-base leading-none">❌</span>
|
||
<div className="font-semibold text-fd-foreground text-base leading-none">Error</div>
|
||
</div>
|
||
</div>
|
||
<div className="p-6">
|
||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||
<p className="text-red-800 dark:text-red-300 font-medium mb-2 leading-none">Request Failed</p>
|
||
<p className="text-red-600 dark:text-red-400 text-sm whitespace-pre-wrap break-words leading-relaxed m-0">{response.error}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!response.status) {
|
||
return (
|
||
<div className="bg-fd-card border border-fd-border rounded-lg">
|
||
<div className="px-6 py-4 border-b border-fd-border bg-fd-muted/30">
|
||
<div className="flex items-center gap-2">
|
||
<ArrowRight className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||
<div className="font-semibold text-fd-foreground text-base leading-none">Response</div>
|
||
</div>
|
||
</div>
|
||
<div className="p-12 flex flex-col items-center justify-center">
|
||
<div className="w-12 h-12 rounded-full bg-fd-muted/30 flex items-center justify-center mb-4">
|
||
<Zap className="w-6 h-6 text-fd-muted-foreground" />
|
||
</div>
|
||
<p className="text-fd-muted-foreground text-center text-sm leading-relaxed m-0">
|
||
Click "Try it out" to see the API response
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const statusColor = response.status < 300
|
||
? 'from-green-500 to-green-600'
|
||
: response.status < 400
|
||
? 'from-yellow-500 to-yellow-600'
|
||
: 'from-red-500 to-red-600';
|
||
|
||
return (
|
||
<div className="bg-fd-card border border-fd-border rounded-lg">
|
||
<div className="px-6 py-4 border-b border-fd-border bg-fd-muted/30">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-green-600 dark:text-green-400 text-base leading-none">✓</span>
|
||
<div className="font-semibold text-fd-foreground text-base leading-none">Response</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{response.duration && (
|
||
<span className="text-xs text-fd-muted-foreground bg-fd-muted px-2 py-1 rounded flex items-center leading-none">
|
||
{response.duration}ms
|
||
</span>
|
||
)}
|
||
<span className={`inline-flex items-center px-3 py-1 rounded-md bg-gradient-to-r ${statusColor} text-white font-mono font-bold text-xs leading-none`}>
|
||
{response.status}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-4">
|
||
{response.headers && Object.keys(response.headers).length > 0 && (
|
||
<div>
|
||
<div className="text-sm font-semibold text-fd-foreground mb-2 leading-none">Response Headers</div>
|
||
<div className="bg-fd-muted rounded-lg p-3 border">
|
||
<pre className="text-xs font-mono overflow-auto max-h-32 whitespace-pre-wrap break-words text-fd-foreground m-0">
|
||
{JSON.stringify(response.headers, null, 2)}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{response.data !== undefined && (
|
||
<div>
|
||
<div className="text-sm font-semibold text-fd-foreground mb-2 leading-none">Response Body</div>
|
||
<div className="bg-fd-muted rounded-lg p-3 border">
|
||
<pre className="text-sm font-mono overflow-auto max-h-96 text-fd-foreground whitespace-pre-wrap break-words m-0">
|
||
{typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2)}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|