Compare commits
17 Commits
7e691a8100
...
a9a7216447
| Author | SHA1 | Date | |
|---|---|---|---|
| a9a7216447 | |||
| 8ed857c31c | |||
| 9b41878ae7 | |||
| eacaa5be05 | |||
| 0bab0ecb93 | |||
| db4e5540ad | |||
| a7027c8aaa | |||
| 9316795e4f | |||
| 0a48152e0f | |||
| 6d633eeac4 | |||
| 7f44bc8e32 | |||
| 2d07ac6cd4 | |||
| 67295c22d1 | |||
| dace447a14 | |||
| 3c53bf08bb | |||
| 4b3a9481c6 | |||
| 1712b25748 |
@@ -10,3 +10,9 @@ SERVER_PORT=3000
|
||||
MCP_PORT=3001
|
||||
WEB_PORT=5173
|
||||
REDIS_URL=redis://localhost:6379
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_KEY_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ docker-compose.override.yml
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Prisma
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
1468
docs/superpowers/plans/2026-04-03-login-page-oauth.md
Normal file
1468
docs/superpowers/plans/2026-04-03-login-page-oauth.md
Normal file
File diff suppressed because it is too large
Load Diff
144
docs/superpowers/specs/2026-04-03-login-page-oauth-design.md
Normal file
144
docs/superpowers/specs/2026-04-03-login-page-oauth-design.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Login Page Redesign + OAuth Support
|
||||
|
||||
## Overview
|
||||
|
||||
Redesign the login/register pages with a left-right split layout featuring prominent branding, and add Google/GitHub/Apple OAuth login via standard server-side redirect flow.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Layout
|
||||
|
||||
- **Desktop**: 50/50 left-right split
|
||||
- **Mobile**: Brand area hidden or collapsed to compact top banner; form area full-width
|
||||
|
||||
### Left Panel (Brand Area)
|
||||
|
||||
Shared `AuthBranding` component used by both Login and Register pages.
|
||||
|
||||
- Dark/gradient background (fox-amber → fox-orange gradient from existing CSS variables)
|
||||
- Large product icon (~80px SVG fox logo)
|
||||
- Product name "AgentFox" (large heading font)
|
||||
- Slogan "API Docs for LLMs, Done Right"
|
||||
- 3 feature highlights, each with icon + text:
|
||||
- "Multi-level API retrieval for minimal token usage"
|
||||
- "Import OpenAPI specs in seconds"
|
||||
- "Works with any MCP-compatible LLM"
|
||||
|
||||
### Right Panel (Form Area)
|
||||
|
||||
- Light background, vertically centered
|
||||
- Title: "Sign in to your account" (login) / "Create your account" (register)
|
||||
- Email + Password inputs (reuse existing input styles)
|
||||
- Primary action button
|
||||
- Divider: "── or continue with ──"
|
||||
- Three OAuth buttons in a row: Google / GitHub / Apple (each with official SVG icon)
|
||||
- Footer link: "Don't have an account? Sign up" / "Already have an account? Sign in"
|
||||
|
||||
## OAuth Architecture
|
||||
|
||||
### Flow (Standard Server-Side Redirect)
|
||||
|
||||
```
|
||||
Browser clicks OAuth button
|
||||
→ GET /api/auth/oauth/:provider
|
||||
→ Server builds authorization URL with state param, 302 redirects to Provider
|
||||
→ User authorizes on Provider's page
|
||||
→ Provider redirects to GET /api/auth/oauth/:provider/callback?code=xxx&state=yyy
|
||||
→ Server validates state, exchanges code for access_token
|
||||
→ Server fetches user info (email, name, avatar)
|
||||
→ Server finds or creates user (see Account Linking below)
|
||||
→ Server issues JWT (accessToken + refreshToken)
|
||||
→ Server 302 redirects to frontend /login/callback?accessToken=xxx&refreshToken=xxx
|
||||
```
|
||||
|
||||
### Account Linking Strategy
|
||||
|
||||
On OAuth callback, the server resolves the user in this order:
|
||||
|
||||
1. Look up `OAuthAccount` by `(provider, providerAccountId)` → if found, use linked `User`
|
||||
2. If no OAuthAccount match, look up `User` by `email` → if found, create `OAuthAccount` linking to existing user
|
||||
3. If no User match, create new `User` (passwordHash=null, name and avatarUrl from provider) + new `OAuthAccount`
|
||||
|
||||
### Security
|
||||
|
||||
- **CSRF protection**: Generate random `state` parameter per auth request, store in in-memory Map with 10-minute TTL, validate on callback
|
||||
- **Token delivery**: Tokens passed via URL query params; frontend immediately consumes and clears URL
|
||||
- **Secrets**: All client secrets stay server-side; no OAuth SDK loaded in frontend
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
| Provider | Auth URL | Token URL | UserInfo URL | Scopes |
|
||||
|----------|----------|-----------|--------------|--------|
|
||||
| Google | accounts.google.com/o/oauth2/v2/auth | oauth2.googleapis.com/token | www.googleapis.com/oauth2/v2/userinfo | email, profile |
|
||||
| GitHub | github.com/login/oauth/authorize | github.com/login/oauth/access_token | api.github.com/user + /user/emails | user:email |
|
||||
| Apple | appleid.apple.com/auth/authorize | appleid.apple.com/auth/token | (decoded from id_token) | name, email |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# Already in .env.example
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# New
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_KEY_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### Frontend Callback Page (`/login/callback`)
|
||||
|
||||
- Extracts `accessToken` and `refreshToken` from URL search params
|
||||
- Stores in localStorage, updates AuthContext
|
||||
- Redirects to `/dashboard` (or saved redirect target)
|
||||
- Shows loading spinner during processing
|
||||
- Shows error message with retry link on failure
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/server/src/routes/oauth.ts` | OAuth routes (/:provider, /:provider/callback) |
|
||||
| `packages/server/src/lib/oauth-providers.ts` | Provider configs + token exchange + userinfo fetch |
|
||||
| `packages/web/src/pages/LoginCallback.tsx` | OAuth callback landing page |
|
||||
| `packages/web/src/components/AuthBranding.tsx` | Shared left-panel brand component |
|
||||
| `packages/web/src/components/OAuthButtons.tsx` | Third-party login button group |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/server/src/index.ts` | Register `/auth/oauth` route |
|
||||
| `packages/web/src/pages/Login.tsx` | Refactor to left-right split layout |
|
||||
| `packages/web/src/pages/Register.tsx` | Refactor to left-right split layout |
|
||||
| `packages/web/src/App.tsx` | Add `/login/callback` route |
|
||||
| `packages/web/src/lib/i18n.tsx` | Add translation keys |
|
||||
| `.env.example` | Add Apple OAuth env vars |
|
||||
|
||||
### No Changes Needed
|
||||
|
||||
- `prisma/schema.prisma` — OAuthAccount model already exists
|
||||
- JWT signing logic — reuse existing `generateAccessToken`/`generateRefreshToken`
|
||||
- Existing email/password auth — unchanged
|
||||
|
||||
### No New Dependencies
|
||||
|
||||
- OAuth token exchange: Node native `fetch`
|
||||
- Apple JWT client_secret signing: Node `crypto` built-in
|
||||
- No Passport.js, no OAuth libraries
|
||||
|
||||
## User Action Required
|
||||
|
||||
Before testing OAuth, the developer must register apps on each provider:
|
||||
|
||||
- **Google**: Google Cloud Console → OAuth 2.0 Client → redirect URI: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/google/callback`
|
||||
- **GitHub**: GitHub Developer Settings → OAuth App → callback URL: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/github/callback`
|
||||
- **Apple**: Apple Developer → Services ID + Key → return URL: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/apple/callback` (requires HTTPS)
|
||||
|
||||
Apple Sign In requires a paid Apple Developer account ($99/year) and HTTPS for callbacks. If unavailable, the Apple button can be displayed as "Coming Soon".
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import authRouter from './routes/auth.js';
|
||||
import oauthRouter from './routes/oauth.js';
|
||||
import projectRouter from './routes/projects.js';
|
||||
import importRouter from './routes/import.js';
|
||||
import moduleRouter from './routes/modules.js';
|
||||
@@ -9,12 +10,14 @@ import endpointRouter from './routes/endpoints.js';
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok' } });
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/auth/oauth', oauthRouter);
|
||||
app.use('/api/projects', projectRouter);
|
||||
app.use('/api/projects', importRouter);
|
||||
app.use('/api/projects', moduleRouter);
|
||||
|
||||
225
packages/server/src/lib/oauth-providers.ts
Normal file
225
packages/server/src/lib/oauth-providers.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
type ProviderConfig = {
|
||||
authUrl: string;
|
||||
tokenUrl: string;
|
||||
userInfoUrl: string | null;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
type ProviderUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type Provider = 'google' | 'github' | 'apple';
|
||||
|
||||
const providers: Record<Provider, ProviderConfig> = {
|
||||
google: {
|
||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
scopes: ['email', 'profile'],
|
||||
},
|
||||
github: {
|
||||
authUrl: 'https://github.com/login/oauth/authorize',
|
||||
tokenUrl: 'https://github.com/login/oauth/access_token',
|
||||
userInfoUrl: 'https://api.github.com/user',
|
||||
scopes: ['user:email'],
|
||||
},
|
||||
apple: {
|
||||
authUrl: 'https://appleid.apple.com/auth/authorize',
|
||||
tokenUrl: 'https://appleid.apple.com/auth/token',
|
||||
userInfoUrl: null,
|
||||
scopes: ['name', 'email'],
|
||||
},
|
||||
};
|
||||
|
||||
function getClientId(provider: Provider): string {
|
||||
const envKey = provider === 'apple' ? 'APPLE_CLIENT_ID' : `${provider.toUpperCase()}_CLIENT_ID`;
|
||||
const value = process.env[envKey];
|
||||
if (!value) throw new Error(`Missing env: ${envKey}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function getClientSecret(provider: Provider): string {
|
||||
if (provider === 'apple') return buildAppleClientSecret();
|
||||
const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`;
|
||||
const value = process.env[envKey];
|
||||
if (!value) throw new Error(`Missing env: ${envKey}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function getCallbackUrl(provider: Provider): string {
|
||||
const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000';
|
||||
return `${base}/api/auth/oauth/${provider}/callback`;
|
||||
}
|
||||
|
||||
const stateStore = new Map<string, { provider: string; createdAt: number }>();
|
||||
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of stateStore) {
|
||||
if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
cleanupTimer.unref();
|
||||
|
||||
function generateState(provider: Provider): string {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
stateStore.set(state, { provider, createdAt: Date.now() });
|
||||
return state;
|
||||
}
|
||||
|
||||
function validateState(state: string, provider: Provider): boolean {
|
||||
const entry = stateStore.get(state);
|
||||
if (!entry) return false;
|
||||
if (entry.provider !== provider) return false;
|
||||
if (Date.now() - entry.createdAt > 10 * 60 * 1000) {
|
||||
stateStore.delete(state);
|
||||
return false;
|
||||
}
|
||||
stateStore.delete(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildAppleClientSecret(): string {
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
const keyId = process.env.APPLE_KEY_ID;
|
||||
const privateKey = process.env.APPLE_PRIVATE_KEY;
|
||||
const clientId = process.env.APPLE_CLIENT_ID;
|
||||
if (!teamId || !keyId || !privateKey || !clientId) {
|
||||
throw new Error('Missing Apple OAuth env vars (APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_CLIENT_ID)');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = { alg: 'ES256', kid: keyId };
|
||||
const payload = { iss: teamId, iat: now, exp: now + 15777000, aud: 'https://appleid.apple.com', sub: clientId };
|
||||
|
||||
const encode = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
const signingInput = `${encode(header)}.${encode(payload)}`;
|
||||
const key = crypto.createPrivateKey(privateKey.replace(/\\n/g, '\n'));
|
||||
const sig = crypto.sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' });
|
||||
|
||||
return `${signingInput}.${sig.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function buildAuthUrl(provider: Provider): string {
|
||||
const config = providers[provider];
|
||||
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||
|
||||
const state = generateState(provider);
|
||||
const params = new URLSearchParams({
|
||||
client_id: getClientId(provider),
|
||||
redirect_uri: getCallbackUrl(provider),
|
||||
response_type: 'code',
|
||||
scope: config.scopes.join(' '),
|
||||
state,
|
||||
});
|
||||
|
||||
if (provider === 'apple') {
|
||||
params.set('response_mode', 'form_post');
|
||||
}
|
||||
|
||||
return `${config.authUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(provider: Provider, code: string): Promise<string> {
|
||||
const config = providers[provider];
|
||||
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: getClientId(provider),
|
||||
client_secret: getClientSecret(provider),
|
||||
code,
|
||||
redirect_uri: getCallbackUrl(provider),
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
const res = await fetch(config.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(provider === 'github' ? { Accept: 'application/json' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Token exchange failed for ${provider}: ${text}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await res.json() as any;
|
||||
|
||||
if (provider === 'apple') {
|
||||
return data.id_token as string;
|
||||
}
|
||||
|
||||
return data.access_token as string;
|
||||
}
|
||||
|
||||
export async function fetchProviderUser(provider: Provider, token: string): Promise<ProviderUser> {
|
||||
if (provider === 'apple') {
|
||||
return parseAppleIdToken(token);
|
||||
}
|
||||
|
||||
const config = providers[provider];
|
||||
if (!config?.userInfoUrl) throw new Error(`No userInfo URL for ${provider}`);
|
||||
|
||||
const res = await fetch(config.userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch user info from ${provider}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await res.json() as any;
|
||||
|
||||
if (provider === 'google') {
|
||||
return {
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
name: data.name || data.email.split('@')[0],
|
||||
avatarUrl: data.picture || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'github') {
|
||||
let email = data.email;
|
||||
if (!email) {
|
||||
const emailRes = await fetch('https://api.github.com/user/emails', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (emailRes.ok) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const emails = await emailRes.json() as any[];
|
||||
const primary = emails.find((e: { primary: boolean }) => e.primary);
|
||||
email = primary?.email || emails[0]?.email;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: String(data.id),
|
||||
email: email || '',
|
||||
name: data.name || data.login,
|
||||
avatarUrl: data.avatar_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
function parseAppleIdToken(idToken: string): ProviderUser {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) throw new Error('Invalid Apple id_token');
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
name: payload.email?.split('@')[0] || 'Apple User',
|
||||
avatarUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
export { validateState };
|
||||
142
packages/server/src/routes/oauth.ts
Normal file
142
packages/server/src/routes/oauth.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Router, type Router as RouterType, type Response } from 'express';
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { generateTokenPair } from '../lib/jwt.js';
|
||||
import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState, type Provider } from '../lib/oauth-providers.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
const VALID_PROVIDERS: Provider[] = ['google', 'github', 'apple'];
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
function isValidProvider(value: string): value is Provider {
|
||||
return (VALID_PROVIDERS as string[]).includes(value);
|
||||
}
|
||||
|
||||
router.get('/:provider', (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!isValidProvider(provider)) {
|
||||
res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildAuthUrl(provider);
|
||||
res.redirect(url);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: { code: 'OAUTH_ERROR', message: err instanceof Error ? err.message : 'Failed to build auth URL' } });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:provider/callback', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
const params = req.query as Record<string, string>;
|
||||
await handleOAuthCallback(provider, params.code, params.state, params.error, res);
|
||||
});
|
||||
|
||||
// Apple sends callback as POST (form_post response mode)
|
||||
router.post('/:provider/callback', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
await handleOAuthCallback(provider, req.body.code, req.body.state, req.body.error, res);
|
||||
});
|
||||
|
||||
async function handleOAuthCallback(
|
||||
provider: string,
|
||||
code: string | undefined,
|
||||
state: string | undefined,
|
||||
oauthError: string | undefined,
|
||||
res: Response,
|
||||
) {
|
||||
if (oauthError) {
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state || !isValidProvider(provider)) {
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateState(state, provider)) {
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await exchangeCodeForToken(provider, code);
|
||||
const providerUser = await fetchProviderUser(provider, token);
|
||||
if (!providerUser.email) {
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await findOrCreateUser(provider, providerUser);
|
||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
||||
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`);
|
||||
} catch (err) {
|
||||
console.error(`OAuth callback error (${provider}):`, err);
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function findOrCreateUser(
|
||||
provider: string,
|
||||
providerUser: { id: string; email: string; name: string; avatarUrl: string | null },
|
||||
) {
|
||||
const existingOAuth = await prisma.oAuthAccount.findUnique({
|
||||
where: { provider_providerAccountId: { provider, providerAccountId: providerUser.id } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (existingOAuth) {
|
||||
if (providerUser.avatarUrl && providerUser.avatarUrl !== existingOAuth.user.avatarUrl) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingOAuth.user.id },
|
||||
data: { avatarUrl: providerUser.avatarUrl },
|
||||
});
|
||||
}
|
||||
return existingOAuth.user;
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
||||
if (existingUser) {
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { userId: existingUser.id, provider, providerAccountId: providerUser.id },
|
||||
});
|
||||
if (providerUser.avatarUrl && !existingUser.avatarUrl) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { avatarUrl: providerUser.avatarUrl },
|
||||
});
|
||||
}
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
try {
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email: providerUser.email,
|
||||
name: providerUser.name,
|
||||
avatarUrl: providerUser.avatarUrl,
|
||||
passwordHash: null,
|
||||
oauthAccounts: {
|
||||
create: { provider, providerAccountId: providerUser.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
return newUser;
|
||||
} catch (err: any) {
|
||||
// Handle race condition: concurrent OAuth with same email
|
||||
if (err?.code === 'P2002') {
|
||||
const user = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
||||
if (user) {
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { userId: user.id, provider, providerAccountId: providerUser.id },
|
||||
}).catch(() => {}); // Ignore if OAuthAccount also raced
|
||||
return user;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import { ThemeProvider } from './lib/theme';
|
||||
import { I18nProvider } from './lib/i18n';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import LoginCallback from './pages/LoginCallback';
|
||||
import Layout from './pages/Layout';
|
||||
import Projects from './pages/Projects';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
@@ -22,6 +23,7 @@ export default function App() {
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login/callback" element={<LoginCallback />} />
|
||||
<Route path="/dashboard" element={<Layout />}>
|
||||
<Route index element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
|
||||
64
packages/web/src/components/AuthBranding.tsx
Normal file
64
packages/web/src/components/AuthBranding.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useI18n, tk } from '../lib/i18n';
|
||||
|
||||
function Logo({ className }: { className: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileBranding() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
||||
<Logo className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.productName')}</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">{t('auth.slogan')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthBranding() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden items-center justify-center p-12"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
<div className="absolute -top-24 -left-24 w-96 h-96 rounded-full opacity-10 bg-white" />
|
||||
<div className="absolute -bottom-32 -right-32 w-[500px] h-[500px] rounded-full opacity-10 bg-white" />
|
||||
|
||||
<div className="relative z-10 max-w-md text-white">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center mb-8 shadow-lg">
|
||||
<Logo className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
||||
{t('auth.productName')}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/90 mb-10 leading-relaxed">
|
||||
{t('auth.slogan')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{['auth.feature1', 'auth.feature2', 'auth.feature3'].map((key) => (
|
||||
<div key={key} className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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(tk(key))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
59
packages/web/src/components/OAuthButtons.tsx
Normal file
59
packages/web/src/components/OAuthButtons.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { API_BASE } from '../lib/api';
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppleIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthButtons() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleOAuth = (provider: string) => {
|
||||
window.location.href = `${API_BASE}/auth/oauth/${provider}`;
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{ provider: 'google', icon: GoogleIcon, label: t('auth.oauth.google') },
|
||||
{ provider: 'github', icon: GitHubIcon, label: t('auth.oauth.github') },
|
||||
{ provider: 'apple', icon: AppleIcon, label: t('auth.oauth.apple') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{buttons.map(({ provider, icon: Icon, label }) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => handleOAuth(provider)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg border border-border-default bg-bg-primary hover:bg-bg-secondary transition-colors text-[13px] font-medium text-text-secondary cursor-pointer"
|
||||
>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</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,4 +1,4 @@
|
||||
const API_BASE = '/api';
|
||||
export const API_BASE = '/api';
|
||||
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
|
||||
@@ -10,6 +10,7 @@ type AuthContextType = {
|
||||
register: (email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (updates: Partial<User>) => void;
|
||||
loginWithTokens: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@@ -53,8 +54,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(prev => prev ? { ...prev, ...updates } : null);
|
||||
};
|
||||
|
||||
const loginWithTokens = async (access: string, refresh: string) => {
|
||||
setTokens(access, refresh);
|
||||
const user = await apiFetch<User>('/auth/me');
|
||||
setUser(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser, loginWithTokens }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,301 +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 };
|
||||
|
||||
// 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',
|
||||
/** Use `tk()` to cast dynamic key strings (e.g. template literals) to TranslationKey */
|
||||
export const tk = (key: string) => key as TranslationKey;
|
||||
|
||||
// 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': '控制台',
|
||||
|
||||
// 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);
|
||||
@@ -314,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(`/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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||
import OAuthButtons from '../components/OAuthButtons';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -11,17 +14,18 @@ export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get('redirect') || '/';
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||
const { t } = useI18n();
|
||||
|
||||
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;
|
||||
@@ -43,82 +47,86 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center 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',
|
||||
}} />
|
||||
{/* Radial fade */}
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||
}} />
|
||||
<div className="min-h-screen flex">
|
||||
<AuthBranding />
|
||||
|
||||
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
|
||||
{/* Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
||||
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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 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)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||
}} />
|
||||
|
||||
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||
<MobileBranding />
|
||||
|
||||
<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>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to AgentFox</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">API documentation for LLMs</p>
|
||||
</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">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }}
|
||||
className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
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"
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{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> Signing in...</>
|
||||
) : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card p-6 shadow-md">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }}
|
||||
className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
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={t('auth.login.passwordPlaceholder')}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{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> {t('auth.login.submitting')}</>
|
||||
) : t('auth.login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-accent hover:underline font-medium">Sign Up</Link>
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<OAuthButtons />
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link to="/register" className="text-accent hover:underline font-medium">{t('auth.login.signUp')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
67
packages/web/src/pages/LoginCallback.tsx
Normal file
67
packages/web/src/pages/LoginCallback.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
export default function LoginCallback() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const { loginWithTokens } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const accessToken = searchParams.get('accessToken');
|
||||
const refreshToken = searchParams.get('refreshToken');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
setError(errorParam);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError('Missing authentication tokens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear tokens from URL immediately
|
||||
window.history.replaceState({}, '', '/login/callback');
|
||||
|
||||
loginWithTokens(accessToken, refreshToken)
|
||||
.then(() => navigate('/dashboard', { replace: true }))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Authentication failed'));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||
<div className="text-center max-w-sm mx-4">
|
||||
<div className="w-12 h-12 rounded-full bg-danger-muted mx-auto flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M15 9l-6 6m0-6l6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-text-primary mb-2">{t('auth.callback.error')}</h1>
|
||||
<p className="text-[13px] text-text-muted mb-6">{error}</p>
|
||||
<Link to="/login" className="btn-primary inline-block px-6">
|
||||
{t('auth.callback.retry')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||
<div className="text-center">
|
||||
<svg className="w-8 h-8 animate-spin text-accent mx-auto mb-4" 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>
|
||||
<p className="text-[13px] text-text-muted">{t('auth.callback.loading')}</p>
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
@@ -76,19 +78,19 @@ export default function Projects() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
|
||||
{projects?.map((p) => (
|
||||
<div key={p.id} className="card card-hover group relative">
|
||||
<Link to={`/projects/${p.id}`} className="block p-5">
|
||||
<Link to={`/dashboard/projects/${p.id}`} className="block p-5">
|
||||
<h3 className="text-[14px] font-medium text-text-primary group-hover:text-accent transition-colors">{p.name}</h3>
|
||||
{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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||
import OAuthButtons from '../components/OAuthButtons';
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('');
|
||||
@@ -11,6 +14,7 @@ export default function Register() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const clearFieldError = (field: string) => {
|
||||
if (fieldErrors[field as keyof typeof fieldErrors]) {
|
||||
@@ -21,17 +25,17 @@ export default function Register() {
|
||||
const validate = () => {
|
||||
const errors: { name?: string; email?: string; password?: string } = {};
|
||||
if (!name.trim()) {
|
||||
errors.name = 'Name is required';
|
||||
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;
|
||||
@@ -55,94 +59,103 @@ export default function Register() {
|
||||
const errorInputClass = 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center 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)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||
}} />
|
||||
<div className="min-h-screen flex">
|
||||
<AuthBranding />
|
||||
|
||||
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
||||
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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 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)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||
}} />
|
||||
|
||||
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||
<MobileBranding />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Create your account</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">Get started with AgentFox</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-md">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
|
||||
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
|
||||
className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
|
||||
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{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> Creating account...</>
|
||||
) : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card p-6 shadow-md">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
|
||||
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
|
||||
placeholder={t('auth.register.namePlaceholder')}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
|
||||
className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
|
||||
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
|
||||
placeholder={t('auth.register.passwordPlaceholder')}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{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> {t('auth.register.submitting')}</>
|
||||
) : t('auth.register.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline font-medium">Sign In</Link>
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<OAuthButtons />
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
{t('auth.register.hasAccount')}{' '}
|
||||
<Link to="/login" className="text-accent hover:underline font-medium">{t('auth.register.signIn')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 }) }),
|
||||
@@ -27,7 +29,7 @@ export default function ProjectSettings({ project }: { project: Project }) {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/dashboard'); },
|
||||
});
|
||||
|
||||
const handleReimportSuccess = () => {
|
||||
@@ -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