merge: 合并 main 分支的 i18n 重构到 login-page 功能分支
解决冲突: - i18n.tsx: 采用 main 的独立文件架构(en.ts/zh.ts),新增 OAuth/branding 翻译 key - Login.tsx: 保留左右分栏布局,合入 main 的验证消息 i18n 化 - Register.tsx: 同上,合入 main 的 placeholder i18n 化 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { useI18n, tk } from '../lib/i18n';
|
||||
|
||||
function Logo({ className }: { className: string }) {
|
||||
return (
|
||||
@@ -54,7 +54,7 @@ export default function AuthBranding() {
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white/90 text-[15px] leading-snug">{t(key)}</span>
|
||||
<span className="text-white/90 text-[15px] leading-snug">{t(tk(key))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Modal from './Modal';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
@@ -10,7 +11,8 @@ type ConfirmDialogProps = {
|
||||
variant?: 'danger' | 'warning';
|
||||
};
|
||||
|
||||
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText = 'Confirm', variant = 'danger' }: ConfirmDialogProps) {
|
||||
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText, variant = 'danger' }: ConfirmDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
|
||||
|
||||
return (
|
||||
@@ -28,12 +30,12 @@ export default function ConfirmDialog({ open, onConfirm, onCancel, title, descri
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2.5 pt-1">
|
||||
<button onClick={onCancel} className="btn-ghost">Cancel</button>
|
||||
<button onClick={onCancel} className="btn-ghost">{t('common.cancel')}</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||
>
|
||||
{confirmText}
|
||||
{confirmText ?? t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Replaces raw JSON.stringify output with readable tables and schema trees.
|
||||
*/
|
||||
|
||||
import { useI18n, type TFunction } from '../lib/i18n';
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
|
||||
type SchemaObj = {
|
||||
@@ -79,21 +81,22 @@ function InBadge({ location }: { location: string }) {
|
||||
/* ===== Parameters Table ===== */
|
||||
|
||||
export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
const { t } = useI18n();
|
||||
if (!Array.isArray(parameters) || parameters.length === 0) return null;
|
||||
const params = parameters as Parameter[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">Parameters</p>
|
||||
<p className="section-label mb-2">{t('dashboard.schema.parameters')}</p>
|
||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="bg-bg-tertiary/50 text-text-muted text-[11px] uppercase tracking-wider">
|
||||
<th className="text-left px-3 py-2 font-medium">Name</th>
|
||||
<th className="text-left px-3 py-2 font-medium">In</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Type</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Required</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Description</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.name')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.in')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.type')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.required')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.descriptionCol')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-muted">
|
||||
@@ -119,9 +122,9 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{p.required ? (
|
||||
<span className="text-[11px] font-medium text-danger">required</span>
|
||||
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-muted">optional</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.optional')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-text-secondary max-w-xs">
|
||||
@@ -129,7 +132,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
{p.description && <span>{p.description}</span>}
|
||||
{enumVals && enumVals.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted">enum:</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
||||
{enumVals.map((v, j) => (
|
||||
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||
{String(v)}
|
||||
@@ -139,7 +142,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
)}
|
||||
{p.schema?.default !== undefined && (
|
||||
<div className="mt-0.5 text-[11px] text-text-muted">
|
||||
default: <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
||||
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -156,7 +159,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
|
||||
/* ===== Schema Properties Tree ===== */
|
||||
|
||||
function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: number }) {
|
||||
function SchemaProperties({ schema, depth = 0, t }: { schema: SchemaObj; depth?: number; t: TFunction }) {
|
||||
const properties = schema.properties;
|
||||
const requiredSet = new Set(schema.required || []);
|
||||
|
||||
@@ -189,10 +192,10 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
<span className="text-[11px] text-text-muted">({prop.format})</span>
|
||||
)}
|
||||
{requiredSet.has(name) && (
|
||||
<span className="text-[11px] font-medium text-danger">required</span>
|
||||
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
||||
)}
|
||||
{prop.nullable && (
|
||||
<span className="text-[11px] text-text-muted">nullable</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.nullable')}</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<span className="text-text-secondary text-[12px] leading-snug">{prop.description}</span>
|
||||
@@ -200,7 +203,7 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
</div>
|
||||
{prop.enum && prop.enum.length > 0 && (
|
||||
<div className="ml-0 mt-0.5 flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted">enum:</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
||||
{prop.enum.map((v, j) => (
|
||||
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||
{String(v)}
|
||||
@@ -210,11 +213,11 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
)}
|
||||
{prop.default !== undefined && (
|
||||
<div className="text-[11px] text-text-muted mt-0.5">
|
||||
default: <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} />}
|
||||
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} />}
|
||||
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} t={t} />}
|
||||
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} t={t} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -225,6 +228,7 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
/* ===== Request Body ===== */
|
||||
|
||||
export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
const { t } = useI18n();
|
||||
if (!requestBody || typeof requestBody !== 'object') return null;
|
||||
const body = requestBody as {
|
||||
required?: boolean;
|
||||
@@ -238,11 +242,11 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">
|
||||
Request Body
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">required</span>}
|
||||
{t('dashboard.schema.requestBody')}
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||
</p>
|
||||
<div className="border border-border-default rounded-lg p-3">
|
||||
<SchemaProperties schema={body.schema} />
|
||||
<SchemaProperties schema={body.schema} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -255,8 +259,8 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">
|
||||
Request Body
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">required</span>}
|
||||
{t('dashboard.schema.requestBody')}
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||
</p>
|
||||
{body.description && (
|
||||
<p className="text-[13px] text-text-secondary mb-2">{body.description}</p>
|
||||
@@ -269,7 +273,7 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
<div className="p-3">
|
||||
{media.schema ? (
|
||||
media.schema.properties ? (
|
||||
<SchemaProperties schema={media.schema} />
|
||||
<SchemaProperties schema={media.schema} t={t} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<TypeBadge type={resolveType(media.schema)} />
|
||||
@@ -277,7 +281,7 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[13px] text-text-muted">No schema</span>
|
||||
<span className="text-[13px] text-text-muted">{t('dashboard.schema.noSchema')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,13 +307,14 @@ function StatusBadge({ code }: { code: string }) {
|
||||
}
|
||||
|
||||
export function ResponsesView({ responses }: { responses: unknown }) {
|
||||
const { t } = useI18n();
|
||||
if (!responses || typeof responses !== 'object') return null;
|
||||
const entries = Object.entries(responses as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">Responses</p>
|
||||
<p className="section-label mb-2">{t('dashboard.schema.responses')}</p>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([code, resp]) => {
|
||||
const response = resp as {
|
||||
@@ -345,13 +350,13 @@ export function ResponsesView({ responses }: { responses: unknown }) {
|
||||
{schema && (schema.properties || schema.items?.properties || schema.type) && (
|
||||
<div className="p-3">
|
||||
{schema.properties ? (
|
||||
<SchemaProperties schema={schema} />
|
||||
<SchemaProperties schema={schema} t={t} />
|
||||
) : schema.type === 'array' && schema.items?.properties ? (
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted mb-1">
|
||||
<TypeBadge type="array" /> of objects:
|
||||
<TypeBadge type="array" /> {t('dashboard.schema.ofObjects')}
|
||||
</div>
|
||||
<SchemaProperties schema={schema.items} />
|
||||
<SchemaProperties schema={schema.items} t={t} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
@@ -8,6 +9,7 @@ type ApiKeyStatus = { hasKey: boolean; prefix: string | null };
|
||||
|
||||
export default function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -29,8 +31,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
queryFn: () => apiFetch<ApiKeyStatus>('/auth/api-key/status'),
|
||||
enabled: open,
|
||||
});
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null); // just generated/rotated
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null); // revealed via password
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [keyLoading, setKeyLoading] = useState(false);
|
||||
const [keyError, setKeyError] = useState('');
|
||||
const [keyCopied, setKeyCopied] = useState(false);
|
||||
@@ -74,7 +76,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
method: 'PUT', body: JSON.stringify({ name }),
|
||||
});
|
||||
updateUser({ name: data.name });
|
||||
setProfileMsg({ type: 'success', text: 'Profile updated' });
|
||||
setProfileMsg({ type: 'success', text: t('dashboard.settings.profileUpdated') });
|
||||
setTimeout(() => setProfileMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' });
|
||||
@@ -85,7 +87,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ type: 'error', text: 'Passwords do not match' });
|
||||
setPasswordMsg({ type: 'error', text: t('dashboard.settings.passwordMismatch') });
|
||||
return;
|
||||
}
|
||||
setPasswordLoading(true);
|
||||
@@ -94,7 +96,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
await apiFetch('/auth/change-password', {
|
||||
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
setPasswordMsg({ type: 'success', text: 'Password changed successfully' });
|
||||
setPasswordMsg({ type: 'success', text: t('dashboard.settings.passwordChanged') });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
@@ -181,7 +183,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
className="max-w-[560px] w-[90vw] rounded-2xl bg-bg-elevated border border-border-default shadow-lg p-0 backdrop:bg-overlay backdrop:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border-muted flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-text-primary">Settings</h2>
|
||||
<h2 className="text-base font-semibold text-text-primary">{t('dashboard.settings.title')}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -192,8 +194,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<div className="px-6 py-5 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
{/* Profile */}
|
||||
<section>
|
||||
<p className="section-title">Profile</p>
|
||||
<p className="section-desc mb-4">Manage your personal information.</p>
|
||||
<p className="section-title">{t('dashboard.settings.profileTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.settings.profileDesc')}</p>
|
||||
<div className="flex items-center gap-3.5 mb-5">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-sm font-bold tracking-wide">{initials}</div>
|
||||
<div>
|
||||
@@ -203,7 +205,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Display Name</label>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.displayName')}</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
|
||||
</div>
|
||||
{profileMsg && (
|
||||
@@ -215,15 +217,15 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
|
||||
{profileLoading ? 'Saving...' : 'Save Profile'}
|
||||
{profileLoading ? t('dashboard.settings.saving') : t('dashboard.settings.saveProfile')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key */}
|
||||
<section className="border-t border-border-default pt-5">
|
||||
<p className="section-title">API Key</p>
|
||||
<p className="section-desc mb-4">Used to authenticate all MCP requests across your projects.</p>
|
||||
<p className="section-title">{t('dashboard.settings.apiKeyTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.settings.apiKeyDesc')}</p>
|
||||
|
||||
{/* Fresh key display (just generated or rotated) */}
|
||||
{freshKey ? (
|
||||
@@ -231,19 +233,19 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] font-medium text-warning">Save this key now — you won't be able to see it again.</p>
|
||||
<p className="text-[13px] font-medium text-warning">{t('dashboard.settings.keySaveWarning')}</p>
|
||||
</div>
|
||||
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2.5">{freshKey}</code>
|
||||
<button onClick={copyFreshKey} className="btn-outline w-full text-[13px]">
|
||||
{keyCopied ? (
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Copied</>
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('common.copied')}</>
|
||||
) : (
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> Copy to Clipboard</>
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {t('dashboard.settings.copyToClipboard')}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setFreshKey(null)} className="text-[13px] text-text-muted hover:text-text-secondary transition-colors">
|
||||
I've saved it, continue
|
||||
{t('dashboard.settings.keySaved')}
|
||||
</button>
|
||||
</div>
|
||||
) : !keyStatus?.hasKey ? (
|
||||
@@ -251,13 +253,13 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 px-3.5 py-3 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary">No API key generated yet. Generate one to use MCP services.</p>
|
||||
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.noKey')}</p>
|
||||
</div>
|
||||
<button onClick={handleGenerateKey} disabled={keyLoading} className="btn-primary">
|
||||
{keyLoading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Generating...</>
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> {t('dashboard.settings.generating')}</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> Generate API Key</>
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> {t('dashboard.settings.generateKey')}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +277,6 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
else { setShowPasswordPrompt('reveal'); setVerifyPassword(''); setVerifyError(''); }
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
title={revealedKey ? 'Hide' : 'Reveal'}
|
||||
>
|
||||
{revealedKey ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
|
||||
@@ -297,7 +298,6 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
}
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
title="Copy"
|
||||
>
|
||||
{keyCopied ? (
|
||||
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||
@@ -310,22 +310,28 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
{/* Password prompt inline */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||
<p className="text-[13px] text-text-secondary">Enter your password to {showPasswordPrompt === 'copy' ? 'copy' : 'reveal'} the API key.</p>
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
{t('dashboard.settings.passwordPrompt', {
|
||||
action: showPasswordPrompt === 'copy'
|
||||
? t('dashboard.settings.passwordPromptCopy')
|
||||
: t('dashboard.settings.passwordPromptReveal'),
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
|
||||
className="input-base"
|
||||
placeholder="Current password"
|
||||
placeholder={t('dashboard.settings.currentPassword')}
|
||||
autoFocus
|
||||
/>
|
||||
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
|
||||
{verifyLoading ? 'Verifying...' : 'Confirm'}
|
||||
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
|
||||
</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">Cancel</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -338,7 +344,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rotate API Key
|
||||
{t('dashboard.settings.rotateKey')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -353,20 +359,20 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
|
||||
{/* Password */}
|
||||
<section className="border-t border-border-default pt-5">
|
||||
<p className="section-title">Change Password</p>
|
||||
<p className="section-desc mb-4">Update your password to keep your account secure.</p>
|
||||
<p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" />
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.currentPasswordLabel')}</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">New Password</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} />
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" />
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
|
||||
</div>
|
||||
{passwordMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
@@ -381,7 +387,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
|
||||
className="btn-primary"
|
||||
>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
{passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -392,9 +398,9 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
open={showRotateConfirm}
|
||||
onCancel={() => setShowRotateConfirm(false)}
|
||||
onConfirm={handleRotateKey}
|
||||
title="Rotate API Key"
|
||||
description="The current API key will be invalidated immediately. All MCP clients using the old key will stop working. A new key will be generated."
|
||||
confirmText="Rotate Key"
|
||||
title={t('dashboard.settings.rotateTitle')}
|
||||
description={t('dashboard.settings.rotateDesc')}
|
||||
confirmText={t('dashboard.settings.rotateConfirm')}
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useI18n, type TranslationKey } from '../lib/i18n';
|
||||
|
||||
const icons = {
|
||||
light: (
|
||||
@@ -18,26 +19,26 @@ const icons = {
|
||||
),
|
||||
};
|
||||
|
||||
const labels = { light: '浅色', dark: '深色', system: '跟随系统' } as const;
|
||||
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-bg-tertiary">
|
||||
{order.map((t) => (
|
||||
{order.map((key) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTheme(t)}
|
||||
title={labels[t]}
|
||||
key={key}
|
||||
onClick={() => setTheme(key)}
|
||||
title={t(`theme.${key}` as TranslationKey)}
|
||||
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
|
||||
theme === t
|
||||
theme === key
|
||||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{icons[t]}
|
||||
{icons[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,379 +1,23 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||
import en from './i18n/en';
|
||||
import zh from './i18n/zh';
|
||||
|
||||
export type Locale = 'en' | 'zh';
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
type Translations = Record<string, string>;
|
||||
type AllTranslations = Record<Locale, Translations>;
|
||||
type AllTranslations = Record<Locale, Record<TranslationKey, string>>;
|
||||
|
||||
const translations: AllTranslations = {
|
||||
en: {
|
||||
// Nav
|
||||
'nav.features': 'Features',
|
||||
'nav.tools': 'Tools',
|
||||
'nav.testimonials': 'Testimonials',
|
||||
'nav.pricing': 'Pricing',
|
||||
'nav.faq': 'FAQ',
|
||||
'nav.signIn': 'Sign In',
|
||||
'nav.getStarted': 'Get Started',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
const translations: AllTranslations = { en, zh };
|
||||
|
||||
// Auth - Branding
|
||||
'auth.productName': 'AgentFox',
|
||||
'auth.slogan': 'API Docs for LLMs, Done Right',
|
||||
'auth.feature1': 'Multi-level API retrieval for minimal token usage',
|
||||
'auth.feature2': 'Import OpenAPI specs in seconds',
|
||||
'auth.feature3': 'Works with any MCP-compatible LLM',
|
||||
/** Use `tk()` to cast dynamic key strings (e.g. template literals) to TranslationKey */
|
||||
export const tk = (key: string) => key as TranslationKey;
|
||||
|
||||
// Auth - Login
|
||||
'auth.login.title': 'Sign in to your account',
|
||||
'auth.login.email': 'Email',
|
||||
'auth.login.password': 'Password',
|
||||
'auth.login.submit': 'Sign In',
|
||||
'auth.login.submitting': 'Signing in...',
|
||||
'auth.login.noAccount': "Don't have an account?",
|
||||
'auth.login.signUp': 'Sign Up',
|
||||
'auth.login.or': 'or continue with',
|
||||
|
||||
// Auth - Register
|
||||
'auth.register.title': 'Create your account',
|
||||
'auth.register.subtitle': 'Get started with AgentFox',
|
||||
'auth.register.name': 'Name',
|
||||
'auth.register.email': 'Email',
|
||||
'auth.register.password': 'Password',
|
||||
'auth.register.submit': 'Create Account',
|
||||
'auth.register.submitting': 'Creating account...',
|
||||
'auth.register.hasAccount': 'Already have an account?',
|
||||
'auth.register.signIn': 'Sign In',
|
||||
'auth.register.or': 'or continue with',
|
||||
|
||||
// Auth - OAuth
|
||||
'auth.oauth.google': 'Google',
|
||||
'auth.oauth.github': 'GitHub',
|
||||
'auth.oauth.apple': 'Apple',
|
||||
|
||||
// Auth - Callback
|
||||
'auth.callback.loading': 'Completing sign in...',
|
||||
'auth.callback.error': 'Sign in failed',
|
||||
'auth.callback.retry': 'Try again',
|
||||
|
||||
// Hero
|
||||
'hero.badge': 'MCP-Powered API Intelligence',
|
||||
'hero.title': 'API Docs for LLMs,',
|
||||
'hero.titleHighlight': 'Done Right',
|
||||
'hero.subtitle': 'Let AI agents query your OpenAPI documentation with surgical precision. Multi-level retrieval serves exactly the tokens needed — not the entire spec.',
|
||||
'hero.cta': 'Start Free',
|
||||
'hero.ctaSecondary': 'View Documentation',
|
||||
'hero.terminal.comment': '# Connect your AI tool to any API documentation',
|
||||
'hero.terminal.cmd1': 'get_project_overview',
|
||||
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
|
||||
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
|
||||
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
|
||||
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
|
||||
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
|
||||
|
||||
// Features
|
||||
'features.label': 'Features',
|
||||
'features.title': 'Intelligent API Retrieval',
|
||||
'features.subtitle': 'Five specialized MCP tools designed for minimal token usage per call',
|
||||
'features.progressive.title': 'Progressive Drill-Down',
|
||||
'features.progressive.desc': 'Navigate from project overview to module list to endpoint detail — retrieve only what you need.',
|
||||
'features.token.title': 'Token Efficient',
|
||||
'features.token.desc': '~200-2,000 tokens per call vs 10,000+ for dumping the full OpenAPI spec into context.',
|
||||
'features.spec.title': 'Full Spec Support',
|
||||
'features.spec.desc': 'Import OpenAPI 3.x and Swagger 2.0 docs. All $refs are dereferenced automatically.',
|
||||
'features.import.title': 'One-Click Import',
|
||||
'features.import.desc': 'Paste a URL or upload JSON/YAML — your API docs are parsed and indexed instantly.',
|
||||
'features.projects.title': 'Multi-Project',
|
||||
'features.projects.desc': 'Organize APIs into isolated projects, each with its own MCP endpoint and API key.',
|
||||
'features.security.title': 'Secure by Default',
|
||||
'features.security.desc': 'Project-level API keys with bcrypt hashing. JWT auth for the dashboard. Zero shared secrets.',
|
||||
|
||||
// Tools
|
||||
'tools.label': 'Compatibility',
|
||||
'tools.title': 'Works with Your Favorite AI Tools',
|
||||
'tools.subtitle': 'AgentFox speaks MCP — the universal protocol supported by leading AI coding assistants',
|
||||
'tools.claude.name': 'Claude Code',
|
||||
'tools.claude.desc': 'Anthropic CLI',
|
||||
'tools.codex.name': 'Codex',
|
||||
'tools.codex.desc': 'OpenAI CLI',
|
||||
'tools.cursor.name': 'Cursor',
|
||||
'tools.cursor.desc': 'AI Code Editor',
|
||||
'tools.copilot.name': 'GitHub Copilot',
|
||||
'tools.copilot.desc': 'GitHub AI Pair',
|
||||
'tools.gemini.name': 'Gemini CLI',
|
||||
'tools.gemini.desc': 'Google AI CLI',
|
||||
'tools.antigravity.name': 'Antigravity',
|
||||
'tools.antigravity.desc': 'AI Dev Platform',
|
||||
'tools.openclaw.name': 'OpenClaw',
|
||||
'tools.openclaw.desc': 'AI Dev Platform',
|
||||
|
||||
// Testimonials
|
||||
'testimonials.label': 'Testimonials',
|
||||
'testimonials.title': 'Loved by Developers',
|
||||
'testimonials.1.quote': 'AgentFox cut our API integration time in half. Instead of copy-pasting docs, Claude just queries what it needs through MCP.',
|
||||
'testimonials.1.name': 'Sarah Chen',
|
||||
'testimonials.1.role': 'Staff Engineer at Vercel',
|
||||
'testimonials.2.quote': 'The token savings are real — our Cursor workflow went from burning 15K tokens per API call to under 1K. Game changer for complex integrations.',
|
||||
'testimonials.2.name': 'Marcus Rivera',
|
||||
'testimonials.2.role': 'CTO at Stackblitz',
|
||||
'testimonials.3.quote': 'We onboarded 50+ internal APIs to AgentFox in a week. Now every team\'s AI assistant can discover and use any service endpoint.',
|
||||
'testimonials.3.name': 'Yuki Tanaka',
|
||||
'testimonials.3.role': 'Platform Lead at Shopify',
|
||||
|
||||
// Pricing
|
||||
'pricing.label': 'Pricing',
|
||||
'pricing.title': 'Simple, Transparent Pricing',
|
||||
'pricing.subtitle': 'Start free, scale as you grow',
|
||||
'pricing.free.name': 'Free',
|
||||
'pricing.free.price': '$0',
|
||||
'pricing.free.period': '/month',
|
||||
'pricing.free.desc': 'Perfect for trying out MCP-powered API docs',
|
||||
'pricing.free.f1': '1 project',
|
||||
'pricing.free.f2': '100 MCP queries/day',
|
||||
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
|
||||
'pricing.free.f4': 'Community support',
|
||||
'pricing.free.cta': 'Get Started',
|
||||
'pricing.pro.name': 'Pro',
|
||||
'pricing.pro.price': '$29',
|
||||
'pricing.pro.period': '/month',
|
||||
'pricing.pro.badge': 'Most Popular',
|
||||
'pricing.pro.desc': 'For teams shipping with AI-assisted development',
|
||||
'pricing.pro.f1': 'Unlimited projects',
|
||||
'pricing.pro.f2': 'Unlimited MCP queries',
|
||||
'pricing.pro.f3': 'Priority import queue',
|
||||
'pricing.pro.f4': 'Team collaboration',
|
||||
'pricing.pro.f5': 'Priority support',
|
||||
'pricing.pro.cta': 'Start Free Trial',
|
||||
'pricing.enterprise.name': 'Enterprise',
|
||||
'pricing.enterprise.price': 'Custom',
|
||||
'pricing.enterprise.period': '',
|
||||
'pricing.enterprise.desc': 'For organizations with advanced requirements',
|
||||
'pricing.enterprise.f1': 'Self-hosted deployment',
|
||||
'pricing.enterprise.f2': 'SSO / SAML',
|
||||
'pricing.enterprise.f3': 'SLA guarantee',
|
||||
'pricing.enterprise.f4': 'Dedicated support',
|
||||
'pricing.enterprise.f5': 'Custom integrations',
|
||||
'pricing.enterprise.cta': 'Contact Sales',
|
||||
|
||||
// FAQ
|
||||
'faq.label': 'FAQ',
|
||||
'faq.title': 'Frequently Asked Questions',
|
||||
'faq.1.q': 'What is MCP and how does AgentFox use it?',
|
||||
'faq.1.a': 'MCP (Model Context Protocol) is an open standard that lets AI assistants connect to external tools and data sources. AgentFox exposes your API documentation through MCP tools, so AI coding assistants like Claude Code, Cursor, and Copilot can query endpoint details on demand — without dumping the entire spec into their context window.',
|
||||
'faq.2.q': 'Which OpenAPI formats are supported?',
|
||||
'faq.2.a': 'AgentFox supports both OpenAPI 3.x and Swagger 2.0 specifications. You can import documents in JSON or YAML format, or provide a URL to fetch them directly. All $ref references are automatically dereferenced during import.',
|
||||
'faq.3.q': 'How much does it reduce token usage?',
|
||||
'faq.3.a': 'Each MCP tool call returns ~200-2,000 tokens of focused information, compared to 10,000+ tokens for dumping a full API spec. For a typical integration task, this means 80-95% reduction in token consumption.',
|
||||
'faq.4.q': 'Is my API documentation secure?',
|
||||
'faq.4.a': 'Yes. Each project has its own API key (bcrypt hashed, never stored in plain text). The MCP endpoint requires authentication for every request. User dashboard access uses JWT with automatic token rotation.',
|
||||
'faq.5.q': 'Which AI tools are compatible?',
|
||||
'faq.5.a': 'Any tool that supports the MCP protocol can connect to AgentFox. This includes Claude Code, OpenAI Codex CLI, OpenClaw, Gemini CLI, Cursor, GitHub Copilot (via MCP plugins), Antigravity, and more. If your tool supports MCP, it works with AgentFox.',
|
||||
'faq.6.q': 'Can I self-host AgentFox?',
|
||||
'faq.6.a': 'Yes! AgentFox is designed for both cloud and self-hosted deployment. The Enterprise plan includes full self-hosted support with Docker Compose, along with SSO integration and dedicated support.',
|
||||
|
||||
// Footer
|
||||
'footer.product': 'Product',
|
||||
'footer.features': 'Features',
|
||||
'footer.pricing': 'Pricing',
|
||||
'footer.docs': 'Documentation',
|
||||
'footer.changelog': 'Changelog',
|
||||
'footer.resources': 'Resources',
|
||||
'footer.github': 'GitHub',
|
||||
'footer.community': 'Community',
|
||||
'footer.blog': 'Blog',
|
||||
'footer.legal': 'Legal',
|
||||
'footer.privacy': 'Privacy',
|
||||
'footer.terms': 'Terms',
|
||||
'footer.copyright': '© 2026 AgentFox. All rights reserved.',
|
||||
'footer.tagline': 'MCP-powered API documentation for AI agents.',
|
||||
},
|
||||
zh: {
|
||||
// Nav
|
||||
'nav.features': '特性',
|
||||
'nav.tools': '工具',
|
||||
'nav.testimonials': '用户评价',
|
||||
'nav.pricing': '定价',
|
||||
'nav.faq': '常见问题',
|
||||
'nav.signIn': '登录',
|
||||
'nav.getStarted': '免费开始',
|
||||
'nav.dashboard': '控制台',
|
||||
|
||||
// Auth - Branding
|
||||
'auth.productName': 'AgentFox',
|
||||
'auth.slogan': 'LLM 专属 API 文档方案',
|
||||
'auth.feature1': '多级 API 检索,最小化 Token 消耗',
|
||||
'auth.feature2': '秒级导入 OpenAPI 文档',
|
||||
'auth.feature3': '兼容所有 MCP 协议的 LLM 工具',
|
||||
|
||||
// Auth - Login
|
||||
'auth.login.title': '登录到您的账户',
|
||||
'auth.login.email': '邮箱',
|
||||
'auth.login.password': '密码',
|
||||
'auth.login.submit': '登录',
|
||||
'auth.login.submitting': '登录中...',
|
||||
'auth.login.noAccount': '还没有账户?',
|
||||
'auth.login.signUp': '注册',
|
||||
'auth.login.or': '或者通过以下方式继续',
|
||||
|
||||
// Auth - Register
|
||||
'auth.register.title': '创建您的账户',
|
||||
'auth.register.subtitle': '开始使用 AgentFox',
|
||||
'auth.register.name': '姓名',
|
||||
'auth.register.email': '邮箱',
|
||||
'auth.register.password': '密码',
|
||||
'auth.register.submit': '创建账户',
|
||||
'auth.register.submitting': '创建中...',
|
||||
'auth.register.hasAccount': '已经有账户了?',
|
||||
'auth.register.signIn': '登录',
|
||||
'auth.register.or': '或者通过以下方式继续',
|
||||
|
||||
// Auth - OAuth
|
||||
'auth.oauth.google': 'Google',
|
||||
'auth.oauth.github': 'GitHub',
|
||||
'auth.oauth.apple': 'Apple',
|
||||
|
||||
// Auth - Callback
|
||||
'auth.callback.loading': '正在完成登录...',
|
||||
'auth.callback.error': '登录失败',
|
||||
'auth.callback.retry': '重试',
|
||||
|
||||
// Hero
|
||||
'hero.badge': 'MCP 驱动的 API 智能服务',
|
||||
'hero.title': '为 LLM 而生的',
|
||||
'hero.titleHighlight': 'API 文档',
|
||||
'hero.subtitle': '让 AI 代理以精准的方式查询你的 OpenAPI 文档。多级检索只提供所需的 token,而非整个规范。',
|
||||
'hero.cta': '免费开始',
|
||||
'hero.ctaSecondary': '查看文档',
|
||||
'hero.terminal.comment': '# 将你的 AI 工具连接到任何 API 文档',
|
||||
'hero.terminal.cmd1': 'get_project_overview',
|
||||
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
|
||||
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
|
||||
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
|
||||
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
|
||||
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
|
||||
|
||||
// Features
|
||||
'features.label': '核心特性',
|
||||
'features.title': '智能 API 检索',
|
||||
'features.subtitle': '五个专用 MCP 工具,每次调用最小化 token 消耗',
|
||||
'features.progressive.title': '渐进式下钻',
|
||||
'features.progressive.desc': '从项目概览到模块列表再到端点详情,按需精确检索。',
|
||||
'features.token.title': 'Token 高效',
|
||||
'features.token.desc': '每次调用 ~200-2,000 tokens,对比全量 OpenAPI 规范的 10,000+ tokens。',
|
||||
'features.spec.title': '全规范支持',
|
||||
'features.spec.desc': '导入 OpenAPI 3.x 和 Swagger 2.0 文档,所有 $ref 自动解引用。',
|
||||
'features.import.title': '一键导入',
|
||||
'features.import.desc': '粘贴 URL 或上传 JSON/YAML 文件,API 文档即时解析并索引。',
|
||||
'features.projects.title': '多项目管理',
|
||||
'features.projects.desc': '将 API 组织到独立项目中,每个项目拥有专属 MCP 端点和 API Key。',
|
||||
'features.security.title': '安全可靠',
|
||||
'features.security.desc': '项目级 API Key(bcrypt 哈希加密),JWT 双令牌认证,零共享密钥。',
|
||||
|
||||
// Tools
|
||||
'tools.label': '兼容性',
|
||||
'tools.title': '兼容你常用的 AI 工具',
|
||||
'tools.subtitle': 'AgentFox 使用 MCP 协议 — 主流 AI 编程助手均已支持的通用协议',
|
||||
'tools.claude.name': 'Claude Code',
|
||||
'tools.claude.desc': 'Anthropic CLI',
|
||||
'tools.codex.name': 'Codex',
|
||||
'tools.codex.desc': 'OpenAI CLI',
|
||||
'tools.cursor.name': 'Cursor',
|
||||
'tools.cursor.desc': 'AI 代码编辑器',
|
||||
'tools.copilot.name': 'GitHub Copilot',
|
||||
'tools.copilot.desc': 'GitHub AI 助手',
|
||||
'tools.gemini.name': 'Gemini CLI',
|
||||
'tools.gemini.desc': 'Google AI CLI',
|
||||
'tools.antigravity.name': 'Antigravity',
|
||||
'tools.antigravity.desc': 'AI 开发平台',
|
||||
'tools.openclaw.name': 'OpenClaw',
|
||||
'tools.openclaw.desc': 'AI 开发平台',
|
||||
|
||||
// Testimonials
|
||||
'testimonials.label': '用户评价',
|
||||
'testimonials.title': '深受开发者喜爱',
|
||||
'testimonials.1.quote': 'AgentFox 将我们的 API 集成时间缩短了一半。Claude 通过 MCP 直接查询所需内容,不再需要复制粘贴文档。',
|
||||
'testimonials.1.name': 'Sarah Chen',
|
||||
'testimonials.1.role': 'Vercel 高级工程师',
|
||||
'testimonials.2.quote': 'Token 节省是实实在在的 — 我们 Cursor 工作流从每次 API 调用消耗 15K tokens 降到了不到 1K。复杂集成的真正利器。',
|
||||
'testimonials.2.name': 'Marcus Rivera',
|
||||
'testimonials.2.role': 'Stackblitz CTO',
|
||||
'testimonials.3.quote': '我们在一周内将 50+ 内部 API 接入了 AgentFox。现在每个团队的 AI 助手都能发现和使用任何服务端点。',
|
||||
'testimonials.3.name': 'Yuki Tanaka',
|
||||
'testimonials.3.role': 'Shopify 平台负责人',
|
||||
|
||||
// Pricing
|
||||
'pricing.label': '定价方案',
|
||||
'pricing.title': '简洁透明的定价',
|
||||
'pricing.subtitle': '免费起步,按需扩展',
|
||||
'pricing.free.name': '免费版',
|
||||
'pricing.free.price': '¥0',
|
||||
'pricing.free.period': '/月',
|
||||
'pricing.free.desc': '体验 MCP 驱动的 API 文档服务',
|
||||
'pricing.free.f1': '1 个项目',
|
||||
'pricing.free.f2': '每日 100 次 MCP 查询',
|
||||
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
|
||||
'pricing.free.f4': '社区支持',
|
||||
'pricing.free.cta': '免费开始',
|
||||
'pricing.pro.name': '专业版',
|
||||
'pricing.pro.price': '¥199',
|
||||
'pricing.pro.period': '/月',
|
||||
'pricing.pro.badge': '最受欢迎',
|
||||
'pricing.pro.desc': '为 AI 辅助开发团队打造',
|
||||
'pricing.pro.f1': '无限项目',
|
||||
'pricing.pro.f2': '无限 MCP 查询',
|
||||
'pricing.pro.f3': '优先导入队列',
|
||||
'pricing.pro.f4': '团队协作',
|
||||
'pricing.pro.f5': '优先支持',
|
||||
'pricing.pro.cta': '开始免费试用',
|
||||
'pricing.enterprise.name': '企业版',
|
||||
'pricing.enterprise.price': '联系我们',
|
||||
'pricing.enterprise.period': '',
|
||||
'pricing.enterprise.desc': '满足企业级高级需求',
|
||||
'pricing.enterprise.f1': '私有化部署',
|
||||
'pricing.enterprise.f2': 'SSO / SAML',
|
||||
'pricing.enterprise.f3': 'SLA 保障',
|
||||
'pricing.enterprise.f4': '专属支持',
|
||||
'pricing.enterprise.f5': '定制集成',
|
||||
'pricing.enterprise.cta': '联系销售',
|
||||
|
||||
// FAQ
|
||||
'faq.label': '常见问题',
|
||||
'faq.title': '常见问题解答',
|
||||
'faq.1.q': '什么是 MCP?AgentFox 如何使用它?',
|
||||
'faq.1.a': 'MCP(Model Context Protocol)是一个开放标准,让 AI 助手能够连接外部工具和数据源。AgentFox 通过 MCP 工具暴露你的 API 文档,让 Claude Code、Cursor、Copilot 等 AI 编程助手可以按需查询端点详情,而无需将整个规范放入上下文窗口。',
|
||||
'faq.2.q': '支持哪些 OpenAPI 格式?',
|
||||
'faq.2.a': 'AgentFox 支持 OpenAPI 3.x 和 Swagger 2.0 规范。你可以导入 JSON 或 YAML 格式的文档,也可以提供 URL 直接获取。导入时所有 $ref 引用会自动解引用。',
|
||||
'faq.3.q': '能减少多少 Token 消耗?',
|
||||
'faq.3.a': '每次 MCP 工具调用返回 ~200-2,000 tokens 的精准信息,相比全量 API 规范的 10,000+ tokens。对于典型的集成任务,这意味着 80-95% 的 token 消耗降低。',
|
||||
'faq.4.q': '我的 API 文档安全吗?',
|
||||
'faq.4.a': '是的。每个项目拥有独立的 API Key(bcrypt 哈希加密,从不以明文存储)。MCP 端点每次请求都需要认证。用户仪表盘使用 JWT 并自动轮换 token。',
|
||||
'faq.5.q': '兼容哪些 AI 工具?',
|
||||
'faq.5.a': '任何支持 MCP 协议的工具都可以连接 AgentFox,包括 Claude Code、OpenAI Codex CLI、OpenClaw、Gemini CLI、Cursor、GitHub Copilot(通过 MCP 插件)、Antigravity 等。如果你的工具支持 MCP,就能与 AgentFox 配合使用。',
|
||||
'faq.6.q': '可以私有化部署吗?',
|
||||
'faq.6.a': '可以!AgentFox 支持云端和私有化部署。企业版包含完整的 Docker Compose 私有化部署支持,以及 SSO 集成和专属技术支持。',
|
||||
|
||||
// Footer
|
||||
'footer.product': '产品',
|
||||
'footer.features': '特性',
|
||||
'footer.pricing': '定价',
|
||||
'footer.docs': '文档',
|
||||
'footer.changelog': '更新日志',
|
||||
'footer.resources': '资源',
|
||||
'footer.github': 'GitHub',
|
||||
'footer.community': '社区',
|
||||
'footer.blog': '博客',
|
||||
'footer.legal': '法律',
|
||||
'footer.privacy': '隐私政策',
|
||||
'footer.terms': '服务条款',
|
||||
'footer.copyright': '© 2026 AgentFox. 保留所有权利。',
|
||||
'footer.tagline': '为 AI 代理打造的 MCP 驱动 API 文档服务。',
|
||||
},
|
||||
};
|
||||
export type TFunction = (key: TranslationKey, params?: Record<string, string | number>) => string;
|
||||
|
||||
type I18nContextType = {
|
||||
locale: Locale;
|
||||
setLocale: (l: Locale) => void;
|
||||
t: (key: string) => string;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextType | null>(null);
|
||||
@@ -392,12 +36,20 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.setItem('agent-fox-locale', l);
|
||||
}, []);
|
||||
|
||||
const t = useCallback((key: string): string => {
|
||||
return translations[locale][key] ?? key;
|
||||
const t = useCallback((key: TranslationKey, params?: Record<string, string | number>): string => {
|
||||
let text = translations[locale][key] ?? key;
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
text = text.replaceAll(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
return text;
|
||||
}, [locale]);
|
||||
|
||||
const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
||||
<I18nContext.Provider value={value}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
|
||||
380
packages/web/src/lib/i18n/en.ts
Normal file
380
packages/web/src/lib/i18n/en.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
const en = {
|
||||
// ===== Landing Page =====
|
||||
|
||||
// Nav
|
||||
'nav.features': 'Features',
|
||||
'nav.tools': 'Tools',
|
||||
'nav.testimonials': 'Testimonials',
|
||||
'nav.pricing': 'Pricing',
|
||||
'nav.faq': 'FAQ',
|
||||
'nav.signIn': 'Sign In',
|
||||
'nav.getStarted': 'Get Started',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
|
||||
// Hero
|
||||
'hero.badge': 'MCP-Powered API Intelligence',
|
||||
'hero.title': 'API Docs for LLMs,',
|
||||
'hero.titleHighlight': 'Done Right',
|
||||
'hero.subtitle': 'Let AI agents query your OpenAPI documentation with surgical precision. Multi-level retrieval serves exactly the tokens needed — not the entire spec.',
|
||||
'hero.cta': 'Start Free',
|
||||
'hero.ctaSecondary': 'View Documentation',
|
||||
'hero.terminal.comment': '# Connect your AI tool to any API documentation',
|
||||
'hero.terminal.cmd1': 'get_project_overview',
|
||||
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
|
||||
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
|
||||
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
|
||||
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
|
||||
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
|
||||
|
||||
// Features
|
||||
'features.label': 'Features',
|
||||
'features.title': 'Intelligent API Retrieval',
|
||||
'features.subtitle': 'Five specialized MCP tools designed for minimal token usage per call',
|
||||
'features.progressive.title': 'Progressive Drill-Down',
|
||||
'features.progressive.desc': 'Navigate from project overview to module list to endpoint detail — retrieve only what you need.',
|
||||
'features.token.title': 'Token Efficient',
|
||||
'features.token.desc': '~200-2,000 tokens per call vs 10,000+ for dumping the full OpenAPI spec into context.',
|
||||
'features.spec.title': 'Full Spec Support',
|
||||
'features.spec.desc': 'Import OpenAPI 3.x and Swagger 2.0 docs. All $refs are dereferenced automatically.',
|
||||
'features.import.title': 'One-Click Import',
|
||||
'features.import.desc': 'Paste a URL or upload JSON/YAML — your API docs are parsed and indexed instantly.',
|
||||
'features.projects.title': 'Multi-Project',
|
||||
'features.projects.desc': 'Organize APIs into isolated projects, each with its own MCP endpoint and API key.',
|
||||
'features.security.title': 'Secure by Default',
|
||||
'features.security.desc': 'Project-level API keys with bcrypt hashing. JWT auth for the dashboard. Zero shared secrets.',
|
||||
|
||||
// Tools
|
||||
'tools.label': 'Compatibility',
|
||||
'tools.title': 'Works with Your Favorite AI Tools',
|
||||
'tools.subtitle': 'AgentFox speaks MCP — the universal protocol supported by leading AI coding assistants',
|
||||
'tools.claude.name': 'Claude Code',
|
||||
'tools.claude.desc': 'Anthropic CLI',
|
||||
'tools.codex.name': 'Codex',
|
||||
'tools.codex.desc': 'OpenAI CLI',
|
||||
'tools.cursor.name': 'Cursor',
|
||||
'tools.cursor.desc': 'AI Code Editor',
|
||||
'tools.copilot.name': 'GitHub Copilot',
|
||||
'tools.copilot.desc': 'GitHub AI Pair',
|
||||
'tools.gemini.name': 'Gemini CLI',
|
||||
'tools.gemini.desc': 'Google AI CLI',
|
||||
'tools.antigravity.name': 'Antigravity',
|
||||
'tools.antigravity.desc': 'AI Dev Platform',
|
||||
'tools.openclaw.name': 'OpenClaw',
|
||||
'tools.openclaw.desc': 'AI Dev Platform',
|
||||
|
||||
// Testimonials
|
||||
'testimonials.label': 'Testimonials',
|
||||
'testimonials.title': 'Loved by Developers',
|
||||
'testimonials.1.quote': 'AgentFox cut our API integration time in half. Instead of copy-pasting docs, Claude just queries what it needs through MCP.',
|
||||
'testimonials.1.name': 'Sarah Chen',
|
||||
'testimonials.1.role': 'Staff Engineer at Vercel',
|
||||
'testimonials.2.quote': 'The token savings are real — our Cursor workflow went from burning 15K tokens per API call to under 1K. Game changer for complex integrations.',
|
||||
'testimonials.2.name': 'Marcus Rivera',
|
||||
'testimonials.2.role': 'CTO at Stackblitz',
|
||||
'testimonials.3.quote': 'We onboarded 50+ internal APIs to AgentFox in a week. Now every team\'s AI assistant can discover and use any service endpoint.',
|
||||
'testimonials.3.name': 'Yuki Tanaka',
|
||||
'testimonials.3.role': 'Platform Lead at Shopify',
|
||||
|
||||
// Pricing
|
||||
'pricing.label': 'Pricing',
|
||||
'pricing.title': 'Simple, Transparent Pricing',
|
||||
'pricing.subtitle': 'Start free, scale as you grow',
|
||||
'pricing.free.name': 'Free',
|
||||
'pricing.free.price': '$0',
|
||||
'pricing.free.period': '/month',
|
||||
'pricing.free.desc': 'Perfect for trying out MCP-powered API docs',
|
||||
'pricing.free.f1': '1 project',
|
||||
'pricing.free.f2': '100 MCP queries/day',
|
||||
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
|
||||
'pricing.free.f4': 'Community support',
|
||||
'pricing.free.cta': 'Get Started',
|
||||
'pricing.pro.name': 'Pro',
|
||||
'pricing.pro.price': '$29',
|
||||
'pricing.pro.period': '/month',
|
||||
'pricing.pro.badge': 'Most Popular',
|
||||
'pricing.pro.desc': 'For teams shipping with AI-assisted development',
|
||||
'pricing.pro.f1': 'Unlimited projects',
|
||||
'pricing.pro.f2': 'Unlimited MCP queries',
|
||||
'pricing.pro.f3': 'Priority import queue',
|
||||
'pricing.pro.f4': 'Team collaboration',
|
||||
'pricing.pro.f5': 'Priority support',
|
||||
'pricing.pro.cta': 'Start Free Trial',
|
||||
'pricing.enterprise.name': 'Enterprise',
|
||||
'pricing.enterprise.price': 'Custom',
|
||||
'pricing.enterprise.period': '',
|
||||
'pricing.enterprise.desc': 'For organizations with advanced requirements',
|
||||
'pricing.enterprise.f1': 'Self-hosted deployment',
|
||||
'pricing.enterprise.f2': 'SSO / SAML',
|
||||
'pricing.enterprise.f3': 'SLA guarantee',
|
||||
'pricing.enterprise.f4': 'Dedicated support',
|
||||
'pricing.enterprise.f5': 'Custom integrations',
|
||||
'pricing.enterprise.cta': 'Contact Sales',
|
||||
|
||||
// FAQ
|
||||
'faq.label': 'FAQ',
|
||||
'faq.title': 'Frequently Asked Questions',
|
||||
'faq.1.q': 'What is MCP and how does AgentFox use it?',
|
||||
'faq.1.a': 'MCP (Model Context Protocol) is an open standard that lets AI assistants connect to external tools and data sources. AgentFox exposes your API documentation through MCP tools, so AI coding assistants like Claude Code, Cursor, and Copilot can query endpoint details on demand — without dumping the entire spec into their context window.',
|
||||
'faq.2.q': 'Which OpenAPI formats are supported?',
|
||||
'faq.2.a': 'AgentFox supports both OpenAPI 3.x and Swagger 2.0 specifications. You can import documents in JSON or YAML format, or provide a URL to fetch them directly. All $ref references are automatically dereferenced during import.',
|
||||
'faq.3.q': 'How much does it reduce token usage?',
|
||||
'faq.3.a': 'Each MCP tool call returns ~200-2,000 tokens of focused information, compared to 10,000+ tokens for dumping a full API spec. For a typical integration task, this means 80-95% reduction in token consumption.',
|
||||
'faq.4.q': 'Is my API documentation secure?',
|
||||
'faq.4.a': 'Yes. Each project has its own API key (bcrypt hashed, never stored in plain text). The MCP endpoint requires authentication for every request. User dashboard access uses JWT with automatic token rotation.',
|
||||
'faq.5.q': 'Which AI tools are compatible?',
|
||||
'faq.5.a': 'Any tool that supports the MCP protocol can connect to AgentFox. This includes Claude Code, OpenAI Codex CLI, OpenClaw, Gemini CLI, Cursor, GitHub Copilot (via MCP plugins), Antigravity, and more. If your tool supports MCP, it works with AgentFox.',
|
||||
'faq.6.q': 'Can I self-host AgentFox?',
|
||||
'faq.6.a': 'Yes! AgentFox is designed for both cloud and self-hosted deployment. The Enterprise plan includes full self-hosted support with Docker Compose, along with SSO integration and dedicated support.',
|
||||
|
||||
// Footer
|
||||
'footer.product': 'Product',
|
||||
'footer.features': 'Features',
|
||||
'footer.pricing': 'Pricing',
|
||||
'footer.docs': 'Documentation',
|
||||
'footer.changelog': 'Changelog',
|
||||
'footer.resources': 'Resources',
|
||||
'footer.github': 'GitHub',
|
||||
'footer.community': 'Community',
|
||||
'footer.blog': 'Blog',
|
||||
'footer.legal': 'Legal',
|
||||
'footer.privacy': 'Privacy',
|
||||
'footer.terms': 'Terms',
|
||||
'footer.copyright': '© 2026 AgentFox. All rights reserved.',
|
||||
'footer.tagline': 'MCP-powered API documentation for AI agents.',
|
||||
|
||||
// ===== Common =====
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.delete': 'Delete',
|
||||
'common.save': 'Save',
|
||||
'common.back': 'Back',
|
||||
'common.done': 'Done',
|
||||
'common.copy': 'Copy',
|
||||
'common.copied': 'Copied',
|
||||
'common.continue': 'Continue',
|
||||
'common.import': 'Import',
|
||||
'common.importing': 'Importing...',
|
||||
'common.signOut': 'Sign Out',
|
||||
'common.signOutConfirm': 'Are you sure you want to sign out?',
|
||||
'common.settings': 'Settings',
|
||||
'common.modules': 'Modules',
|
||||
'common.endpoints': 'Endpoints',
|
||||
'common.total': 'total',
|
||||
'common.add': 'Add',
|
||||
'common.fromUrl': 'From URL',
|
||||
'common.uploadFile': 'Upload File',
|
||||
'common.dropFile': 'Drop your OpenAPI file here',
|
||||
'common.jsonOrYaml': 'JSON or YAML',
|
||||
|
||||
// ===== Theme =====
|
||||
'theme.light': 'Light',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.system': 'System',
|
||||
|
||||
// ===== Auth =====
|
||||
// Login
|
||||
'auth.login.title': 'Sign in to AgentFox',
|
||||
'auth.login.subtitle': 'API documentation for LLMs',
|
||||
'auth.login.email': 'Email',
|
||||
'auth.login.password': 'Password',
|
||||
'auth.login.submit': 'Sign In',
|
||||
'auth.login.submitting': 'Signing in...',
|
||||
'auth.login.noAccount': 'Don\'t have an account?',
|
||||
'auth.login.signUp': 'Sign Up',
|
||||
'auth.login.emailRequired': 'Email is required',
|
||||
'auth.login.emailInvalid': 'Please enter a valid email address',
|
||||
'auth.login.passwordRequired': 'Password is required',
|
||||
'auth.login.passwordPlaceholder': 'Enter your password',
|
||||
'auth.login.or': 'or continue with',
|
||||
|
||||
// Branding
|
||||
'auth.productName': 'AgentFox',
|
||||
'auth.slogan': 'API Docs for LLMs, Done Right',
|
||||
'auth.feature1': 'Multi-level API retrieval for minimal token usage',
|
||||
'auth.feature2': 'Import OpenAPI specs in seconds',
|
||||
'auth.feature3': 'Works with any MCP-compatible LLM',
|
||||
|
||||
// Register
|
||||
'auth.register.title': 'Create your account',
|
||||
'auth.register.subtitle': 'Get started with AgentFox',
|
||||
'auth.register.name': 'Name',
|
||||
'auth.register.email': 'Email',
|
||||
'auth.register.password': 'Password',
|
||||
'auth.register.submit': 'Create Account',
|
||||
'auth.register.submitting': 'Creating account...',
|
||||
'auth.register.hasAccount': 'Already have an account?',
|
||||
'auth.register.signIn': 'Sign In',
|
||||
'auth.register.nameRequired': 'Name is required',
|
||||
'auth.register.emailRequired': 'Email is required',
|
||||
'auth.register.emailInvalid': 'Please enter a valid email address',
|
||||
'auth.register.passwordRequired': 'Password is required',
|
||||
'auth.register.passwordMin': 'Password must be at least 8 characters',
|
||||
'auth.register.namePlaceholder': 'Your name',
|
||||
'auth.register.passwordPlaceholder': 'At least 8 characters',
|
||||
'auth.register.or': 'or continue with',
|
||||
|
||||
// OAuth
|
||||
'auth.oauth.google': 'Google',
|
||||
'auth.oauth.github': 'GitHub',
|
||||
'auth.oauth.apple': 'Apple',
|
||||
|
||||
// Callback
|
||||
'auth.callback.loading': 'Completing sign in...',
|
||||
'auth.callback.error': 'Sign in failed',
|
||||
'auth.callback.retry': 'Try again',
|
||||
|
||||
// ===== Dashboard Layout =====
|
||||
'dashboard.layout.projects': 'Projects',
|
||||
'dashboard.layout.allProjects': 'All Projects',
|
||||
'dashboard.layout.onboardingTitle': 'Welcome! Generate an API key to start using MCP services.',
|
||||
'dashboard.layout.onboardingDesc': 'You\'ll need an API key to connect your LLM client to your projects.',
|
||||
'dashboard.layout.generateApiKey': 'Generate API Key',
|
||||
|
||||
// ===== Dashboard Projects =====
|
||||
'dashboard.projects.title': 'Projects',
|
||||
'dashboard.projects.importBtn': 'Import API Doc',
|
||||
'dashboard.projects.emptyTitle': 'No projects yet',
|
||||
'dashboard.projects.emptyDesc': 'Import an OpenAPI document to get started with MCP-powered API documentation.',
|
||||
'dashboard.projects.importFirst': 'Import Your First API',
|
||||
'dashboard.projects.deleteTitle': 'Delete project',
|
||||
'dashboard.projects.deleteDesc': 'Are you sure you want to delete "{name}"? This will permanently remove all modules, endpoints, and MCP configuration.',
|
||||
'dashboard.projects.deleteBtn': 'Delete project',
|
||||
|
||||
// ===== Project Detail =====
|
||||
'dashboard.projectDetail.breadcrumbProjects': 'Projects',
|
||||
'dashboard.projectDetail.notFound': 'Project not found',
|
||||
'dashboard.projectDetail.backToProjects': 'Back to projects',
|
||||
'dashboard.projectDetail.tabMcp': 'MCP',
|
||||
'dashboard.projectDetail.tabDocs': 'Documentation',
|
||||
'dashboard.projectDetail.tabModules': 'Modules',
|
||||
'dashboard.projectDetail.tabSettings': 'Settings',
|
||||
|
||||
// ===== Import Dialog =====
|
||||
'dashboard.import.title': 'Import OpenAPI Document',
|
||||
'dashboard.import.desc': 'Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.',
|
||||
'dashboard.import.successTitle': 'Import Successful',
|
||||
'dashboard.import.goToProject': 'Go to Project',
|
||||
|
||||
// ===== Reimport Dialog =====
|
||||
'dashboard.reimport.title': 'Re-import API Document',
|
||||
'dashboard.reimport.subtitle': 'This action will replace all existing data.',
|
||||
'dashboard.reimport.warningTitle': 'The following data will be permanently deleted:',
|
||||
'dashboard.reimport.warningModules': '{count} module(s)',
|
||||
'dashboard.reimport.warningEndpoints': '{count} endpoint(s)',
|
||||
'dashboard.reimport.warningNote': 'New modules and endpoints will be created from the imported document. The API key will remain unchanged.',
|
||||
'dashboard.reimport.importTitle': 'Import New Document',
|
||||
'dashboard.reimport.importDesc': 'Provide a Swagger 2.0 or OpenAPI 3.x document.',
|
||||
'dashboard.reimport.submit': 'Re-import',
|
||||
'dashboard.reimport.successTitle': 'Re-import Successful',
|
||||
'dashboard.reimport.successDesc': 'API documentation has been updated.',
|
||||
|
||||
// ===== MCP Integration =====
|
||||
'dashboard.mcp.urlTitle': 'MCP Service URL',
|
||||
'dashboard.mcp.urlDesc': 'Connect your LLM client to this endpoint.',
|
||||
'dashboard.mcp.configTitle': 'Configuration for Claude Code / Cursor',
|
||||
'dashboard.mcp.configDesc': 'Add this to your MCP client configuration.',
|
||||
'dashboard.mcp.keyGenerated': 'API key generated. Copy it from',
|
||||
'dashboard.mcp.keyReplace': 'and replace',
|
||||
'dashboard.mcp.keyAbove': 'above.',
|
||||
'dashboard.mcp.noKeyWarning': 'You need to generate an API key before using MCP.',
|
||||
'dashboard.mcp.openSettings': 'Open Settings',
|
||||
'dashboard.mcp.toolsTitle': 'Available MCP Tools',
|
||||
'dashboard.mcp.toolsDesc': '5 tools for progressive drill-down, designed for minimal token usage.',
|
||||
'dashboard.mcp.tool1Desc': 'Get project name, version, base URL, and module summary. Call this first.',
|
||||
'dashboard.mcp.tool2Desc': 'List all modules with descriptions and endpoint counts.',
|
||||
'dashboard.mcp.tool3Desc': 'List endpoints in a module. Provide moduleId.',
|
||||
'dashboard.mcp.tool4Desc': 'Get full endpoint details: parameters, request body, responses.',
|
||||
'dashboard.mcp.tool5Desc': 'Search by keyword across all endpoints. Optional moduleId filter.',
|
||||
|
||||
// ===== Project Settings =====
|
||||
'dashboard.projectSettings.generalTitle': 'General',
|
||||
'dashboard.projectSettings.generalDesc': 'Update your project name and description.',
|
||||
'dashboard.projectSettings.projectName': 'Project Name',
|
||||
'dashboard.projectSettings.description': 'Description',
|
||||
'dashboard.projectSettings.saveChanges': 'Save Changes',
|
||||
'dashboard.projectSettings.saved': 'Saved',
|
||||
'dashboard.projectSettings.reimportTitle': 'Re-import API Document',
|
||||
'dashboard.projectSettings.reimportDesc': 'Replace the current API documentation with a new OpenAPI document. This will clear all existing modules ({modules}) and endpoints ({endpoints}), then recreate them from the new document.',
|
||||
'dashboard.projectSettings.reimportBtn': 'Re-import Document',
|
||||
'dashboard.projectSettings.dangerZone': 'Danger Zone',
|
||||
'dashboard.projectSettings.dangerDesc': 'Permanently delete this project and all its data. This action cannot be undone.',
|
||||
'dashboard.projectSettings.deleteProject': 'Delete Project',
|
||||
'dashboard.projectSettings.deleteTitle': 'Delete project',
|
||||
'dashboard.projectSettings.deleteDesc': 'Permanently delete "{name}"? All modules, endpoints, and MCP configuration will be removed.',
|
||||
|
||||
// ===== Module Management =====
|
||||
'dashboard.modules.addTitle': 'Add Manual Module',
|
||||
'dashboard.modules.placeholder': 'Module name',
|
||||
'dashboard.modules.allModules': 'All Modules',
|
||||
'dashboard.modules.emptyTitle': 'No modules yet',
|
||||
'dashboard.modules.emptyDesc': 'Modules are automatically created when you import an API document. You can also add manual modules above.',
|
||||
'dashboard.modules.deleteTitle': 'Delete module',
|
||||
'dashboard.modules.deleteDesc': 'Delete "{name}"? This will also remove its {count} endpoints.',
|
||||
'dashboard.modules.deleteBtn': 'Delete module',
|
||||
|
||||
// ===== Doc Preview =====
|
||||
'dashboard.docs.modules': 'Modules',
|
||||
'dashboard.docs.noModules': 'No modules',
|
||||
'dashboard.docs.allEndpoints': 'All endpoints',
|
||||
'dashboard.docs.noEndpoints': 'No endpoints',
|
||||
'dashboard.docs.noEndpointsModule': 'This module has no endpoints.',
|
||||
'dashboard.docs.noEndpointsProject': 'No endpoints in this project yet. Import an API document to get started.',
|
||||
'dashboard.docs.deprecated': 'deprecated',
|
||||
'dashboard.docs.operationId': 'Operation ID',
|
||||
|
||||
// ===== Schema View =====
|
||||
'dashboard.schema.parameters': 'Parameters',
|
||||
'dashboard.schema.name': 'Name',
|
||||
'dashboard.schema.in': 'In',
|
||||
'dashboard.schema.type': 'Type',
|
||||
'dashboard.schema.required': 'required',
|
||||
'dashboard.schema.optional': 'optional',
|
||||
'dashboard.schema.descriptionCol': 'Description',
|
||||
'dashboard.schema.requestBody': 'Request Body',
|
||||
'dashboard.schema.responses': 'Responses',
|
||||
'dashboard.schema.noSchema': 'No schema',
|
||||
'dashboard.schema.ofObjects': 'of objects:',
|
||||
'dashboard.schema.enum': 'enum:',
|
||||
'dashboard.schema.default': 'default:',
|
||||
'dashboard.schema.nullable': 'nullable',
|
||||
|
||||
// ===== Settings Dialog =====
|
||||
'dashboard.settings.title': 'Settings',
|
||||
'dashboard.settings.profileTitle': 'Profile',
|
||||
'dashboard.settings.profileDesc': 'Manage your personal information.',
|
||||
'dashboard.settings.displayName': 'Display Name',
|
||||
'dashboard.settings.saveProfile': 'Save Profile',
|
||||
'dashboard.settings.saving': 'Saving...',
|
||||
'dashboard.settings.profileUpdated': 'Profile updated',
|
||||
'dashboard.settings.apiKeyTitle': 'API Key',
|
||||
'dashboard.settings.apiKeyDesc': 'Used to authenticate all MCP requests across your projects.',
|
||||
'dashboard.settings.keySaveWarning': 'Save this key now — you won\'t be able to see it again.',
|
||||
'dashboard.settings.copyToClipboard': 'Copy to Clipboard',
|
||||
'dashboard.settings.keySaved': 'I\'ve saved it, continue',
|
||||
'dashboard.settings.noKey': 'No API key generated yet. Generate one to use MCP services.',
|
||||
'dashboard.settings.generateKey': 'Generate API Key',
|
||||
'dashboard.settings.generating': 'Generating...',
|
||||
'dashboard.settings.rotateKey': 'Rotate API Key',
|
||||
'dashboard.settings.rotateTitle': 'Rotate API Key',
|
||||
'dashboard.settings.rotateDesc': 'The current API key will be invalidated immediately. All MCP clients using the old key will stop working. A new key will be generated.',
|
||||
'dashboard.settings.rotateConfirm': 'Rotate Key',
|
||||
'dashboard.settings.passwordPrompt': 'Enter your password to {action} the API key.',
|
||||
'dashboard.settings.passwordPromptCopy': 'copy',
|
||||
'dashboard.settings.passwordPromptReveal': 'reveal',
|
||||
'dashboard.settings.currentPassword': 'Current password',
|
||||
'dashboard.settings.verifying': 'Verifying...',
|
||||
'dashboard.settings.changePasswordTitle': 'Change Password',
|
||||
'dashboard.settings.changePasswordDesc': 'Update your password to keep your account secure.',
|
||||
'dashboard.settings.currentPasswordLabel': 'Current Password',
|
||||
'dashboard.settings.newPasswordLabel': 'New Password',
|
||||
'dashboard.settings.confirmPasswordLabel': 'Confirm New Password',
|
||||
'dashboard.settings.changePassword': 'Change Password',
|
||||
'dashboard.settings.changingPassword': 'Changing...',
|
||||
'dashboard.settings.passwordMismatch': 'Passwords do not match',
|
||||
'dashboard.settings.passwordChanged': 'Password changed successfully',
|
||||
'dashboard.settings.enterCurrentPassword': 'Enter current password',
|
||||
'dashboard.settings.atLeast8Chars': 'At least 8 characters',
|
||||
'dashboard.settings.confirmNewPassword': 'Confirm new password',
|
||||
};
|
||||
|
||||
export default en;
|
||||
382
packages/web/src/lib/i18n/zh.ts
Normal file
382
packages/web/src/lib/i18n/zh.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import type { TranslationKey } from '../i18n';
|
||||
|
||||
const zh: Record<TranslationKey, string> = {
|
||||
// ===== Landing Page =====
|
||||
|
||||
// Nav
|
||||
'nav.features': '特性',
|
||||
'nav.tools': '工具',
|
||||
'nav.testimonials': '用户评价',
|
||||
'nav.pricing': '定价',
|
||||
'nav.faq': '常见问题',
|
||||
'nav.signIn': '登录',
|
||||
'nav.getStarted': '免费开始',
|
||||
'nav.dashboard': '控制台',
|
||||
|
||||
// Hero
|
||||
'hero.badge': 'MCP 驱动的 API 智能服务',
|
||||
'hero.title': '为 LLM 而生的',
|
||||
'hero.titleHighlight': 'API 文档',
|
||||
'hero.subtitle': '让 AI 代理以精准的方式查询你的 OpenAPI 文档。多级检索只提供所需的 token,而非整个规范。',
|
||||
'hero.cta': '免费开始',
|
||||
'hero.ctaSecondary': '查看文档',
|
||||
'hero.terminal.comment': '# 将你的 AI 工具连接到任何 API 文档',
|
||||
'hero.terminal.cmd1': 'get_project_overview',
|
||||
'hero.terminal.res1': '{ "name": "Stripe API", "modules": 12, "endpoints": 247 }',
|
||||
'hero.terminal.cmd2': 'list_endpoints moduleId="payments"',
|
||||
'hero.terminal.res2': '[ "POST /charges", "GET /charges/:id", "POST /refunds" ... ]',
|
||||
'hero.terminal.cmd3': 'get_endpoint_detail endpointId="create-charge"',
|
||||
'hero.terminal.res3': '{ params: [...], requestBody: {...}, responses: {...} } // ~800 tokens',
|
||||
|
||||
// Features
|
||||
'features.label': '核心特性',
|
||||
'features.title': '智能 API 检索',
|
||||
'features.subtitle': '五个专用 MCP 工具,每次调用最小化 token 消耗',
|
||||
'features.progressive.title': '渐进式下钻',
|
||||
'features.progressive.desc': '从项目概览到模块列表再到端点详情,按需精确检索。',
|
||||
'features.token.title': 'Token 高效',
|
||||
'features.token.desc': '每次调用 ~200-2,000 tokens,对比全量 OpenAPI 规范的 10,000+ tokens。',
|
||||
'features.spec.title': '全规范支持',
|
||||
'features.spec.desc': '导入 OpenAPI 3.x 和 Swagger 2.0 文档,所有 $ref 自动解引用。',
|
||||
'features.import.title': '一键导入',
|
||||
'features.import.desc': '粘贴 URL 或上传 JSON/YAML 文件,API 文档即时解析并索引。',
|
||||
'features.projects.title': '多项目管理',
|
||||
'features.projects.desc': '将 API 组织到独立项目中,每个项目拥有专属 MCP 端点和 API Key。',
|
||||
'features.security.title': '安全可靠',
|
||||
'features.security.desc': '项目级 API Key(bcrypt 哈希加密),JWT 双令牌认证,零共享密钥。',
|
||||
|
||||
// Tools
|
||||
'tools.label': '兼容性',
|
||||
'tools.title': '兼容你常用的 AI 工具',
|
||||
'tools.subtitle': 'AgentFox 使用 MCP 协议 — 主流 AI 编程助手均已支持的通用协议',
|
||||
'tools.claude.name': 'Claude Code',
|
||||
'tools.claude.desc': 'Anthropic CLI',
|
||||
'tools.codex.name': 'Codex',
|
||||
'tools.codex.desc': 'OpenAI CLI',
|
||||
'tools.cursor.name': 'Cursor',
|
||||
'tools.cursor.desc': 'AI 代码编辑器',
|
||||
'tools.copilot.name': 'GitHub Copilot',
|
||||
'tools.copilot.desc': 'GitHub AI 助手',
|
||||
'tools.gemini.name': 'Gemini CLI',
|
||||
'tools.gemini.desc': 'Google AI CLI',
|
||||
'tools.antigravity.name': 'Antigravity',
|
||||
'tools.antigravity.desc': 'AI 开发平台',
|
||||
'tools.openclaw.name': 'OpenClaw',
|
||||
'tools.openclaw.desc': 'AI 开发平台',
|
||||
|
||||
// Testimonials
|
||||
'testimonials.label': '用户评价',
|
||||
'testimonials.title': '深受开发者喜爱',
|
||||
'testimonials.1.quote': 'AgentFox 将我们的 API 集成时间缩短了一半。Claude 通过 MCP 直接查询所需内容,不再需要复制粘贴文档。',
|
||||
'testimonials.1.name': 'Sarah Chen',
|
||||
'testimonials.1.role': 'Vercel 高级工程师',
|
||||
'testimonials.2.quote': 'Token 节省是实实在在的 — 我们 Cursor 工作流从每次 API 调用消耗 15K tokens 降到了不到 1K。复杂集成的真正利器。',
|
||||
'testimonials.2.name': 'Marcus Rivera',
|
||||
'testimonials.2.role': 'Stackblitz CTO',
|
||||
'testimonials.3.quote': '我们在一周内将 50+ 内部 API 接入了 AgentFox。现在每个团队的 AI 助手都能发现和使用任何服务端点。',
|
||||
'testimonials.3.name': 'Yuki Tanaka',
|
||||
'testimonials.3.role': 'Shopify 平台负责人',
|
||||
|
||||
// Pricing
|
||||
'pricing.label': '定价方案',
|
||||
'pricing.title': '简洁透明的定价',
|
||||
'pricing.subtitle': '免费起步,按需扩展',
|
||||
'pricing.free.name': '免费版',
|
||||
'pricing.free.price': '¥0',
|
||||
'pricing.free.period': '/月',
|
||||
'pricing.free.desc': '体验 MCP 驱动的 API 文档服务',
|
||||
'pricing.free.f1': '1 个项目',
|
||||
'pricing.free.f2': '每日 100 次 MCP 查询',
|
||||
'pricing.free.f3': 'OpenAPI 3.x & Swagger 2.0',
|
||||
'pricing.free.f4': '社区支持',
|
||||
'pricing.free.cta': '免费开始',
|
||||
'pricing.pro.name': '专业版',
|
||||
'pricing.pro.price': '¥199',
|
||||
'pricing.pro.period': '/月',
|
||||
'pricing.pro.badge': '最受欢迎',
|
||||
'pricing.pro.desc': '为 AI 辅助开发团队打造',
|
||||
'pricing.pro.f1': '无限项目',
|
||||
'pricing.pro.f2': '无限 MCP 查询',
|
||||
'pricing.pro.f3': '优先导入队列',
|
||||
'pricing.pro.f4': '团队协作',
|
||||
'pricing.pro.f5': '优先支持',
|
||||
'pricing.pro.cta': '开始免费试用',
|
||||
'pricing.enterprise.name': '企业版',
|
||||
'pricing.enterprise.price': '联系我们',
|
||||
'pricing.enterprise.period': '',
|
||||
'pricing.enterprise.desc': '满足企业级高级需求',
|
||||
'pricing.enterprise.f1': '私有化部署',
|
||||
'pricing.enterprise.f2': 'SSO / SAML',
|
||||
'pricing.enterprise.f3': 'SLA 保障',
|
||||
'pricing.enterprise.f4': '专属支持',
|
||||
'pricing.enterprise.f5': '定制集成',
|
||||
'pricing.enterprise.cta': '联系销售',
|
||||
|
||||
// FAQ
|
||||
'faq.label': '常见问题',
|
||||
'faq.title': '常见问题解答',
|
||||
'faq.1.q': '什么是 MCP?AgentFox 如何使用它?',
|
||||
'faq.1.a': 'MCP(Model Context Protocol)是一个开放标准,让 AI 助手能够连接外部工具和数据源。AgentFox 通过 MCP 工具暴露你的 API 文档,让 Claude Code、Cursor、Copilot 等 AI 编程助手可以按需查询端点详情,而无需将整个规范放入上下文窗口。',
|
||||
'faq.2.q': '支持哪些 OpenAPI 格式?',
|
||||
'faq.2.a': 'AgentFox 支持 OpenAPI 3.x 和 Swagger 2.0 规范。你可以导入 JSON 或 YAML 格式的文档,也可以提供 URL 直接获取。导入时所有 $ref 引用会自动解引用。',
|
||||
'faq.3.q': '能减少多少 Token 消耗?',
|
||||
'faq.3.a': '每次 MCP 工具调用返回 ~200-2,000 tokens 的精准信息,相比全量 API 规范的 10,000+ tokens。对于典型的集成任务,这意味着 80-95% 的 token 消耗降低。',
|
||||
'faq.4.q': '我的 API 文档安全吗?',
|
||||
'faq.4.a': '是的。每个项目拥有独立的 API Key(bcrypt 哈希加密,从不以明文存储)。MCP 端点每次请求都需要认证。用户仪表盘使用 JWT 并自动轮换 token。',
|
||||
'faq.5.q': '兼容哪些 AI 工具?',
|
||||
'faq.5.a': '任何支持 MCP 协议的工具都可以连接 AgentFox,包括 Claude Code、OpenAI Codex CLI、OpenClaw、Gemini CLI、Cursor、GitHub Copilot(通过 MCP 插件)、Antigravity 等。如果你的工具支持 MCP,就能与 AgentFox 配合使用。',
|
||||
'faq.6.q': '可以私有化部署吗?',
|
||||
'faq.6.a': '可以!AgentFox 支持云端和私有化部署。企业版包含完整的 Docker Compose 私有化部署支持,以及 SSO 集成和专属技术支持。',
|
||||
|
||||
// Footer
|
||||
'footer.product': '产品',
|
||||
'footer.features': '特性',
|
||||
'footer.pricing': '定价',
|
||||
'footer.docs': '文档',
|
||||
'footer.changelog': '更新日志',
|
||||
'footer.resources': '资源',
|
||||
'footer.github': 'GitHub',
|
||||
'footer.community': '社区',
|
||||
'footer.blog': '博客',
|
||||
'footer.legal': '法律',
|
||||
'footer.privacy': '隐私政策',
|
||||
'footer.terms': '服务条款',
|
||||
'footer.copyright': '© 2026 AgentFox. 保留所有权利。',
|
||||
'footer.tagline': '为 AI 代理打造的 MCP 驱动 API 文档服务。',
|
||||
|
||||
// ===== Common =====
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确认',
|
||||
'common.delete': '删除',
|
||||
'common.save': '保存',
|
||||
'common.back': '返回',
|
||||
'common.done': '完成',
|
||||
'common.copy': '复制',
|
||||
'common.copied': '已复制',
|
||||
'common.continue': '继续',
|
||||
'common.import': '导入',
|
||||
'common.importing': '导入中...',
|
||||
'common.signOut': '退出登录',
|
||||
'common.signOutConfirm': '确定要退出登录吗?',
|
||||
'common.settings': '设置',
|
||||
'common.modules': '模块',
|
||||
'common.endpoints': '端点',
|
||||
'common.total': '总计',
|
||||
'common.add': '添加',
|
||||
'common.fromUrl': '从 URL',
|
||||
'common.uploadFile': '上传文件',
|
||||
'common.dropFile': '将 OpenAPI 文件拖放到这里',
|
||||
'common.jsonOrYaml': 'JSON 或 YAML',
|
||||
|
||||
// ===== Theme =====
|
||||
'theme.light': '浅色',
|
||||
'theme.dark': '深色',
|
||||
'theme.system': '跟随系统',
|
||||
|
||||
// ===== Auth =====
|
||||
// Login
|
||||
'auth.login.title': '登录 AgentFox',
|
||||
'auth.login.subtitle': '为 LLM 打造的 API 文档服务',
|
||||
'auth.login.email': '邮箱',
|
||||
'auth.login.password': '密码',
|
||||
'auth.login.submit': '登录',
|
||||
'auth.login.submitting': '登录中...',
|
||||
'auth.login.noAccount': '还没有账号?',
|
||||
'auth.login.signUp': '注册',
|
||||
'auth.login.emailRequired': '请输入邮箱',
|
||||
'auth.login.emailInvalid': '请输入有效的邮箱地址',
|
||||
'auth.login.passwordRequired': '请输入密码',
|
||||
'auth.login.passwordPlaceholder': '输入你的密码',
|
||||
'auth.login.or': '或者通过以下方式继续',
|
||||
|
||||
// Branding
|
||||
'auth.productName': 'AgentFox',
|
||||
'auth.slogan': 'LLM 专属 API 文档方案',
|
||||
'auth.feature1': '多级 API 检索,最小化 Token 消耗',
|
||||
'auth.feature2': '秒级导入 OpenAPI 文档',
|
||||
'auth.feature3': '兼容所有 MCP 协议的 LLM 工具',
|
||||
|
||||
// Register
|
||||
'auth.register.title': '创建账号',
|
||||
'auth.register.subtitle': '开始使用 AgentFox',
|
||||
'auth.register.name': '姓名',
|
||||
'auth.register.email': '邮箱',
|
||||
'auth.register.password': '密码',
|
||||
'auth.register.submit': '创建账号',
|
||||
'auth.register.submitting': '创建中...',
|
||||
'auth.register.hasAccount': '已有账号?',
|
||||
'auth.register.signIn': '登录',
|
||||
'auth.register.nameRequired': '请输入姓名',
|
||||
'auth.register.emailRequired': '请输入邮箱',
|
||||
'auth.register.emailInvalid': '请输入有效的邮箱地址',
|
||||
'auth.register.passwordRequired': '请输入密码',
|
||||
'auth.register.passwordMin': '密码至少需要 8 个字符',
|
||||
'auth.register.namePlaceholder': '你的姓名',
|
||||
'auth.register.passwordPlaceholder': '至少 8 个字符',
|
||||
'auth.register.or': '或者通过以下方式继续',
|
||||
|
||||
// OAuth
|
||||
'auth.oauth.google': 'Google',
|
||||
'auth.oauth.github': 'GitHub',
|
||||
'auth.oauth.apple': 'Apple',
|
||||
|
||||
// Callback
|
||||
'auth.callback.loading': '正在完成登录...',
|
||||
'auth.callback.error': '登录失败',
|
||||
'auth.callback.retry': '重试',
|
||||
|
||||
// ===== Dashboard Layout =====
|
||||
'dashboard.layout.projects': '项目',
|
||||
'dashboard.layout.allProjects': '所有项目',
|
||||
'dashboard.layout.onboardingTitle': '欢迎!生成 API Key 以开始使用 MCP 服务。',
|
||||
'dashboard.layout.onboardingDesc': '你需要一个 API Key 来将 LLM 客户端连接到你的项目。',
|
||||
'dashboard.layout.generateApiKey': '生成 API Key',
|
||||
|
||||
// ===== Dashboard Projects =====
|
||||
'dashboard.projects.title': '项目',
|
||||
'dashboard.projects.importBtn': '导入 API 文档',
|
||||
'dashboard.projects.emptyTitle': '暂无项目',
|
||||
'dashboard.projects.emptyDesc': '导入 OpenAPI 文档以开始使用 MCP 驱动的 API 文档服务。',
|
||||
'dashboard.projects.importFirst': '导入你的第一个 API',
|
||||
'dashboard.projects.deleteTitle': '删除项目',
|
||||
'dashboard.projects.deleteDesc': '确定要删除"{name}"吗?这将永久删除所有模块、端点和 MCP 配置。',
|
||||
'dashboard.projects.deleteBtn': '删除项目',
|
||||
|
||||
// ===== Project Detail =====
|
||||
'dashboard.projectDetail.breadcrumbProjects': '项目',
|
||||
'dashboard.projectDetail.notFound': '项目未找到',
|
||||
'dashboard.projectDetail.backToProjects': '返回项目列表',
|
||||
'dashboard.projectDetail.tabMcp': 'MCP',
|
||||
'dashboard.projectDetail.tabDocs': '文档',
|
||||
'dashboard.projectDetail.tabModules': '模块',
|
||||
'dashboard.projectDetail.tabSettings': '设置',
|
||||
|
||||
// ===== Import Dialog =====
|
||||
'dashboard.import.title': '导入 OpenAPI 文档',
|
||||
'dashboard.import.desc': '导入 Swagger 2.0 或 OpenAPI 3.x 文档以创建新项目。',
|
||||
'dashboard.import.successTitle': '导入成功',
|
||||
'dashboard.import.goToProject': '前往项目',
|
||||
|
||||
// ===== Reimport Dialog =====
|
||||
'dashboard.reimport.title': '重新导入 API 文档',
|
||||
'dashboard.reimport.subtitle': '此操作将替换所有现有数据。',
|
||||
'dashboard.reimport.warningTitle': '以下数据将被永久删除:',
|
||||
'dashboard.reimport.warningModules': '{count} 个模块',
|
||||
'dashboard.reimport.warningEndpoints': '{count} 个端点',
|
||||
'dashboard.reimport.warningNote': '将根据导入的文档创建新的模块和端点。API Key 将保持不变。',
|
||||
'dashboard.reimport.importTitle': '导入新文档',
|
||||
'dashboard.reimport.importDesc': '提供 Swagger 2.0 或 OpenAPI 3.x 文档。',
|
||||
'dashboard.reimport.submit': '重新导入',
|
||||
'dashboard.reimport.successTitle': '重新导入成功',
|
||||
'dashboard.reimport.successDesc': 'API 文档已更新。',
|
||||
|
||||
// ===== MCP Integration =====
|
||||
'dashboard.mcp.urlTitle': 'MCP 服务 URL',
|
||||
'dashboard.mcp.urlDesc': '将你的 LLM 客户端连接到此端点。',
|
||||
'dashboard.mcp.configTitle': 'Claude Code / Cursor 配置',
|
||||
'dashboard.mcp.configDesc': '将此内容添加到你的 MCP 客户端配置中。',
|
||||
'dashboard.mcp.keyGenerated': 'API Key 已生成。从',
|
||||
'dashboard.mcp.keyReplace': '复制并替换上方的',
|
||||
'dashboard.mcp.keyAbove': '。',
|
||||
'dashboard.mcp.noKeyWarning': '使用 MCP 前需要先生成 API Key。',
|
||||
'dashboard.mcp.openSettings': '打开设置',
|
||||
'dashboard.mcp.toolsTitle': '可用 MCP 工具',
|
||||
'dashboard.mcp.toolsDesc': '5 个渐进式下钻工具,为最小 token 消耗而设计。',
|
||||
'dashboard.mcp.tool1Desc': '获取项目名称、版本、基础 URL 和模块摘要。首先调用此工具。',
|
||||
'dashboard.mcp.tool2Desc': '列出所有模块及其描述和端点数量。',
|
||||
'dashboard.mcp.tool3Desc': '列出模块中的端点。需提供 moduleId。',
|
||||
'dashboard.mcp.tool4Desc': '获取完整端点详情:参数、请求体、响应。',
|
||||
'dashboard.mcp.tool5Desc': '按关键词搜索所有端点。可选 moduleId 过滤。',
|
||||
|
||||
// ===== Project Settings =====
|
||||
'dashboard.projectSettings.generalTitle': '基本信息',
|
||||
'dashboard.projectSettings.generalDesc': '更新项目名称和描述。',
|
||||
'dashboard.projectSettings.projectName': '项目名称',
|
||||
'dashboard.projectSettings.description': '描述',
|
||||
'dashboard.projectSettings.saveChanges': '保存更改',
|
||||
'dashboard.projectSettings.saved': '已保存',
|
||||
'dashboard.projectSettings.reimportTitle': '重新导入 API 文档',
|
||||
'dashboard.projectSettings.reimportDesc': '使用新的 OpenAPI 文档替换当前 API 文档。这将清除所有现有模块({modules})和端点({endpoints}),然后根据新文档重新创建。',
|
||||
'dashboard.projectSettings.reimportBtn': '重新导入文档',
|
||||
'dashboard.projectSettings.dangerZone': '危险区域',
|
||||
'dashboard.projectSettings.dangerDesc': '永久删除此项目及其所有数据。此操作不可撤销。',
|
||||
'dashboard.projectSettings.deleteProject': '删除项目',
|
||||
'dashboard.projectSettings.deleteTitle': '删除项目',
|
||||
'dashboard.projectSettings.deleteDesc': '永久删除"{name}"?所有模块、端点和 MCP 配置将被移除。',
|
||||
|
||||
// ===== Module Management =====
|
||||
'dashboard.modules.addTitle': '添加手动模块',
|
||||
'dashboard.modules.placeholder': '模块名称',
|
||||
'dashboard.modules.allModules': '所有模块',
|
||||
'dashboard.modules.emptyTitle': '暂无模块',
|
||||
'dashboard.modules.emptyDesc': '导入 API 文档时会自动创建模块。你也可以在上方手动添加模块。',
|
||||
'dashboard.modules.deleteTitle': '删除模块',
|
||||
'dashboard.modules.deleteDesc': '删除"{name}"?这将同时删除其 {count} 个端点。',
|
||||
'dashboard.modules.deleteBtn': '删除模块',
|
||||
|
||||
// ===== Doc Preview =====
|
||||
'dashboard.docs.modules': '模块',
|
||||
'dashboard.docs.noModules': '暂无模块',
|
||||
'dashboard.docs.allEndpoints': '所有端点',
|
||||
'dashboard.docs.noEndpoints': '暂无端点',
|
||||
'dashboard.docs.noEndpointsModule': '此模块暂无端点。',
|
||||
'dashboard.docs.noEndpointsProject': '此项目暂无端点。导入 API 文档以开始使用。',
|
||||
'dashboard.docs.deprecated': '已弃用',
|
||||
'dashboard.docs.operationId': '操作 ID',
|
||||
|
||||
// ===== Schema View =====
|
||||
'dashboard.schema.parameters': '参数',
|
||||
'dashboard.schema.name': '名称',
|
||||
'dashboard.schema.in': '位置',
|
||||
'dashboard.schema.type': '类型',
|
||||
'dashboard.schema.required': '必填',
|
||||
'dashboard.schema.optional': '可选',
|
||||
'dashboard.schema.descriptionCol': '说明',
|
||||
'dashboard.schema.requestBody': '请求体',
|
||||
'dashboard.schema.responses': '响应',
|
||||
'dashboard.schema.noSchema': '无 Schema',
|
||||
'dashboard.schema.ofObjects': '对象数组:',
|
||||
'dashboard.schema.enum': '枚举:',
|
||||
'dashboard.schema.default': '默认值:',
|
||||
'dashboard.schema.nullable': '可空',
|
||||
|
||||
// ===== Settings Dialog =====
|
||||
'dashboard.settings.title': '设置',
|
||||
'dashboard.settings.profileTitle': '个人资料',
|
||||
'dashboard.settings.profileDesc': '管理你的个人信息。',
|
||||
'dashboard.settings.displayName': '显示名称',
|
||||
'dashboard.settings.saveProfile': '保存资料',
|
||||
'dashboard.settings.saving': '保存中...',
|
||||
'dashboard.settings.profileUpdated': '资料已更新',
|
||||
'dashboard.settings.apiKeyTitle': 'API Key',
|
||||
'dashboard.settings.apiKeyDesc': '用于验证所有项目的 MCP 请求。',
|
||||
'dashboard.settings.keySaveWarning': '请立即保存此密钥 — 之后将无法再次查看。',
|
||||
'dashboard.settings.copyToClipboard': '复制到剪贴板',
|
||||
'dashboard.settings.keySaved': '我已保存,继续',
|
||||
'dashboard.settings.noKey': '尚未生成 API Key。生成一个以使用 MCP 服务。',
|
||||
'dashboard.settings.generateKey': '生成 API Key',
|
||||
'dashboard.settings.generating': '生成中...',
|
||||
'dashboard.settings.rotateKey': '轮换 API Key',
|
||||
'dashboard.settings.rotateTitle': '轮换 API Key',
|
||||
'dashboard.settings.rotateDesc': '当前 API Key 将立即失效。所有使用旧密钥的 MCP 客户端将停止工作。将生成新的密钥。',
|
||||
'dashboard.settings.rotateConfirm': '轮换密钥',
|
||||
'dashboard.settings.passwordPrompt': '输入密码以{action} API Key。',
|
||||
'dashboard.settings.passwordPromptCopy': '复制',
|
||||
'dashboard.settings.passwordPromptReveal': '查看',
|
||||
'dashboard.settings.currentPassword': '当前密码',
|
||||
'dashboard.settings.verifying': '验证中...',
|
||||
'dashboard.settings.changePasswordTitle': '修改密码',
|
||||
'dashboard.settings.changePasswordDesc': '更新密码以保护账号安全。',
|
||||
'dashboard.settings.currentPasswordLabel': '当前密码',
|
||||
'dashboard.settings.newPasswordLabel': '新密码',
|
||||
'dashboard.settings.confirmPasswordLabel': '确认新密码',
|
||||
'dashboard.settings.changePassword': '修改密码',
|
||||
'dashboard.settings.changingPassword': '修改中...',
|
||||
'dashboard.settings.passwordMismatch': '两次输入的密码不一致',
|
||||
'dashboard.settings.passwordChanged': '密码修改成功',
|
||||
'dashboard.settings.enterCurrentPassword': '输入当前密码',
|
||||
'dashboard.settings.atLeast8Chars': '至少 8 个字符',
|
||||
'dashboard.settings.confirmNewPassword': '确认新密码',
|
||||
};
|
||||
|
||||
export default zh;
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
type ImportResult = {
|
||||
@@ -21,6 +22,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setFileName(file.name);
|
||||
@@ -63,14 +65,14 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
{!result ? (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">Import OpenAPI Document</h2>
|
||||
<p className="section-desc">Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.</p>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">{t('dashboard.import.title')}</h2>
|
||||
<p className="section-desc">{t('dashboard.import.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary max-w-fit border border-border-muted">
|
||||
<button onClick={() => setMode('url')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>From URL</button>
|
||||
<button onClick={() => setMode('file')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>Upload File</button>
|
||||
<button onClick={() => setMode('url')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>{t('common.fromUrl')}</button>
|
||||
<button onClick={() => setMode('file')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>{t('common.uploadFile')}</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
@@ -93,8 +95,8 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
<p className="text-[13px] text-text-primary font-medium">{fileName}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[13px] text-text-secondary">Drop your OpenAPI file here</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">JSON or YAML</p>
|
||||
<p className="text-[13px] text-text-secondary">{t('common.dropFile')}</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">{t('common.jsonOrYaml')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -108,11 +110,11 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<button onClick={onClose} className="btn-ghost">Cancel</button>
|
||||
<button onClick={onClose} className="btn-ghost">{t('common.cancel')}</button>
|
||||
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
|
||||
{loading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Importing...</>
|
||||
) : 'Import'}
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> {t('common.importing')}</>
|
||||
) : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +125,7 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
<svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">Import Successful</h2>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">{t('dashboard.import.successTitle')}</h2>
|
||||
<p className="text-[13px] text-text-muted">{result.project.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,16 +133,16 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.modules}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">Modules</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">{t('common.modules')}</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.endpoints}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">Endpoints</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">{t('common.endpoints')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">Go to Project</button>
|
||||
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">{t('dashboard.import.goToProject')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { Navigate, Outlet, NavLink, Link, useLocation, useParams, useOutletContext } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
import LanguageToggle from '../components/LanguageToggle';
|
||||
import SettingsDialog from '../components/SettingsDialog';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
|
||||
@@ -19,6 +21,7 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { t } = useI18n();
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,7 +76,7 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
{t('common.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); setConfirmLogout(true); }}
|
||||
@@ -83,7 +86,7 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
{t('common.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,9 +95,9 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
open={confirmLogout}
|
||||
onConfirm={() => { setConfirmLogout(false); logout(); }}
|
||||
onCancel={() => setConfirmLogout(false)}
|
||||
title="Sign Out"
|
||||
description="Are you sure you want to sign out?"
|
||||
confirmText="Sign Out"
|
||||
title={t('common.signOut')}
|
||||
description={t('common.signOutConfirm')}
|
||||
confirmText={t('common.signOut')}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
@@ -105,6 +108,7 @@ function ProjectSidebar() {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const activeProjectId = params.id;
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
@@ -117,7 +121,7 @@ function ProjectSidebar() {
|
||||
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
|
||||
{/* Sidebar header */}
|
||||
<div className="px-4 h-12 flex items-center justify-between border-b border-border-muted shrink-0">
|
||||
<span className="section-label">Projects</span>
|
||||
<span className="section-label">{t('dashboard.layout.projects')}</span>
|
||||
</div>
|
||||
|
||||
{/* Project list */}
|
||||
@@ -134,7 +138,7 @@ function ProjectSidebar() {
|
||||
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
All Projects
|
||||
{t('dashboard.layout.allProjects')}
|
||||
</NavLink>
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
@@ -175,6 +179,7 @@ function ProjectSidebar() {
|
||||
|
||||
function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem('agent-fox-onboarding-dismissed') === 'true');
|
||||
const { t } = useI18n();
|
||||
const { data: keyStatus } = useQuery({
|
||||
queryKey: ['api-key-status'],
|
||||
queryFn: () => apiFetch<{ hasKey: boolean }>('/auth/api-key/status'),
|
||||
@@ -188,11 +193,11 @@ function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
<div className="mb-6 p-4 rounded-xl bg-accent-muted border border-accent/20 flex items-center gap-4 animate-fade-in">
|
||||
<svg className="w-5 h-5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-text-primary font-medium">Welcome! Generate an API key to start using MCP services.</p>
|
||||
<p className="text-[12px] text-text-secondary mt-0.5">You'll need an API key to connect your LLM client to your projects.</p>
|
||||
<p className="text-[13px] text-text-primary font-medium">{t('dashboard.layout.onboardingTitle')}</p>
|
||||
<p className="text-[12px] text-text-secondary mt-0.5">{t('dashboard.layout.onboardingDesc')}</p>
|
||||
</div>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5">
|
||||
Generate API Key
|
||||
{t('dashboard.layout.generateApiKey')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDismissed(true); localStorage.setItem('agent-fox-onboarding-dismissed', 'true'); }}
|
||||
@@ -206,6 +211,7 @@ function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
|
||||
export default function Layout() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
@@ -243,8 +249,9 @@ export default function Layout() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: theme toggle + user */}
|
||||
{/* Right: language toggle + theme toggle + user */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
<UserDropdown user={user} logout={logout} onOpenSettings={() => setSettingsOpen(true)} />
|
||||
@@ -285,7 +292,7 @@ export default function Layout() {
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Projects
|
||||
{t('dashboard.layout.projects')}
|
||||
</NavLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -20,12 +20,12 @@ export default function Login() {
|
||||
const validate = () => {
|
||||
const errors: { email?: string; password?: string } = {};
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
errors.email = t('auth.login.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
errors.email = t('auth.login.emailInvalid');
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
errors.password = t('auth.login.passwordRequired');
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -48,12 +48,9 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left panel — branding (hidden on mobile) */}
|
||||
<AuthBranding />
|
||||
|
||||
{/* Right panel — form */}
|
||||
<div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
|
||||
{/* Subtle grid background */}
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
@@ -65,12 +62,10 @@ export default function Login() {
|
||||
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||
<MobileBranding />
|
||||
|
||||
{/* Title (desktop) */}
|
||||
<div className="hidden lg:block mb-8">
|
||||
<h1 className="text-2xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.login.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card p-6 shadow-md">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
@@ -102,7 +97,7 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })); }}
|
||||
className={`input-base ${fieldErrors.password ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('auth.login.passwordPlaceholder')}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
@@ -118,14 +113,12 @@ export default function Login() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-border-default" />
|
||||
<span className="text-[12px] text-text-muted">{t('auth.login.or')}</span>
|
||||
<div className="flex-1 h-px bg-border-default" />
|
||||
</div>
|
||||
|
||||
{/* OAuth buttons */}
|
||||
<OAuthButtons />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import DocPreview from './tabs/DocPreview';
|
||||
import ModuleManagement from './tabs/ModuleManagement';
|
||||
import McpIntegration from './tabs/McpIntegration';
|
||||
@@ -17,10 +18,10 @@ type ProjectData = {
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: 'mcp', label: 'MCP', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' },
|
||||
{ key: 'docs', label: 'Documentation', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ key: 'modules', label: 'Modules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ key: 'settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
|
||||
{ key: 'mcp', labelKey: 'dashboard.projectDetail.tabMcp', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' },
|
||||
{ key: 'docs', labelKey: 'dashboard.projectDetail.tabDocs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ key: 'modules', labelKey: 'dashboard.projectDetail.tabModules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ key: 'settings', labelKey: 'dashboard.projectDetail.tabSettings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof tabs)[number]['key'];
|
||||
@@ -28,6 +29,7 @@ type TabKey = (typeof tabs)[number]['key'];
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('mcp');
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: project, isLoading } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
@@ -52,8 +54,8 @@ export default function ProjectDetail() {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<svg className="w-10 h-10 mx-auto text-text-muted mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<p className="text-text-muted text-sm">Project not found</p>
|
||||
<Link to="/dashboard" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
|
||||
<p className="text-text-muted text-sm">{t('dashboard.projectDetail.notFound')}</p>
|
||||
<Link to="/dashboard" className="text-accent hover:underline text-sm mt-2 inline-block">{t('dashboard.projectDetail.backToProjects')}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +64,7 @@ export default function ProjectDetail() {
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-text-muted mb-5">
|
||||
<Link to="/dashboard" className="hover:text-text-primary transition-colors">Projects</Link>
|
||||
<Link to="/dashboard" className="hover:text-text-primary transition-colors">{t('dashboard.projectDetail.breadcrumbProjects')}</Link>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-text-secondary font-medium">{project.name}</span>
|
||||
</div>
|
||||
@@ -75,7 +77,7 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-4">
|
||||
<Badge>OpenAPI {project.openApiVersion}</Badge>
|
||||
<Badge>{project._count.endpoints} endpoints</Badge>
|
||||
<Badge>{project._count.endpoints} {t('common.endpoints')}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +94,7 @@ export default function ProjectDetail() {
|
||||
}`}
|
||||
>
|
||||
<svg className="w-[14px] h-[14px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d={tab.icon} /></svg>
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="hidden sm:inline">{t(tab.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import ImportDialog from './ImportDialog';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
@@ -17,6 +18,7 @@ export default function Projects() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
@@ -31,12 +33,12 @@ export default function Projects() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Projects</h2>
|
||||
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{t('dashboard.projects.title')}</h2>
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Import API Doc
|
||||
{t('dashboard.projects.importBtn')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,14 +63,14 @@ export default function Projects() {
|
||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
}
|
||||
title="No projects yet"
|
||||
description="Import an OpenAPI document to get started with MCP-powered API documentation."
|
||||
title={t('dashboard.projects.emptyTitle')}
|
||||
description={t('dashboard.projects.emptyDesc')}
|
||||
action={
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Import Your First API
|
||||
{t('dashboard.projects.importFirst')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -81,14 +83,14 @@ export default function Projects() {
|
||||
{p.description && <p className="text-[13px] text-text-muted mt-1.5 line-clamp-2 leading-relaxed">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
<Badge>OpenAPI {p.openApiVersion}</Badge>
|
||||
<Badge>{p._count.modules} modules</Badge>
|
||||
<Badge>{p._count.endpoints} endpoints</Badge>
|
||||
<Badge>{p._count.modules} {t('common.modules')}</Badge>
|
||||
<Badge>{p._count.endpoints} {t('common.endpoints')}</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setDeleteTarget(p); }}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-md text-text-muted opacity-0 group-hover:opacity-100 hover:text-danger hover:bg-danger-muted transition-all"
|
||||
title="Delete project"
|
||||
title={t('dashboard.projects.deleteBtn')}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -105,9 +107,9 @@ export default function Projects() {
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
title="Delete project"
|
||||
description={`Are you sure you want to delete "${deleteTarget?.name}"? This will permanently remove all modules, endpoints, and MCP configuration.`}
|
||||
confirmText="Delete"
|
||||
title={t('dashboard.projects.deleteTitle')}
|
||||
description={t('dashboard.projects.deleteDesc', { name: deleteTarget?.name || '' })}
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,16 +24,18 @@ export default function Register() {
|
||||
|
||||
const validate = () => {
|
||||
const errors: { name?: string; email?: string; password?: string } = {};
|
||||
if (!name.trim()) errors.name = 'Name is required';
|
||||
if (!name.trim()) {
|
||||
errors.name = t('auth.register.nameRequired');
|
||||
}
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
errors.email = t('auth.register.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
errors.email = t('auth.register.emailInvalid');
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
errors.password = t('auth.register.passwordRequired');
|
||||
} else if (password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
errors.password = t('auth.register.passwordMin');
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -58,10 +60,8 @@ export default function Register() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left panel — branding (hidden on mobile) */}
|
||||
<AuthBranding />
|
||||
|
||||
{/* Right panel — form */}
|
||||
<div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||
@@ -74,7 +74,6 @@ export default function Register() {
|
||||
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||
<MobileBranding />
|
||||
|
||||
{/* Title (desktop) */}
|
||||
<div className="hidden lg:block mb-8">
|
||||
<h1 className="text-2xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.register.title')}</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">{t('auth.register.subtitle')}</p>
|
||||
@@ -95,7 +94,7 @@ export default function Register() {
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
|
||||
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
|
||||
placeholder="Your name"
|
||||
placeholder={t('auth.register.namePlaceholder')}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
@@ -127,7 +126,7 @@ export default function Register() {
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
|
||||
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
|
||||
placeholder="At least 8 characters"
|
||||
placeholder={t('auth.register.passwordPlaceholder')}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
@@ -143,14 +142,12 @@ export default function Register() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-border-default" />
|
||||
<span className="text-[12px] text-text-muted">{t('auth.register.or')}</span>
|
||||
<div className="flex-1 h-px bg-border-default" />
|
||||
</div>
|
||||
|
||||
{/* OAuth buttons */}
|
||||
<OAuthButtons />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
type ReimportResult = {
|
||||
@@ -26,6 +27,7 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
const [result, setResult] = useState<ReimportResult | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setFileName(file.name);
|
||||
@@ -68,8 +70,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Re-import API Document</h2>
|
||||
<p className="text-sm text-text-muted mt-1">This action will replace all existing data.</p>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('dashboard.reimport.title')}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">{t('dashboard.reimport.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
|
||||
@@ -78,19 +80,19 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-warning mb-1">The following data will be permanently deleted:</p>
|
||||
<p className="font-medium text-warning mb-1">{t('dashboard.reimport.warningTitle')}</p>
|
||||
<ul className="text-text-secondary space-y-1">
|
||||
<li>{currentStats.modules} module{currentStats.modules !== 1 ? 's' : ''}</li>
|
||||
<li>{currentStats.endpoints} endpoint{currentStats.endpoints !== 1 ? 's' : ''}</li>
|
||||
<li>{t('dashboard.reimport.warningModules', { count: currentStats.modules })}</li>
|
||||
<li>{t('dashboard.reimport.warningEndpoints', { count: currentStats.endpoints })}</li>
|
||||
</ul>
|
||||
<p className="text-text-muted mt-2">New modules and endpoints will be created from the imported document. The API key will remain unchanged.</p>
|
||||
<p className="text-text-muted mt-2">{t('dashboard.reimport.warningNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="btn-ghost">Cancel</button>
|
||||
<button onClick={() => setStep('import')} className="btn-primary">Continue</button>
|
||||
<button onClick={onClose} className="btn-ghost">{t('common.cancel')}</button>
|
||||
<button onClick={() => setStep('import')} className="btn-primary">{t('common.continue')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -98,13 +100,13 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
{step === 'import' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Import New Document</h2>
|
||||
<p className="text-sm text-text-muted mt-1">Provide a Swagger 2.0 or OpenAPI 3.x document.</p>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('dashboard.reimport.importTitle')}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">{t('dashboard.reimport.importDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-bg-tertiary max-w-fit">
|
||||
<button onClick={() => setMode('url')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>From URL</button>
|
||||
<button onClick={() => setMode('file')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>Upload File</button>
|
||||
<button onClick={() => setMode('url')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>{t('common.fromUrl')}</button>
|
||||
<button onClick={() => setMode('file')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>{t('common.uploadFile')}</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
@@ -127,8 +129,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
<p className="text-sm text-text-primary font-medium">{fileName}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-text-secondary">Drop your OpenAPI file here</p>
|
||||
<p className="text-xs text-text-muted mt-1">JSON or YAML</p>
|
||||
<p className="text-sm text-text-secondary">{t('common.dropFile')}</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t('common.jsonOrYaml')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,14 +139,14 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
{error && <div className="p-3 rounded-lg bg-danger-muted text-danger text-sm">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setStep('confirm')} className="btn-ghost">Back</button>
|
||||
<button onClick={() => setStep('confirm')} className="btn-ghost">{t('common.back')}</button>
|
||||
<button onClick={handleReimport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
Importing...
|
||||
{t('common.importing')}
|
||||
</>
|
||||
) : 'Re-import'}
|
||||
) : t('dashboard.reimport.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,24 +159,24 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
<svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Re-import Successful</h2>
|
||||
<p className="text-sm text-text-muted">API documentation has been updated.</p>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('dashboard.reimport.successTitle')}</h2>
|
||||
<p className="text-sm text-text-muted">{t('dashboard.reimport.successDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="card p-3 text-center">
|
||||
<div className="text-2xl font-semibold text-text-primary">{result.stats.modules}</div>
|
||||
<div className="text-xs text-text-muted">Modules</div>
|
||||
<div className="text-xs text-text-muted">{t('common.modules')}</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center">
|
||||
<div className="text-2xl font-semibold text-text-primary">{result.stats.endpoints}</div>
|
||||
<div className="text-xs text-text-muted">Endpoints</div>
|
||||
<div className="text-xs text-text-muted">{t('common.endpoints')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { onSuccess(); onClose(); }} className="btn-primary">Done</button>
|
||||
<button onClick={() => { onSuccess(); onClose(); }} className="btn-primary">{t('common.done')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ function LandingUserDropdown({ user, logout }: { user: { name: string; email: st
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { t } = useI18n();
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,7 +64,7 @@ function LandingUserDropdown({ user, logout }: { user: { name: string; email: st
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
{t('common.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,9 +73,9 @@ function LandingUserDropdown({ user, logout }: { user: { name: string; email: st
|
||||
open={confirmLogout}
|
||||
onConfirm={() => { setConfirmLogout(false); logout(); }}
|
||||
onCancel={() => setConfirmLogout(false)}
|
||||
title="Sign Out"
|
||||
description="Are you sure you want to sign out?"
|
||||
confirmText="Sign Out"
|
||||
title={t('common.signOut')}
|
||||
description={t('common.signOutConfirm')}
|
||||
confirmText={t('common.signOut')}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
@@ -252,7 +253,7 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<H
|
||||
</div>
|
||||
<span className="text-lg text-text-primary font-medium flex-1">{user.name}</span>
|
||||
<button onClick={() => { setMobileOpen(false); setConfirmLogout(true); }} className="text-sm text-text-muted hover:text-danger transition-colors">
|
||||
Sign Out
|
||||
{t('common.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,9 +265,9 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<H
|
||||
open={confirmLogout}
|
||||
onConfirm={() => { setConfirmLogout(false); logout(); }}
|
||||
onCancel={() => setConfirmLogout(false)}
|
||||
title="Sign Out"
|
||||
description="Are you sure you want to sign out?"
|
||||
confirmText="Sign Out"
|
||||
title={t('common.signOut')}
|
||||
description={t('common.signOutConfirm')}
|
||||
confirmText={t('common.signOut')}
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useI18n, tk } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
type PlanKey = 'free' | 'pro' | 'enterprise';
|
||||
@@ -48,30 +48,30 @@ export default function PricingSection() {
|
||||
}}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
{featured && t(`pricing.${key}.badge`) && (
|
||||
{featured && t(tk(`pricing.${key}.badge`)) && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full text-[11px] font-semibold text-white"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
{t(`pricing.${key}.badge`)}
|
||||
{t(tk(`pricing.${key}.badge`))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan name + price */}
|
||||
<div className="mb-5">
|
||||
<h3 className="text-base font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||
{t(`pricing.${key}.name`)}
|
||||
{t(tk(`pricing.${key}.name`))}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t(`pricing.${key}.price`)}
|
||||
{t(tk(`pricing.${key}.price`))}
|
||||
</span>
|
||||
{t(`pricing.${key}.period`) && (
|
||||
{t(tk(`pricing.${key}.period`)) && (
|
||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{t(`pricing.${key}.period`)}
|
||||
{t(tk(`pricing.${key}.period`))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(`pricing.${key}.desc`)}
|
||||
{t(tk(`pricing.${key}.desc`))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function PricingSection() {
|
||||
<svg className="w-4 h-4 shrink-0" style={{ color: 'var(--fox-amber)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{t(`pricing.${key}.f${j + 1}`)}
|
||||
{t(tk(`pricing.${key}.f${j + 1}`))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -93,7 +93,7 @@ export default function PricingSection() {
|
||||
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 cursor-pointer`}
|
||||
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
|
||||
>
|
||||
{t(`pricing.${key}.cta`)}
|
||||
{t(tk(`pricing.${key}.cta`))}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
@@ -106,7 +106,7 @@ export default function PricingSection() {
|
||||
: { background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }
|
||||
}
|
||||
>
|
||||
{t(`pricing.${key}.cta`)}
|
||||
{t(tk(`pricing.${key}.cta`))}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import Badge from '../../components/Badge';
|
||||
import Skeleton from '../../components/Skeleton';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
@@ -17,6 +18,7 @@ const methodVariant: Record<string, 'get' | 'post' | 'put' | 'delete' | 'patch'>
|
||||
export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: modules, isLoading: modulesLoading } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
@@ -41,11 +43,11 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
{/* Module sidebar */}
|
||||
<div className="w-52 shrink-0 overflow-y-auto">
|
||||
<div>
|
||||
<p className="section-label px-3 mb-3">Modules</p>
|
||||
<p className="section-label px-3 mb-3">{t('dashboard.docs.modules')}</p>
|
||||
{modulesLoading ? (
|
||||
<div className="space-y-1.5 px-1">{[1,2,3].map(i => <Skeleton key={i} className="h-9 w-full" />)}</div>
|
||||
) : modules?.length === 0 ? (
|
||||
<p className="text-sm text-text-muted px-3">No modules</p>
|
||||
<p className="text-sm text-text-muted px-3">{t('dashboard.docs.noModules')}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<button onClick={() => setSelectedModule(null)}
|
||||
@@ -54,7 +56,7 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
? 'bg-accent-muted text-accent font-medium'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}>
|
||||
All endpoints <span className="text-text-muted ml-1">{totalEndpoints}</span>
|
||||
{t('dashboard.docs.allEndpoints')} <span className="text-text-muted ml-1">{totalEndpoints}</span>
|
||||
</button>
|
||||
{modules?.map((m) => (
|
||||
<button key={m.id} onClick={() => setSelectedModule(m.id)}
|
||||
@@ -82,8 +84,8 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
<path d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.757 8.257" />
|
||||
</svg>
|
||||
}
|
||||
title="No endpoints"
|
||||
description={selectedModule ? "This module has no endpoints." : "No endpoints in this project yet. Import an API document to get started."}
|
||||
title={t('dashboard.docs.noEndpoints')}
|
||||
description={selectedModule ? t('dashboard.docs.noEndpointsModule') : t('dashboard.docs.noEndpointsProject')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5 stagger-children">
|
||||
@@ -96,7 +98,7 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
<Badge variant={methodVariant[ep.method] || 'default'}>{ep.method}</Badge>
|
||||
<code className="text-[13px] text-text-primary font-mono truncate">{ep.path}</code>
|
||||
{ep.summary && <span className="text-[13px] text-text-muted ml-auto truncate max-w-[240px] hidden lg:block">{ep.summary}</span>}
|
||||
{ep.deprecated && <Badge variant="warning">deprecated</Badge>}
|
||||
{ep.deprecated && <Badge variant="warning">{t('dashboard.docs.deprecated')}</Badge>}
|
||||
<svg className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform duration-200 ${expandedEndpoint === ep.id ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{expandedEndpoint === ep.id && endpointDetail && (
|
||||
@@ -104,7 +106,7 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
{endpointDetail.description && <p className="text-[13px] text-text-secondary leading-relaxed">{endpointDetail.description}</p>}
|
||||
{endpointDetail.operationId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="section-label">Operation ID</span>
|
||||
<span className="section-label">{t('dashboard.docs.operationId')}</span>
|
||||
<code className="text-xs font-mono text-text-secondary bg-bg-tertiary px-1.5 py-0.5 rounded">{endpointDetail.operationId}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useLayoutContext } from '../Layout';
|
||||
|
||||
type Project = { id: string; name: string };
|
||||
@@ -8,6 +9,7 @@ type Project = { id: string; name: string };
|
||||
export default function McpIntegration({ project }: { project: Project }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const { onOpenSettings } = useLayoutContext();
|
||||
const { t } = useI18n();
|
||||
const mcpHost = window.location.hostname;
|
||||
const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`;
|
||||
|
||||
@@ -37,15 +39,15 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* MCP URL */}
|
||||
<section>
|
||||
<p className="section-title">MCP Service URL</p>
|
||||
<p className="section-desc mb-3">Connect your LLM client to this endpoint.</p>
|
||||
<p className="section-title">{t('dashboard.mcp.urlTitle')}</p>
|
||||
<p className="section-desc mb-3">{t('dashboard.mcp.urlDesc')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-primary truncate">{mcpUrl}</code>
|
||||
<button onClick={() => copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
|
||||
{copied === 'url' ? (
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Copied</>
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('common.copied')}</>
|
||||
) : (
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> Copy</>
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {t('common.copy')}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,12 +55,12 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
|
||||
{/* Config snippet */}
|
||||
<section>
|
||||
<p className="section-title">Configuration for Claude Code / Cursor</p>
|
||||
<p className="section-desc mb-3">Add this to your MCP client configuration.</p>
|
||||
<p className="section-title">{t('dashboard.mcp.configTitle')}</p>
|
||||
<p className="section-desc mb-3">{t('dashboard.mcp.configDesc')}</p>
|
||||
<div className="relative">
|
||||
<pre className="code-block text-xs">{configSnippet}</pre>
|
||||
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn absolute top-2.5 right-2.5">
|
||||
{copied === 'config' ? 'Copied!' : 'Copy'}
|
||||
{copied === 'config' ? `${t('common.copied')}!` : t('common.copy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -69,17 +71,17 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
API key generated. Copy it from{' '}
|
||||
<button onClick={onOpenSettings} className="text-accent hover:underline font-medium">Settings</button>
|
||||
{' '}and replace <code className="text-xs font-mono bg-bg-inset px-1 py-0.5 rounded"><your-api-key></code> above.
|
||||
{t('dashboard.mcp.keyGenerated')}{' '}
|
||||
<button onClick={onOpenSettings} className="text-accent hover:underline font-medium">{t('common.settings')}</button>
|
||||
{' '}{t('dashboard.mcp.keyReplace')} <code className="text-xs font-mono bg-bg-inset px-1 py-0.5 rounded"><your-api-key></code> {t('dashboard.mcp.keyAbove')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary flex-1">You need to generate an API key before using MCP.</p>
|
||||
<p className="text-[13px] text-text-secondary flex-1">{t('dashboard.mcp.noKeyWarning')}</p>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
|
||||
Open Settings
|
||||
{t('dashboard.mcp.openSettings')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -89,21 +91,21 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
|
||||
{/* Available tools */}
|
||||
<section>
|
||||
<p className="section-title">Available MCP Tools</p>
|
||||
<p className="section-desc mb-3">5 tools for progressive drill-down, designed for minimal token usage.</p>
|
||||
<p className="section-title">{t('dashboard.mcp.toolsTitle')}</p>
|
||||
<p className="section-desc mb-3">{t('dashboard.mcp.toolsDesc')}</p>
|
||||
<div className="space-y-1.5 stagger-children">
|
||||
{[
|
||||
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.', num: '1' },
|
||||
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.', num: '2' },
|
||||
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.', num: '3' },
|
||||
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.', num: '4' },
|
||||
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.', num: '5' },
|
||||
].map((t) => (
|
||||
<div key={t.name} className="card px-4 py-3 flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full bg-accent-muted text-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">{t.num}</span>
|
||||
{ name: 'get_project_overview', desc: t('dashboard.mcp.tool1Desc'), num: '1' },
|
||||
{ name: 'list_modules', desc: t('dashboard.mcp.tool2Desc'), num: '2' },
|
||||
{ name: 'list_endpoints', desc: t('dashboard.mcp.tool3Desc'), num: '3' },
|
||||
{ name: 'get_endpoint_detail', desc: t('dashboard.mcp.tool4Desc'), num: '4' },
|
||||
{ name: 'search_endpoints', desc: t('dashboard.mcp.tool5Desc'), num: '5' },
|
||||
].map((tool) => (
|
||||
<div key={tool.name} className="card px-4 py-3 flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full bg-accent-muted text-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">{tool.num}</span>
|
||||
<div className="min-w-0">
|
||||
<code className="text-[13px] font-mono font-medium text-accent">{t.name}</code>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t.desc}</p>
|
||||
<code className="text-[13px] font-mono font-medium text-accent">{tool.name}</code>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{tool.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import Badge from '../../components/Badge';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
@@ -12,6 +13,7 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
const [newModuleName, setNewModuleName] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Module | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: modules, isLoading } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
@@ -32,11 +34,11 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Add module */}
|
||||
<section>
|
||||
<p className="section-label mb-3">Add Manual Module</p>
|
||||
<p className="section-label mb-3">{t('dashboard.modules.addTitle')}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Module name"
|
||||
placeholder={t('dashboard.modules.placeholder')}
|
||||
value={newModuleName}
|
||||
onChange={(e) => setNewModuleName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newModuleName && createMutation.mutate(newModuleName)}
|
||||
@@ -44,7 +46,7 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName} className="btn-primary shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 4v16m8-8H4" /></svg>
|
||||
Add
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -52,8 +54,8 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
{/* Module list */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="section-label">All Modules</p>
|
||||
{modules && <span className="text-xs text-text-muted">{modules.length} total</span>}
|
||||
<p className="section-label">{t('dashboard.modules.allModules')}</p>
|
||||
{modules && <span className="text-xs text-text-muted">{modules.length} {t('common.total')}</span>}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -65,8 +67,8 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
<path d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25z" />
|
||||
</svg>
|
||||
}
|
||||
title="No modules yet"
|
||||
description="Modules are automatically created when you import an API document. You can also add manual modules above."
|
||||
title={t('dashboard.modules.emptyTitle')}
|
||||
description={t('dashboard.modules.emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5 stagger-children">
|
||||
@@ -77,11 +79,11 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
<Badge>{m.source}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs text-text-muted">{m._count.endpoints} endpoints</span>
|
||||
<span className="text-xs text-text-muted">{m._count.endpoints} {t('common.endpoints')}</span>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(m)}
|
||||
className="p-1.5 rounded-md text-text-muted opacity-0 group-hover:opacity-100 hover:text-danger hover:bg-danger-muted transition-all"
|
||||
title="Delete module"
|
||||
title={t('dashboard.modules.deleteBtn')}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -98,9 +100,9 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
title="Delete module"
|
||||
description={`Delete "${deleteTarget?.name}"? This will also remove its ${deleteTarget?._count.endpoints ?? 0} endpoints.`}
|
||||
confirmText="Delete"
|
||||
title={t('dashboard.modules.deleteTitle')}
|
||||
description={t('dashboard.modules.deleteDesc', { name: deleteTarget?.name || '', count: deleteTarget?._count.endpoints ?? 0 })}
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import ReimportDialog from '../ReimportDialog';
|
||||
|
||||
@@ -15,6 +16,7 @@ export default function ProjectSettings({ project }: { project: Project }) {
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description: description || undefined }) }),
|
||||
@@ -41,36 +43,36 @@ export default function ProjectSettings({ project }: { project: Project }) {
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* General */}
|
||||
<section>
|
||||
<p className="section-title">General</p>
|
||||
<p className="section-desc mb-4">Update your project name and description.</p>
|
||||
<p className="section-title">{t('dashboard.projectSettings.generalTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.projectSettings.generalDesc')}</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Project Name</label>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.projectSettings.projectName')}</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Description</label>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.projectSettings.description')}</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="input-base resize-none" />
|
||||
</div>
|
||||
<button onClick={() => updateMutation.mutate()} className="btn-primary">
|
||||
{saveSuccess ? (
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Saved</>
|
||||
) : 'Save Changes'}
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('dashboard.projectSettings.saved')}</>
|
||||
) : t('dashboard.projectSettings.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Re-import */}
|
||||
<section className="border-t border-border-default pt-8">
|
||||
<p className="section-title">Re-import API Document</p>
|
||||
<p className="section-title">{t('dashboard.projectSettings.reimportTitle')}</p>
|
||||
<p className="section-desc mb-4">
|
||||
Replace the current API documentation with a new OpenAPI document. This will clear all existing modules ({project._count.modules}) and endpoints ({project._count.endpoints}), then recreate them from the new document.
|
||||
{t('dashboard.projectSettings.reimportDesc', { modules: project._count.modules, endpoints: project._count.endpoints })}
|
||||
</p>
|
||||
<button onClick={() => setShowReimport(true)} className="btn-outline">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Re-import Document
|
||||
{t('dashboard.projectSettings.reimportBtn')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -78,19 +80,19 @@ export default function ProjectSettings({ project }: { project: Project }) {
|
||||
<section className="border border-danger/15 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="section-title" style={{ color: 'var(--danger)' }}>Danger Zone</p>
|
||||
<p className="section-title" style={{ color: 'var(--danger)' }}>{t('dashboard.projectSettings.dangerZone')}</p>
|
||||
</div>
|
||||
<p className="text-[13px] text-text-muted">Permanently delete this project and all its data. This action cannot be undone.</p>
|
||||
<button onClick={() => setShowDelete(true)} className="btn-danger">Delete Project</button>
|
||||
<p className="text-[13px] text-text-muted">{t('dashboard.projectSettings.dangerDesc')}</p>
|
||||
<button onClick={() => setShowDelete(true)} className="btn-danger">{t('dashboard.projectSettings.deleteProject')}</button>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
title="Delete project"
|
||||
description={`Permanently delete "${project.name}"? All modules, endpoints, and MCP configuration will be removed.`}
|
||||
confirmText="Delete"
|
||||
title={t('dashboard.projectSettings.deleteTitle')}
|
||||
description={t('dashboard.projectSettings.deleteDesc', { name: project.name })}
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user