feat(web): add reusable admin form dialog

This commit is contained in:
2026-05-09 20:15:35 +08:00
parent c0335bd5d0
commit a5e66e79cd
49 changed files with 6013 additions and 1108 deletions
+134
View File
@@ -0,0 +1,134 @@
import type { FormEvent } from 'react';
import { LogIn, UserPlus } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from './ui';
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
const tabs = [
{ value: 'login', label: '账号登录' },
{ value: 'register', label: '注册账号' },
{ value: 'external', label: '外部 Token' },
] satisfies Array<{ value: AuthMode; label: string }>;
export function AuthPanel(props: {
authMode: AuthMode;
externalToken: string;
loginForm: LoginForm;
registerForm: RegisterForm;
state: LoadState;
onAuthModeChange: (value: AuthMode) => void;
onExternalTokenChange: (value: string) => void;
onLoginChange: (value: LoginForm) => void;
onRegisterChange: (value: RegisterForm) => void;
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<section className="authShell" aria-label="登录">
<Card className="authCard">
<CardHeader>
<div>
<p className="eyebrow">Gateway Identity</p>
<CardTitle> AI Gateway</CardTitle>
</div>
</CardHeader>
<CardContent className="authContent">
<Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} />
{props.authMode === 'login' && <LoginFormView {...props} />}
{props.authMode === 'register' && <RegisterFormView {...props} />}
{props.authMode === 'external' && <ExternalTokenForm {...props} />}
</CardContent>
</Card>
</section>
);
}
function LoginFormView(props: {
loginForm: LoginForm;
state: LoadState;
onLoginChange: (value: LoginForm) => void;
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="formGrid" onSubmit={props.onSubmitLogin}>
<Label>
<Input
autoComplete="username"
value={props.loginForm.account}
onChange={(event) => props.onLoginChange({ ...props.loginForm, account: event.target.value })}
placeholder="用户名或邮箱"
/>
</Label>
<Label>
<Input
autoComplete="current-password"
type="password"
value={props.loginForm.password}
onChange={(event) => props.onLoginChange({ ...props.loginForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</Label>
<Button type="submit" disabled={props.state === 'loading'}>
<LogIn size={15} />
{props.state === 'loading' ? '登录中' : '登录'}
</Button>
</form>
);
}
function RegisterFormView(props: {
registerForm: RegisterForm;
state: LoadState;
onRegisterChange: (value: RegisterForm) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="formGrid two" onSubmit={props.onSubmitRegister}>
<Label>
<Input autoComplete="username" value={props.registerForm.username} onChange={(event) => props.onRegisterChange({ ...props.registerForm, username: event.target.value })} />
</Label>
<Label>
<Input autoComplete="email" type="email" value={props.registerForm.email} onChange={(event) => props.onRegisterChange({ ...props.registerForm, email: event.target.value })} />
</Label>
<Label>
<Input value={props.registerForm.displayName} onChange={(event) => props.onRegisterChange({ ...props.registerForm, displayName: event.target.value })} />
</Label>
<Label>
<Input autoComplete="new-password" type="password" value={props.registerForm.password} onChange={(event) => props.onRegisterChange({ ...props.registerForm, password: event.target.value })} />
</Label>
<Label>
<Input value={props.registerForm.invitationCode} onChange={(event) => props.onRegisterChange({ ...props.registerForm, invitationCode: event.target.value })} placeholder="可选" />
</Label>
<Button type="submit" disabled={props.state === 'loading'} className="spanTwo">
<UserPlus size={15} />
{props.state === 'loading' ? '注册中' : '注册并登录'}
</Button>
</form>
);
}
function ExternalTokenForm(props: {
externalToken: string;
state: LoadState;
onExternalTokenChange: (value: string) => void;
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="formGrid" onSubmit={props.onSubmitExternalToken}>
<Label>
Access Token
<Input value={props.externalToken} onChange={(event) => props.onExternalTokenChange(event.target.value)} placeholder="粘贴 server-main access token" />
</Label>
<Button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '验证中' : '进入控制台'}
</Button>
</form>
);
}
+171
View File
@@ -0,0 +1,171 @@
import type { FormEvent } from 'react';
import type { GatewayApiKey, GatewayTask } from '@easyai-ai-gateway/contracts';
import type { LoadState, TaskForm } from '../types';
const taskKindOptions = [
['chat.completions', 'Chat'],
['images.generations', '生图'],
['images.edits', '图像编辑'],
] as const;
export function CoreFlowPanel(props: {
apiKeyForm: { name: string };
apiKeys: GatewayApiKey[];
apiKeySecret: string;
coreMessage: string;
coreState: LoadState;
platformForm: { provider: string; platformKey: string; name: string; baseUrl: string };
taskForm: TaskForm;
taskResult: GatewayTask | null;
onAPIKeyFormChange: (value: { name: string }) => void;
onPlatformFormChange: (value: { provider: string; platformKey: string; name: string; baseUrl: string }) => void;
onSubmitAPIKey: (event: FormEvent<HTMLFormElement>) => void;
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
onTaskFormChange: (value: TaskForm) => void;
}) {
return (
<section className="corePanel" aria-label="核心链路验证">
<div className="sectionHeader">
<div>
<p className="eyebrow">Smoke Flow</p>
<h2></h2>
</div>
<span>{props.coreState === 'loading' ? '运行中' : '本地闭环'}</span>
</div>
<div className="coreGrid">
<ApiKeyForm {...props} />
<PlatformForm {...props} />
<TaskSmokeForm {...props} />
</div>
{props.coreMessage && (
<p className="coreMessage" data-error={props.coreState === 'error'}>
{props.coreMessage}
</p>
)}
</section>
);
}
function ApiKeyForm(props: {
apiKeyForm: { name: string };
apiKeys: GatewayApiKey[];
apiKeySecret: string;
coreState: LoadState;
onAPIKeyFormChange: (value: { name: string }) => void;
onSubmitAPIKey: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="inlineForm" onSubmit={props.onSubmitAPIKey}>
<h3>1. API Key</h3>
<label>
<span></span>
<input value={props.apiKeyForm.name} onChange={(event) => props.onAPIKeyFormChange({ name: event.target.value })} />
</label>
<button type="submit" disabled={props.coreState === 'loading'}>
API Key
</button>
<p className="formHint"> {props.apiKeys.length} Key</p>
{props.apiKeySecret && <code className="secretBox">{props.apiKeySecret}</code>}
</form>
);
}
function PlatformForm(props: {
coreState: LoadState;
platformForm: { provider: string; platformKey: string; name: string; baseUrl: string };
onPlatformFormChange: (value: { provider: string; platformKey: string; name: string; baseUrl: string }) => void;
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="inlineForm" onSubmit={props.onSubmitPlatform}>
<h3>2. </h3>
<label>
<span>Provider</span>
<input value={props.platformForm.provider} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, provider: event.target.value })} />
</label>
<label>
<span> Key</span>
<input value={props.platformForm.platformKey} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, platformKey: event.target.value })} />
</label>
<label>
<span></span>
<input value={props.platformForm.name} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, name: event.target.value })} />
</label>
<label>
<span>Base URL</span>
<input value={props.platformForm.baseUrl} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, baseUrl: event.target.value })} />
</label>
<button type="submit" disabled={props.coreState === 'loading'}>
</button>
</form>
);
}
function TaskSmokeForm(props: {
coreState: LoadState;
taskForm: TaskForm;
taskResult: GatewayTask | null;
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
onTaskFormChange: (value: TaskForm) => void;
}) {
return (
<form className="inlineForm" onSubmit={props.onSubmitTask}>
<h3>3. Phase 1 </h3>
<label>
<span></span>
<select value={props.taskForm.kind} onChange={(event) => props.onTaskFormChange(defaultTaskForKind(event.target.value as TaskForm['kind'], props.taskForm))}>
{taskKindOptions.map(([value, label]) => (
<option value={value} key={value}>
{label}
</option>
))}
</select>
</label>
<label>
<span></span>
<input value={props.taskForm.model} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, model: event.target.value })} />
</label>
<label>
<span>Prompt</span>
<input value={props.taskForm.prompt} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, prompt: event.target.value })} />
</label>
{props.taskForm.kind === 'images.edits' && (
<>
<label>
<span> URL</span>
<input value={props.taskForm.image ?? ''} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, image: event.target.value })} />
</label>
<label>
<span>Mask URL</span>
<input value={props.taskForm.mask ?? ''} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, mask: event.target.value })} />
</label>
</>
)}
<button type="submit" disabled={props.coreState === 'loading'}>
</button>
{props.taskResult && (
<div className="resultBox">
<div>
<span className="statusPill">{props.taskResult.status}</span>
<strong>{props.taskResult.model}</strong>
</div>
<pre>{JSON.stringify(props.taskResult.result ?? {}, null, 2)}</pre>
</div>
)}
</form>
);
}
function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm {
if (kind === 'chat.completions') {
return { ...current, kind, model: 'gpt-4o-mini' };
}
if (kind === 'images.edits') {
return { ...current, kind, model: 'gpt-image-1', image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png' };
}
return { ...current, kind, model: 'gpt-image-1' };
}
+96
View File
@@ -0,0 +1,96 @@
import type { BaseModelCatalogItem, IntegrationPlatform, PlatformModel, RateLimitWindow } from '@easyai-ai-gateway/contracts';
import { adminPages, apiDocPages, primaryModules, workspacePages } from '../navigation';
import { DataPanel } from './DataPanel';
import { ModuleList } from './ModuleList';
export function Dashboard(props: {
baseModels: BaseModelCatalogItem[];
models: PlatformModel[];
platforms: IntegrationPlatform[];
rateLimitWindows: RateLimitWindow[];
stats: Array<{ label: string; value: number; tone: string }>;
}) {
return (
<>
<section className="moduleBand" aria-label="一级页面">
<div className="sectionHeader">
<div>
<p className="eyebrow">Navigation</p>
<h2></h2>
</div>
<span>5 </span>
</div>
<div className="moduleGrid">
{primaryModules.map((item) => (
<article className="moduleCard" key={item.path}>
<div className="moduleCardTop">
<h3>{item.title}</h3>
<span>{item.path}</span>
</div>
<p>{item.description}</p>
<div className="moduleTags">
{item.items.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</article>
))}
</div>
</section>
<section className="moduleBand" aria-label="工作台与文档">
<div className="sectionHeader">
<div>
<p className="eyebrow">Workspace</p>
<h2> API </h2>
</div>
<span></span>
</div>
<div className="detailGrid">
<ModuleList title="用户工作台" items={workspacePages} />
<ModuleList title="管理工作台" items={adminPages} />
<ModuleList title="API 文档" items={apiDocPages} />
</div>
</section>
<section className="metrics" aria-label="概览">
{props.stats.map((item) => (
<div className="metric" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</section>
<section className="split">
<DataPanel
columns={['Provider', '名称', '状态', '优先级']}
empty="暂无平台数据"
rows={props.platforms.map((item) => [item.provider, item.name, item.status, String(item.priority)])}
title="平台"
/>
<DataPanel
columns={['模型', '类型', '平台', '启用']}
empty="暂无模型数据"
rows={props.models.map((item) => [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])}
title="模型"
/>
</section>
<section className="split secondary">
<DataPanel
columns={['Provider', '模型', '类型', '版本']}
empty="暂无基准模型"
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])}
title="基准模型库"
/>
<DataPanel
columns={['Scope', '指标', '使用', '预占']}
empty="暂无限流窗口"
rows={props.rateLimitWindows.map((item) => [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])}
title="TPM/RPM 窗口"
/>
</section>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
export function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) {
return (
<div className="panel">
<div className="panelHeader">
<h2>{props.title}</h2>
<span>{props.rows.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
{props.columns.map((column) => (
<span key={column}>{column}</span>
))}
</div>
{props.rows.map((row, index) => (
<div className="row" role="row" key={`${props.title}-${index}`}>
{row.map((cell, cellIndex) => (
<span key={`${props.title}-${index}-${cellIndex}`}>{cell}</span>
))}
</div>
))}
{!props.rows.length && <p className="empty">{props.empty}</p>}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { EmptyState, Table, TableCell, TableHead, TableRow } from './ui';
export function EntityTable(props: {
columns: string[];
empty: string;
rows: Array<Array<string | number>>;
}) {
return (
<Table>
<TableRow className="shTableHeader">
{props.columns.map((column) => (
<TableHead key={column}>{column}</TableHead>
))}
</TableRow>
{props.rows.map((row, index) => (
<TableRow key={index}>
{row.map((cell, cellIndex) => (
<TableCell key={`${index}-${cellIndex}`}>{cell}</TableCell>
))}
</TableRow>
))}
{!props.rows.length && <EmptyState title={props.empty} />}
</Table>
);
}
@@ -0,0 +1,14 @@
import { AuthPanel } from './AuthPanel';
export function LoginRequiredPanel(props: Parameters<typeof AuthPanel>[0]) {
return (
<div className="loginRequiredPage">
<div className="loginRequiredCopy">
<p className="eyebrow">Identity</p>
<h1></h1>
<p>API Key server-main token</p>
</div>
<AuthPanel {...props} />
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
export function ModuleList(props: {
title: string;
items: Array<{ title: string; path: string; description: string }>;
}) {
return (
<div className="moduleList">
<h3>{props.title}</h3>
{props.items.map((item) => (
<div className="moduleRow" key={item.path}>
<div>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<span>{item.path}</span>
</div>
))}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
export function PageHeader(props: { eyebrow: string; title: string; description?: string; action?: ReactNode }) {
return (
<div className="pageHeader">
<div>
<p className="eyebrow">{props.eyebrow}</p>
<h1>{props.title}</h1>
{props.description && <p>{props.description}</p>}
</div>
{props.action && <div className="pageHeaderAction">{props.action}</div>}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { StatItem } from '../app-state';
export function StatGrid(props: { items: StatItem[] }) {
return (
<section className="statGrid" aria-label="统计">
{props.items.map((item) => (
<div className="statCard" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</section>
);
}
@@ -0,0 +1,82 @@
import type { ReactNode } from 'react';
import { BookOpen, Boxes, Home, RefreshCw, ShieldCheck, UserCircle } from 'lucide-react';
import type { HealthResponse } from '../../api';
import type { LoadState, PageKey } from '../../types';
import { Button, Badge } from '../ui';
const navItems: Array<{ key: PageKey; label: string; icon: ReactNode }> = [
{ key: 'home', label: '首页', icon: <Home size={17} /> },
{ key: 'models', label: '模型', icon: <Boxes size={17} /> },
{ key: 'workspace', label: '用户工作台', icon: <UserCircle size={17} /> },
{ key: 'admin', label: '管理工作台', icon: <ShieldCheck size={17} /> },
{ key: 'docs', label: 'API 文档', icon: <BookOpen size={17} /> },
];
export function AppShell(props: {
activePage: PageKey;
children: ReactNode;
health: HealthResponse | null;
isAuthenticated: boolean;
state: LoadState;
onNavigate: (page: PageKey) => void;
onLogin: () => void;
onRefresh: () => void;
onSignOut: () => void;
}) {
return (
<div className="appShell">
<header className="appTopbar">
<div className="brandBlock">
<div className="brandMark">AI</div>
<div>
<strong>EasyAI Gateway</strong>
<span>Console</span>
</div>
</div>
<nav className="topNav" aria-label="主导航">
{navItems.map((item) => (
<button
type="button"
className="topNavItem"
data-active={props.activePage === item.key}
key={item.key}
onClick={() => props.onNavigate(item.key)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</nav>
<div className="topbarActions">
<div className="health" data-ok={props.health?.ok === true}>
<span />
{props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'}
</div>
{props.isAuthenticated ? (
<>
<Button type="button" variant="outline" size="sm" onClick={props.onRefresh} disabled={props.state === 'loading'}>
<RefreshCw size={15} />
{props.state === 'loading' ? '刷新中' : '刷新'}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={props.onSignOut}>
退
</Button>
</>
) : (
<Button type="button" size="sm" onClick={props.onLogin}>
</Button>
)}
</div>
</header>
<div className="workspaceShell">
<main className="contentShell">
{props.state === 'error' && <Badge variant="destructive"></Badge>}
{props.children}
</main>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const badgeVariants = cva('shBadge', {
variants: {
variant: {
default: 'shBadgeDefault',
secondary: 'shBadgeSecondary',
outline: 'shBadgeOutline',
success: 'shBadgeSuccess',
warning: 'shBadgeWarning',
destructive: 'shBadgeDestructive',
},
},
defaultVariants: {
variant: 'default',
},
});
export function Badge(props: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
const { className, variant, ...rest } = props;
return <div className={cn(badgeVariants({ variant, className }))} {...rest} />;
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva('shButton', {
variants: {
variant: {
default: 'shButtonDefault',
secondary: 'shButtonSecondary',
outline: 'shButtonOutline',
ghost: 'shButtonGhost',
destructive: 'shButtonDestructive',
},
size: {
default: 'shButtonDefaultSize',
sm: 'shButtonSm',
icon: 'shButtonIcon',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('shCard', className)} {...props} />,
);
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('shCardHeader', className)} {...props} />,
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => <h3 ref={ref} className={cn('shCardTitle', className)} {...props} />,
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => <p ref={ref} className={cn('shCardDescription', className)} {...props} />,
);
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('shCardContent', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('shCardFooter', className)} {...props} />,
);
CardFooter.displayName = 'CardFooter';
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
import { Button } from './button';
export interface FormDialogProps {
ariaLabel?: string;
bodyClassName?: string;
children: React.ReactNode;
className?: string;
closeLabel?: string;
eyebrow?: string;
footer: React.ReactNode;
formClassName?: string;
open: boolean;
title: string;
onClose: () => void;
onSubmit: React.FormEventHandler<HTMLFormElement>;
}
export function FormDialog(props: FormDialogProps) {
const { onClose, open } = props;
React.useEffect(() => {
if (!open) return undefined;
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose, open]);
if (!open) return null;
const closeLabel = props.closeLabel ?? '关闭';
return (
<div className="formDialogBackdrop" role="presentation">
<div className={cn('formDialog', props.className)} role="dialog" aria-modal="true" aria-label={props.ariaLabel ?? props.title}>
<header className="formDialogHeader">
<div>
{props.eyebrow && <span>{props.eyebrow}</span>}
<strong>{props.title}</strong>
</div>
<Button type="button" variant="ghost" size="sm" onClick={props.onClose}>{closeLabel}</Button>
</header>
<form className={cn('formDialogForm', props.formClassName)} onSubmit={props.onSubmit}>
<div className={cn('formDialogBody', props.bodyClassName)}>{props.children}</div>
<div className="formDialogActions">{props.footer}</div>
</form>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export * from './badge';
export * from './button';
export * from './card';
export * from './dialog';
export * from './input';
export * from './label';
export * from './select';
export * from './separator';
export * from './table';
export * from './tabs';
export * from './textarea';
+8
View File
@@ -0,0 +1,8 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => <input ref={ref} className={cn('shInput', className)} {...props} />,
);
Input.displayName = 'Input';
+8
View File
@@ -0,0 +1,8 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => <label ref={ref} className={cn('shLabel', className)} {...props} />,
);
Label.displayName = 'Label';
+8
View File
@@ -0,0 +1,8 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
({ className, ...props }, ref) => <select ref={ref} className={cn('shInput', className)} {...props} />,
);
Select.displayName = 'Select';
+7
View File
@@ -0,0 +1,7 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export function Separator(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shSeparator', className)} {...rest} />;
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export function Table(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTable', className)} role="table" {...rest} />;
}
export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTableRow', className)} role="row" {...rest} />;
}
export function TableHead(props: React.HTMLAttributes<HTMLSpanElement>) {
const { className, ...rest } = props;
return <span className={cn('shTableHead', className)} role="columnheader" {...rest} />;
}
export function TableCell(props: React.HTMLAttributes<HTMLSpanElement>) {
const { className, ...rest } = props;
return <span className={cn('shTableCell', className)} role="cell" {...rest} />;
}
export function EmptyState(props: { title: string; description?: string }) {
return (
<div className="emptyState">
<strong>{props.title}</strong>
{props.description && <span>{props.description}</span>}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export function Tabs<T extends string>(props: {
value: T;
tabs: Array<{ value: T; label: string; icon?: React.ReactNode }>;
onValueChange: (value: T) => void;
className?: string;
}) {
return (
<div className={cn('shTabs', props.className)} role="tablist">
{props.tabs.map((tab) => (
<button
type="button"
className="shTab"
data-active={props.value === tab.value}
key={tab.value}
onClick={() => props.onValueChange(tab.value)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => <textarea ref={ref} className={cn('shTextarea', className)} {...props} />,
);
Textarea.displayName = 'Textarea';