feat: add React frontend with auth, project list, import, and project detail pages

Converts packages/web from vanilla TypeScript Vite scaffold to React with:
- React 19, react-router-dom v7, @tanstack/react-query v5, Tailwind CSS v4
- JWT auth context with auto-refresh token support
- Login/Register pages, protected Layout with auth guard
- Projects list with grid cards and delete action
- ImportDialog supporting URL or file upload with API key display
- ProjectDetail with 4 tabs: Documentation, Modules, MCP Integration, Settings
- All TypeScript compiles cleanly (noEmit check passes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:36:45 +08:00
parent ac60f0bb49
commit c3f8b598af
26 changed files with 1143 additions and 389 deletions

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<title>Agent Fox</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
{
"name": "web",
"name": "@agent-fox/web",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -9,7 +9,18 @@
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1"
},
"dependencies": {
"@tanstack/react-query": "^5.96.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2"
}
}

30
packages/web/src/App.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './lib/auth';
import Login from './pages/Login';
import Register from './pages/Register';
import Layout from './pages/Layout';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<Layout />}>
<Route path="/" element={<Projects />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,9 +0,0 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `Count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,74 @@
const API_BASE = '/api';
type ApiResponse<T> = {
success: boolean;
data?: T;
error?: { code: string; message: string };
};
let accessToken: string | null = localStorage.getItem('accessToken');
let refreshToken: string | null = localStorage.getItem('refreshToken');
export function setTokens(access: string, refresh: string) {
accessToken = access;
refreshToken = refresh;
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
}
export function clearTokens() {
accessToken = null;
refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
export function getAccessToken() {
return accessToken;
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) return false;
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json();
if (json.success && json.data) {
setTokens(json.data.accessToken, json.data.refreshToken);
return true;
}
return false;
} catch {
return false;
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401 && refreshToken) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers.set('Authorization', `Bearer ${accessToken}`);
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
}
const json: ApiResponse<T> = await res.json();
if (!json.success) {
throw new Error(json.error?.message || 'Request failed');
}
return json.data as T;
}

View File

@@ -0,0 +1,62 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
type User = { id: string; email: string; name: string };
type AuthContextType = {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (getAccessToken()) {
apiFetch<User>('/auth/me')
.then(setUser)
.catch(() => clearTokens())
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
'/auth/login',
{ method: 'POST', body: JSON.stringify({ email, password }) },
);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
};
const register = async (email: string, password: string, name: string) => {
const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
'/auth/register',
{ method: 'POST', body: JSON.stringify({ email, password, name }) },
);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
};
const logout = () => { clearTokens(); setUser(null); };
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -1,60 +0,0 @@
import './style.css'
import typescriptLogo from './assets/typescript.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import { setupCounter } from './counter.ts'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<section id="center">
<div class="hero">
<img src="${heroImg}" class="base" width="170" height="179">
<img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
<img src=${viteLogo} class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
</div>
<button id="counter" type="button" class="counter"></button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" src=${viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://www.typescriptlang.org" target="_blank">
<img class="button-icon" src="${typescriptLogo}" alt="">
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)

10
packages/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
type ImportResult = {
project: { id: string; name: string };
apiKey: string;
stats: { modules: number; endpoints: number };
};
export default function ImportDialog({ onClose }: { onClose: () => void }) {
const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ImportResult | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file);
};
const handleImport = async () => {
setLoading(true);
setError('');
try {
let body: Record<string, unknown>;
if (mode === 'url') {
body = { specUrl: url };
} else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
}
const data = await apiFetch<ImportResult>('/projects', {
method: 'POST', body: JSON.stringify(body),
});
setResult(data);
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
{!result ? (
<>
<h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2>
<div className="flex gap-2 mb-4">
<button onClick={() => setMode('url')} className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>From URL</button>
<button onClick={() => setMode('file')} className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>Upload File</button>
</div>
{mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" />
) : (
<input type="file" accept=".json,.yaml,.yml" onChange={handleFileChange} className="w-full mb-4" />
)}
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">Cancel</button>
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Importing...' : 'Import'}
</button>
</div>
</>
) : (
<>
<h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2>
<div className="space-y-3 text-sm">
<p><strong>Project:</strong> {result.project.name}</p>
<p><strong>Modules:</strong> {result.stats.modules}</p>
<p><strong>Endpoints:</strong> {result.stats.endpoints}</p>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="font-medium text-yellow-800 mb-1">API Key (save it now):</p>
<code className="text-xs break-all">{result.apiKey}</code>
</div>
</div>
<div className="flex justify-end mt-4">
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Go to Project</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Layout() {
const { user, loading, logout } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
if (!user) return <Navigate to="/login" replace />;
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Agent Fox</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.name}</span>
<button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button>
</div>
</header>
<main className="p-6"><Outlet /></main>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Sign In</button>
</form>
<p className="text-center text-sm mt-4">
Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
import DocPreview from './tabs/DocPreview';
import ModuleManagement from './tabs/ModuleManagement';
import McpIntegration from './tabs/McpIntegration';
import ProjectSettings from './tabs/ProjectSettings';
type ProjectData = {
id: string; name: string; description: string | null; baseUrl: string | null;
openApiVersion: string;
modules: Array<{ id: string; name: string; description: string | null; _count: { endpoints: number } }>;
_count: { endpoints: number };
};
const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
type Tab = (typeof tabs)[number];
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<Tab>('Documentation');
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],
queryFn: () => apiFetch<ProjectData>(`/projects/${id}`),
});
if (isLoading) return <div>Loading...</div>;
if (!project) return <div>Project not found</div>;
return (
<div>
<div className="mb-4"><Link to="/" className="text-sm text-blue-600 hover:underline">&larr; Back to projects</Link></div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{project.name}</h2>
{project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
</div>
<div className="text-sm text-gray-400">OpenAPI {project.openApiVersion} &middot; {project._count.endpoints} endpoints</div>
</div>
<div className="border-b mb-6">
<div className="flex gap-6">
{tabs.map((tab) => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{tab}
</button>
))}
</div>
</div>
{activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
{activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
{activeTab === 'MCP Integration' && <McpIntegration project={project} />}
{activeTab === 'Settings' && <ProjectSettings project={project} />}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import ImportDialog from './ImportDialog';
type ProjectSummary = {
id: string; name: string; description: string | null; openApiVersion: string;
updatedAt: string; _count: { endpoints: number; modules: number };
};
export default function Projects() {
const [showImport, setShowImport] = useState(false);
const queryClient = useQueryClient();
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
});
if (isLoading) return <div>Loading projects...</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Projects</h2>
<button onClick={() => setShowImport(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Import API Doc</button>
</div>
{projects?.length === 0 && <p className="text-gray-500 text-center py-12">No projects yet. Import an OpenAPI document to get started.</p>}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects?.map((p) => (
<div key={p.id} className="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow">
<Link to={`/projects/${p.id}`}>
<h3 className="font-medium text-lg">{p.name}</h3>
{p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
<div className="mt-3 flex items-center gap-4 text-xs text-gray-400">
<span>OpenAPI {p.openApiVersion}</span>
<span>{p._count.modules} modules</span>
<span>{p._count.endpoints} endpoints</span>
</div>
</Link>
<button onClick={() => { if (confirm('Delete this project?')) deleteMutation.mutate(p.id); }}
className="mt-2 text-xs text-red-500 hover:underline">Delete</button>
</div>
))}
</div>
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Register() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await register(email, password, name);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<input type="password" placeholder="Password (min 8 chars)" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" minLength={8} required />
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Create Account</button>
</form>
<p className="text-center text-sm mt-4">
Already have an account? <Link to="/login" className="text-blue-600 hover:underline">Sign In</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } };
type EndpointSummary = { id: string; method: string; path: string; summary: string | null; deprecated: boolean; module: { name: string } };
type EndpointFull = EndpointSummary & { description: string | null; operationId: string | null; parameters: unknown; requestBody: unknown; responses: unknown };
const methodColors: Record<string, string> = {
GET: 'bg-green-100 text-green-800', POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-yellow-100 text-yellow-800', DELETE: 'bg-red-100 text-red-800',
PATCH: 'bg-purple-100 text-purple-800',
};
export default function DocPreview({ projectId }: { projectId: string }) {
const [selectedModule, setSelectedModule] = useState<string | null>(null);
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const { data: modules } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
const { data: endpoints } = useQuery({
queryKey: ['endpoints', projectId, selectedModule],
queryFn: () => apiFetch<EndpointSummary[]>(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`),
});
const { data: endpointDetail } = useQuery({
queryKey: ['endpoint-detail', projectId, expandedEndpoint],
queryFn: () => apiFetch<EndpointFull>(`/projects/${projectId}/endpoints/${expandedEndpoint}`),
enabled: !!expandedEndpoint,
});
return (
<div className="flex gap-6">
<div className="w-56 shrink-0">
<h3 className="text-sm font-medium text-gray-700 mb-2">Modules</h3>
<button onClick={() => setSelectedModule(null)}
className={`block w-full text-left px-3 py-2 rounded text-sm ${!selectedModule ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
All ({modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0})
</button>
{modules?.map((m) => (
<button key={m.id} onClick={() => setSelectedModule(m.id)}
className={`block w-full text-left px-3 py-2 rounded text-sm ${selectedModule === m.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
{m.name} ({m._count.endpoints})
</button>
))}
</div>
<div className="flex-1 space-y-2">
{endpoints?.map((ep) => (
<div key={ep.id} className="bg-white rounded-lg border">
<button onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)} className="w-full text-left px-4 py-3 flex items-center gap-3">
<span className={`px-2 py-0.5 rounded text-xs font-mono font-medium ${methodColors[ep.method] || 'bg-gray-100'}`}>{ep.method}</span>
<span className="font-mono text-sm">{ep.path}</span>
{ep.summary && <span className="text-sm text-gray-500 ml-auto truncate max-w-xs">{ep.summary}</span>}
{ep.deprecated && <span className="text-xs text-orange-500 ml-2">deprecated</span>}
</button>
{expandedEndpoint === ep.id && endpointDetail && (
<div className="border-t px-4 py-3 text-sm space-y-3">
{endpointDetail.description && <p className="text-gray-600">{endpointDetail.description}</p>}
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
<div><h4 className="font-medium mb-1">Parameters</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre></div>
)}
{endpointDetail.requestBody != null && (
<div><h4 className="font-medium mb-1">Request Body</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre></div>
)}
{endpointDetail.responses != null && (
<div><h4 className="font-medium mb-1">Responses</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.responses, null, 2)}</pre></div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Project = { id: string; name: string };
export default function McpIntegration({ project }: { project: Project }) {
const [apiKey, setApiKey] = useState<string | null>(null);
const mcpBaseUrl = window.location.origin;
const mcpUrl = `${mcpBaseUrl}/mcp/${project.id}`;
const rotateMutation = useMutation({
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
onSuccess: (data) => setApiKey(data.apiKey),
});
const configSnippet = JSON.stringify({
mcpServers: {
[project.name.toLowerCase().replace(/\s+/g, '-')]: {
url: mcpUrl,
headers: { Authorization: `Bearer ${apiKey || '<your-api-key>'}` },
},
},
}, null, 2);
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="font-medium mb-2">MCP Service URL</h3>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono">{mcpUrl}</code>
<button onClick={() => navigator.clipboard.writeText(mcpUrl)} className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300">Copy</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">API Key</h3>
{apiKey ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-xs text-yellow-700 mb-1">Save this key it won't be shown again.</p>
<code className="text-sm break-all">{apiKey}</code>
</div>
) : (
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
)}
<button onClick={() => { if (confirm('This will invalidate the current API key. Continue?')) rotateMutation.mutate(); }}
className="mt-2 px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200">Rotate API Key</button>
</div>
<div>
<h3 className="font-medium mb-2">Configuration for Claude Code / Cursor</h3>
<div className="relative">
<pre className="bg-gray-900 text-green-400 p-4 rounded text-sm overflow-auto">{configSnippet}</pre>
<button onClick={() => navigator.clipboard.writeText(configSnippet)} className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600">Copy</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">Available Tools</h3>
<div className="space-y-2 text-sm">
{[
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.' },
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.' },
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.' },
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.' },
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.' },
].map((t) => (
<div key={t.name} className="p-3 bg-gray-50 rounded">
<code className="font-medium">{t.name}</code>
<p className="text-gray-500 mt-1">{t.desc}</p>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } };
export default function ModuleManagement({ projectId }: { projectId: string }) {
const [newModuleName, setNewModuleName] = useState('');
const queryClient = useQueryClient();
const { data: modules } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
const createMutation = useMutation({
mutationFn: (name: string) => apiFetch(`/projects/${projectId}/modules`, { method: 'POST', body: JSON.stringify({ name }) }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setNewModuleName(''); },
});
const deleteMutation = useMutation({
mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
});
return (
<div className="max-w-2xl space-y-4">
<div className="flex gap-2">
<input type="text" placeholder="New module name" value={newModuleName} onChange={(e) => setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" />
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50">Add Module</button>
</div>
<div className="space-y-2">
{modules?.map((m) => (
<div key={m.id} className="flex items-center justify-between p-3 bg-white rounded-lg border">
<div>
<span className="font-medium">{m.name}</span>
<span className="text-xs text-gray-400 ml-2">({m.source})</span>
<span className="text-xs text-gray-400 ml-2">{m._count.endpoints} endpoints</span>
</div>
<button onClick={() => { if (confirm(`Delete "${m.name}"?`)) deleteMutation.mutate(m.id); }}
className="text-xs text-red-500 hover:underline">Delete</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Project = { id: string; name: string; description: string | null };
export default function ProjectSettings({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || '');
const navigate = useNavigate();
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description: description || undefined }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }),
});
const deleteMutation = useMutation({
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'DELETE' }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
});
return (
<div className="max-w-lg space-y-6">
<div className="space-y-3">
<div><label className="block text-sm font-medium mb-1">Project Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" /></div>
<div><label className="block text-sm font-medium mb-1">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded-md" /></div>
<button onClick={() => updateMutation.mutate()} className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">Save Changes</button>
</div>
<div className="border-t pt-6">
<h3 className="text-red-600 font-medium mb-2">Danger Zone</h3>
<button onClick={() => { if (confirm('Permanently delete this project?')) deleteMutation.mutate(); }}
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700">Delete Project</button>
</div>
</div>
);
}

View File

@@ -1,296 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,26 +1,16 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
},
},
});