feat: optimize web ui

This commit is contained in:
2026-04-02 18:22:14 +08:00
parent ccf76fea95
commit 143b1e8c4b
24 changed files with 1833 additions and 246 deletions

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
import Modal from '../components/Modal';
type ImportResult = {
project: { id: string; name: string };
@@ -13,20 +14,30 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState<string>('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ImportResult | null>(null);
const [copied, setCopied] = useState(false);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const handleFile = (file: File) => {
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleImport = async () => {
setLoading(true);
setError('');
@@ -49,48 +60,113 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
}
};
const copyKey = () => {
if (result?.apiKey) {
navigator.clipboard.writeText(result.apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
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>
<Modal open onClose={onClose} size="md">
{!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>
</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>
</div>
{mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
}`}
>
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
{fileName ? (
<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>
</>
)}
</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'}
)}
{error && (
<div className="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>
)}
<div className="flex justify-end gap-2.5">
<button onClick={onClose} className="btn-ghost">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'}
</button>
</div>
</div>
) : (
<div className="space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-muted flex items-center justify-center">
<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>
<p className="text-[13px] text-text-muted">{result.project.name}</p>
</div>
</div>
<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>
<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>
</div>
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-warning" 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] font-medium text-warning">API Key save it now</p>
</div>
<button onClick={copyKey} className="text-[11px] font-medium text-warning hover:underline">
{copied ? 'Copied!' : 'Copy'}
</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>
<code className="text-xs break-all text-text-primary font-mono bg-bg-primary/30 rounded p-2 block">{result.apiKey}</code>
</div>
<div className="flex justify-end">
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="btn-primary">Go to Project</button>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -1,22 +1,128 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useState } from 'react';
import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../lib/auth';
import ThemeToggle from '../components/ThemeToggle';
export default function Layout() {
const { user, loading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg-secondary">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
const isSettings = location.pathname === '/settings';
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 className="min-h-screen bg-bg-secondary flex">
{/* Mobile overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 w-[220px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:translate-x-0 lg:static lg:z-auto ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
{/* Brand */}
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
<svg className="w-[14px] h-[14px] 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="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
</div>
</header>
<main className="p-6"><Outlet /></main>
{/* Navigation */}
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
<NavLink
to="/"
end
className={({ isActive }) =>
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isActive && !isSettings
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
<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
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) =>
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isActive
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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
</NavLink>
</nav>
{/* Bottom section */}
<div className="px-2.5 pb-3 space-y-2.5">
<div className="px-1">
<ThemeToggle />
</div>
<div className="border-t border-border-default pt-2.5">
<Link
to="/settings"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-bg-tertiary transition-colors"
>
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
{initials}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-text-primary truncate leading-tight">{user.name}</div>
<div className="text-[11px] text-text-muted truncate leading-tight mt-0.5">{user.email}</div>
</div>
</Link>
<button
onClick={logout}
className="flex items-center gap-2.5 w-full px-2.5 py-[7px] rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mt-0.5"
>
<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
</button>
</div>
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile header */}
<header className="lg:hidden h-14 border-b border-border-default bg-bg-sidebar px-4 flex items-center">
<button onClick={() => setSidebarOpen(true)} className="p-2 -ml-2 text-text-secondary hover:text-text-primary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="ml-3 font-semibold text-[15px] text-text-primary">Agent Fox</span>
</header>
<main className="flex-1 p-5 lg:p-8 overflow-auto">
<div className="animate-fade-in">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View File

@@ -6,32 +6,76 @@ export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
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>
<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="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>
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to Agent Fox</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} 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)} className="input-base" placeholder="you@example.com" required />
</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)} className="input-base" placeholder="Enter your password" required />
</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>
<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>
</div>

View File

@@ -6,53 +6,104 @@ import DocPreview from './tabs/DocPreview';
import ModuleManagement from './tabs/ModuleManagement';
import McpIntegration from './tabs/McpIntegration';
import ProjectSettings from './tabs/ProjectSettings';
import Badge from '../components/Badge';
import Skeleton from '../components/Skeleton';
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 };
_count: { endpoints: number; modules: number };
};
const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
type Tab = (typeof tabs)[number];
const tabs = [
{ 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: '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: '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' },
] as const;
type TabKey = (typeof tabs)[number]['key'];
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<Tab>('Documentation');
const [activeTab, setActiveTab] = useState<TabKey>('docs');
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>;
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-4 w-28" />
<div className="space-y-2">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-10 w-96" />
<Skeleton className="h-[300px] w-full" />
</div>
);
}
if (!project) {
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="/" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
</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">
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-[13px] text-text-muted mb-5">
<Link to="/" className="hover:text-text-primary transition-colors">Projects</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>
{/* Header */}
<div className="flex items-start 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>}
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{project.name}</h2>
{project.description && <p className="text-[13px] text-text-muted mt-1 max-w-xl">{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 className="flex items-center gap-1.5 shrink-0 ml-4">
<Badge>OpenAPI {project.openApiVersion}</Badge>
<Badge>{project._count.endpoints} endpoints</Badge>
</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} />}
{/* Tabs */}
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary mb-6 max-w-fit border border-border-muted">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-[6px] rounded-md text-[13px] font-medium transition-all duration-150 ${
activeTab === tab.key
? 'bg-bg-elevated text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
>
<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>
</button>
))}
</div>
{/* Content */}
<div key={activeTab} className="animate-fade-in">
{activeTab === 'docs' && <DocPreview projectId={project.id} />}
{activeTab === 'modules' && <ModuleManagement projectId={project.id} />}
{activeTab === 'mcp' && <McpIntegration project={project} />}
{activeTab === 'settings' && <ProjectSettings project={{ ...project, _count: project._count }} />}
</div>
</div>
);
}

View File

@@ -3,6 +3,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import ImportDialog from './ImportDialog';
import ConfirmDialog from '../components/ConfirmDialog';
import EmptyState from '../components/EmptyState';
import Skeleton from '../components/Skeleton';
import Badge from '../components/Badge';
type ProjectSummary = {
id: string; name: string; description: string | null; openApiVersion: string;
@@ -11,6 +15,7 @@ type ProjectSummary = {
export default function Projects() {
const [showImport, setShowImport] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
const queryClient = useQueryClient();
const { data: projects, isLoading } = useQuery({
@@ -20,36 +25,91 @@ export default function Projects() {
const deleteMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setDeleteTarget(null); },
});
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>
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Projects</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
</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>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="card p-5 space-y-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-full" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</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>
</div>
))}
</div>
) : projects?.length === 0 ? (
<EmptyState
icon={
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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."
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
</button>
}
/>
) : (
<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">
<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>
</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"
>
<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" />
</svg>
</button>
</div>
))}
</div>
)}
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
<ConfirmDialog
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"
variant="danger"
/>
</div>
);
}

View File

@@ -7,33 +7,76 @@ export default function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await register(email, password, name);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
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>
<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="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>
<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 Agent Fox</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} 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)} className="input-base" placeholder="Your name" required />
</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)} className="input-base" placeholder="you@example.com" required />
</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)} className="input-base" placeholder="At least 8 characters" minLength={8} required />
</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>
<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>
</div>

View File

@@ -0,0 +1,183 @@
import { useState, useRef } from 'react';
import { apiFetch } from '../lib/api';
import Modal from '../components/Modal';
type ReimportResult = {
stats: { modules: number; endpoints: number };
};
type ReimportDialogProps = {
projectId: string;
currentStats: { modules: number; endpoints: number };
onClose: () => void;
onSuccess: () => void;
};
type Step = 'confirm' | 'import' | 'success';
export default function ReimportDialog({ projectId, currentStats, onClose, onSuccess }: ReimportDialogProps) {
const [step, setStep] = useState<Step>('confirm');
const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ReimportResult | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = (file: File) => {
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleReimport = 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<ReimportResult>(`/projects/${projectId}/reimport`, {
method: 'POST', body: JSON.stringify(body),
});
setResult(data);
setStep('success');
} catch (err) {
setError(err instanceof Error ? err.message : 'Re-import failed');
} finally {
setLoading(false);
}
};
return (
<Modal open onClose={onClose} size="md">
{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>
</div>
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-warning shrink-0 mt-0.5" 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>
<div className="text-sm">
<p className="font-medium text-warning mb-1">The following data will be permanently deleted:</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>
</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>
</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>
</div>
</div>
)}
{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>
</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>
</div>
{mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
}`}
>
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
{fileName ? (
<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>
</>
)}
</div>
)}
{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={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...
</>
) : 'Re-import'}
</button>
</div>
</div>
)}
{step === 'success' && result && (
<div className="space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-muted flex items-center justify-center">
<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>
</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>
<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>
</div>
<div className="flex justify-end">
<button onClick={() => { onSuccess(); onClose(); }} className="btn-primary">Done</button>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,147 @@
import { useState } from 'react';
import { useAuth } from '../lib/auth';
import { apiFetch } from '../lib/api';
export default function Settings() {
const { user, updateUser } = useAuth();
const [name, setName] = useState(user?.name || '');
const [profileLoading, setProfileLoading] = useState(false);
const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleProfileSave = async () => {
setProfileLoading(true);
setProfileMsg(null);
try {
const data = await apiFetch<{ id: string; email: string; name: string }>('/auth/profile', {
method: 'PUT', body: JSON.stringify({ name }),
});
updateUser({ name: data.name });
setProfileMsg({ type: 'success', text: 'Profile updated' });
setTimeout(() => setProfileMsg(null), 3000);
} catch (err) {
setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' });
} finally {
setProfileLoading(false);
}
};
const handlePasswordChange = async () => {
if (newPassword !== confirmPassword) {
setPasswordMsg({ type: 'error', text: 'Passwords do not match' });
return;
}
setPasswordLoading(true);
setPasswordMsg(null);
try {
await apiFetch('/auth/change-password', {
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
});
setPasswordMsg({ type: 'success', text: 'Password changed successfully' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setPasswordMsg(null), 3000);
} catch (err) {
setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' });
} finally {
setPasswordLoading(false);
}
};
const initials = user?.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?';
return (
<div className="max-w-2xl">
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em] mb-8">Settings</h2>
{/* Profile */}
<section className="mb-8">
<p className="section-title">Profile</p>
<p className="section-desc mb-5">Manage your personal information.</p>
<div className="flex items-center gap-4 mb-6">
<div className="w-14 h-14 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-lg font-bold tracking-wide">
{initials}
</div>
<div>
<div className="text-[14px] font-medium text-text-primary">{user?.name}</div>
<div className="text-[13px] text-text-muted">{user?.email}</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">Display Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base max-w-sm" />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">Email</label>
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] text-text-muted max-w-sm">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
{user?.email}
</div>
</div>
{profileMsg && (
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 max-w-sm ${profileMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{profileMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
</svg>
{profileMsg.text}
</div>
)}
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
{profileLoading ? 'Saving...' : 'Save Profile'}
</button>
</div>
</section>
{/* Password */}
<section className="border-t border-border-default pt-8">
<p className="section-title">Change Password</p>
<p className="section-desc mb-5">Update your password to keep your account secure.</p>
<div className="space-y-4 max-w-sm">
<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" />
</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} />
</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" />
</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'}`}>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
</svg>
{passwordMsg.text}
</div>
)}
<button
onClick={handlePasswordChange}
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
className="btn-primary"
>
{passwordLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</section>
</div>
);
}

View File

@@ -1,27 +1,28 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import Badge from '../../components/Badge';
import Skeleton from '../../components/Skeleton';
import EmptyState from '../../components/EmptyState';
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',
const methodVariant: Record<string, 'get' | 'post' | 'put' | 'delete' | 'patch'> = {
GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete', PATCH: 'patch',
};
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({
const { data: modules, isLoading: modulesLoading } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
const { data: endpoints } = useQuery({
const { data: endpoints, isLoading: endpointsLoading } = useQuery({
queryKey: ['endpoints', projectId, selectedModule],
queryFn: () => apiFetch<EndpointSummary[]>(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`),
});
@@ -32,46 +33,104 @@ export default function DocPreview({ projectId }: { projectId: string }) {
enabled: !!expandedEndpoint,
});
const totalEndpoints = modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0;
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 className="flex gap-6 min-h-[400px]">
{/* Module sidebar */}
<div className="w-52 shrink-0">
<div className="sticky top-0">
<p className="section-label px-3 mb-3">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>
) : (
<div className="space-y-0.5">
<button onClick={() => setSelectedModule(null)}
className={`w-full text-left px-3 py-2 rounded-lg text-[13px] transition-all duration-150 ${
!selectedModule
? '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>
</button>
{modules?.map((m) => (
<button key={m.id} onClick={() => setSelectedModule(m.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-[13px] transition-all duration-150 ${
selectedModule === m.id
? 'bg-accent-muted text-accent font-medium'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}>
{m.name} <span className="text-text-muted ml-1">{m._count.endpoints}</span>
</button>
))}
</div>
)}
</div>
</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>
{/* Endpoints */}
<div className="flex-1 min-w-0">
{endpointsLoading ? (
<div className="space-y-2">{[1,2,3,4,5].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
) : endpoints?.length === 0 ? (
<EmptyState
icon={
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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."}
/>
) : (
<div className="space-y-1.5 stagger-children">
{endpoints?.map((ep) => (
<div key={ep.id} className="card overflow-hidden">
<button
onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-bg-tertiary/50 transition-colors"
>
<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>}
<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 && (
<div className="border-t border-border-muted px-5 py-5 space-y-5 animate-fade-in">
{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>
<code className="text-xs font-mono text-text-secondary bg-bg-tertiary px-1.5 py-0.5 rounded">{endpointDetail.operationId}</code>
</div>
)}
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
<div>
<p className="section-label mb-2">Parameters</p>
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre>
</div>
)}
{endpointDetail.requestBody != null && (
<div>
<p className="section-label mb-2">Request Body</p>
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre>
</div>
)}
{endpointDetail.responses != null && (
<div>
<p className="section-label mb-2">Responses</p>
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.responses, null, 2)}</pre>
</div>
)}
</div>
)}
</div>
)}
))}
</div>
))}
)}
</div>
</div>
);

View File

@@ -1,21 +1,23 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import ConfirmDialog from '../../components/ConfirmDialog';
type Project = { id: string; name: string };
export default function McpIntegration({ project }: { project: Project }) {
const [apiKey, setApiKey] = useState<string | null>(null);
const [showRotateConfirm, setShowRotateConfirm] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const mcpHost = window.location.hostname;
const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`;
const rotateMutation = useMutation({
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
onSuccess: (data) => setApiKey(data.apiKey),
onSuccess: (data) => { setApiKey(data.apiKey); setShowRotateConfirm(false); },
});
const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const configSnippet = JSON.stringify({
mcpServers: {
[serverName]: {
@@ -26,52 +28,105 @@ export default function McpIntegration({ project }: { project: Project }) {
},
}, null, 2);
const copyText = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="font-medium mb-2">MCP Service URL</h3>
<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>
<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>
<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" 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</>
)}
</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">API Key</h3>
</section>
{/* API Key */}
<section>
<p className="section-title">API Key</p>
<p className="section-desc mb-3">Used to authenticate MCP requests. Each project has its own key.</p>
{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 className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-warning" 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-xs font-medium text-warning">Save this key it won't be shown again.</p>
</div>
<button onClick={() => copyText(apiKey, 'key')} className="text-xs font-medium text-warning hover:underline">
{copied === 'key' ? 'Copied!' : 'Copy'}
</button>
</div>
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2">{apiKey}</code>
</div>
) : (
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
<div className="flex items-center gap-3 px-3.5 py-2.5 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-muted">API key is hidden. Rotate to generate a new one.</p>
</div>
)}
<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>
<button onClick={() => setShowRotateConfirm(true)} className="btn-outline mt-3">
<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
</button>
</section>
{/* 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>
<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>
<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'}
</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">Available Tools</h3>
<div className="space-y-2 text-sm">
</section>
{/* 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>
<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.' },
{ 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.' },
{ 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="p-3 bg-gray-50 rounded">
<code className="font-medium">{t.name}</code>
<p className="text-gray-500 mt-1">{t.desc}</p>
<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>
<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>
</div>
</div>
))}
</div>
</div>
</section>
<ConfirmDialog
open={showRotateConfirm}
onCancel={() => setShowRotateConfirm(false)}
onConfirm={() => rotateMutation.mutate()}
title="Rotate API Key"
description="This will invalidate the current API key immediately. Any MCP clients using the old key will stop working."
confirmText="Rotate Key"
variant="warning"
/>
</div>
);
}

View File

@@ -1,14 +1,19 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import Badge from '../../components/Badge';
import ConfirmDialog from '../../components/ConfirmDialog';
import EmptyState from '../../components/EmptyState';
import Skeleton from '../../components/Skeleton';
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 [deleteTarget, setDeleteTarget] = useState<Module | null>(null);
const queryClient = useQueryClient();
const { data: modules } = useQuery({
const { data: modules, isLoading } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
@@ -20,29 +25,84 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
const deleteMutation = useMutation({
mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setDeleteTarget(null); },
});
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 className="max-w-2xl space-y-6">
{/* Add module */}
<section>
<p className="section-label mb-3">Add Manual Module</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Module name"
value={newModuleName}
onChange={(e) => setNewModuleName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && newModuleName && createMutation.mutate(newModuleName)}
className="input-base flex-1"
/>
<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
</button>
</div>
</section>
{/* 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>}
</div>
{isLoading ? (
<div className="space-y-2">{[1,2,3].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
) : modules?.length === 0 ? (
<EmptyState
icon={
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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."
/>
) : (
<div className="space-y-1.5 stagger-children">
{modules?.map((m) => (
<div key={m.id} className="card flex items-center justify-between px-4 py-3 group">
<div className="flex items-center gap-3 min-w-0">
<span className="text-[13px] font-medium text-text-primary truncate">{m.name}</span>
<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>
<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"
>
<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" />
</svg>
</button>
</div>
</div>
))}
</div>
))}
</div>
)}
</section>
<ConfirmDialog
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"
variant="danger"
/>
</div>
);
}

View File

@@ -2,18 +2,27 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import ConfirmDialog from '../../components/ConfirmDialog';
import ReimportDialog from '../ReimportDialog';
type Project = { id: string; name: string; description: string | null };
type Project = { id: string; name: string; description: string | null; _count: { endpoints: number; modules: number } };
export default function ProjectSettings({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || '');
const [showDelete, setShowDelete] = useState(false);
const [showReimport, setShowReimport] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
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] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project', project.id] });
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
},
});
const deleteMutation = useMutation({
@@ -21,20 +30,78 @@ export default function ProjectSettings({ project }: { project: Project }) {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
});
const handleReimportSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['project', project.id] });
queryClient.invalidateQueries({ queryKey: ['modules', project.id] });
queryClient.invalidateQueries({ queryKey: ['endpoints', project.id] });
setShowReimport(false);
};
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 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>
<div className="space-y-4">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">Project Name</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>
<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'}
</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-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.
</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
</button>
</section>
{/* Danger zone */}
<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>
</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>
</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"
variant="danger"
/>
{showReimport && (
<ReimportDialog
projectId={project.id}
currentStats={project._count}
onClose={() => setShowReimport(false)}
onSuccess={handleReimportSuccess}
/>
)}
</div>
);
}