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

@@ -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>
);
}