diff --git a/packages/web/index.html b/packages/web/index.html index 929c7a3..95a4a58 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -4,10 +4,10 @@ - web + Agent Fox -
- +
+ diff --git a/packages/web/package.json b/packages/web/package.json index ccd2e34..6279047 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,5 +1,5 @@ { - "name": "web", + "name": "@agent-fox/web", "private": true, "version": "0.0.0", "type": "module", @@ -9,7 +9,18 @@ "preview": "vite preview" }, "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "vite": "^8.0.1" + }, + "dependencies": { + "@tanstack/react-query": "^5.96.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2" } } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..8256aeb --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,30 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from './lib/auth'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Layout from './pages/Layout'; +import Projects from './pages/Projects'; +import ProjectDetail from './pages/ProjectDetail'; + +const queryClient = new QueryClient(); + +export default function App() { + return ( + + + + + } /> + } /> + }> + } /> + } /> + + } /> + + + + + ); +} diff --git a/packages/web/src/assets/hero.png b/packages/web/src/assets/hero.png deleted file mode 100644 index cc51a3d..0000000 Binary files a/packages/web/src/assets/hero.png and /dev/null differ diff --git a/packages/web/src/assets/typescript.svg b/packages/web/src/assets/typescript.svg deleted file mode 100644 index 6c9d69c..0000000 --- a/packages/web/src/assets/typescript.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/web/src/assets/vite.svg b/packages/web/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/packages/web/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/packages/web/src/counter.ts b/packages/web/src/counter.ts deleted file mode 100644 index 9f629e8..0000000 --- a/packages/web/src/counter.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function setupCounter(element: HTMLButtonElement) { - let counter = 0 - const setCounter = (count: number) => { - counter = count - element.innerHTML = `Count is ${counter}` - } - element.addEventListener('click', () => setCounter(counter + 1)) - setCounter(0) -} diff --git a/packages/web/src/index.css b/packages/web/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/packages/web/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts new file mode 100644 index 0000000..dcb9e28 --- /dev/null +++ b/packages/web/src/lib/api.ts @@ -0,0 +1,74 @@ +const API_BASE = '/api'; + +type ApiResponse = { + success: boolean; + data?: T; + error?: { code: string; message: string }; +}; + +let accessToken: string | null = localStorage.getItem('accessToken'); +let refreshToken: string | null = localStorage.getItem('refreshToken'); + +export function setTokens(access: string, refresh: string) { + accessToken = access; + refreshToken = refresh; + localStorage.setItem('accessToken', access); + localStorage.setItem('refreshToken', refresh); +} + +export function clearTokens() { + accessToken = null; + refreshToken = null; + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); +} + +export function getAccessToken() { + return accessToken; +} + +async function refreshAccessToken(): Promise { + if (!refreshToken) return false; + try { + const res = await fetch(`${API_BASE}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!res.ok) return false; + const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json(); + if (json.success && json.data) { + setTokens(json.data.accessToken, json.data.refreshToken); + return true; + } + return false; + } catch { + return false; + } +} + +export async function apiFetch(path: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers); + if (!headers.has('Content-Type') && !(options.body instanceof FormData)) { + headers.set('Content-Type', 'application/json'); + } + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + + let res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (res.status === 401 && refreshToken) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + headers.set('Authorization', `Bearer ${accessToken}`); + res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + } + } + + const json: ApiResponse = await res.json(); + if (!json.success) { + throw new Error(json.error?.message || 'Request failed'); + } + return json.data as T; +} diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx new file mode 100644 index 0000000..9dc8948 --- /dev/null +++ b/packages/web/src/lib/auth.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { getAccessToken, clearTokens, setTokens, apiFetch } from './api'; + +type User = { id: string; email: string; name: string }; + +type AuthContextType = { + user: User | null; + loading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name: string) => Promise; + logout: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (getAccessToken()) { + apiFetch('/auth/me') + .then(setUser) + .catch(() => clearTokens()) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + const login = async (email: string, password: string) => { + const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>( + '/auth/login', + { method: 'POST', body: JSON.stringify({ email, password }) }, + ); + setTokens(data.accessToken, data.refreshToken); + setUser(data.user); + }; + + const register = async (email: string, password: string, name: string) => { + const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>( + '/auth/register', + { method: 'POST', body: JSON.stringify({ email, password, name }) }, + ); + setTokens(data.accessToken, data.refreshToken); + setUser(data.user); + }; + + const logout = () => { clearTokens(); setUser(null); }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts deleted file mode 100644 index 7a3f325..0000000 --- a/packages/web/src/main.ts +++ /dev/null @@ -1,60 +0,0 @@ -import './style.css' -import typescriptLogo from './assets/typescript.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import { setupCounter } from './counter.ts' - -document.querySelector('#app')!.innerHTML = ` -
-
- - TypeScript logo - Vite logo -
-
-

Get started

-

Edit src/main.ts and save to test HMR

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
-` - -setupCounter(document.querySelector('#counter')!) diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..dfacde0 --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx new file mode 100644 index 0000000..271a111 --- /dev/null +++ b/packages/web/src/pages/ImportDialog.tsx @@ -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(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const handleFileChange = (e: React.ChangeEvent) => { + 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; + if (mode === 'url') { + body = { specUrl: url }; + } else { + try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } + } + const data = await apiFetch('/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 ( +
+
+ {!result ? ( + <> +

Import OpenAPI Document

+
+ + +
+ {mode === 'url' ? ( + setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" /> + ) : ( + + )} + {error &&

{error}

} +
+ + +
+ + ) : ( + <> +

Import Successful!

+
+

Project: {result.project.name}

+

Modules: {result.stats.modules}

+

Endpoints: {result.stats.endpoints}

+
+

API Key (save it now):

+ {result.apiKey} +
+
+
+ +
+ + )} +
+
+ ); +} diff --git a/packages/web/src/pages/Layout.tsx b/packages/web/src/pages/Layout.tsx new file mode 100644 index 0000000..ac598c1 --- /dev/null +++ b/packages/web/src/pages/Layout.tsx @@ -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
Loading...
; + if (!user) return ; + + return ( +
+
+

Agent Fox

+
+ {user.name} + +
+
+
+
+ ); +} diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx new file mode 100644 index 0000000..7810a2e --- /dev/null +++ b/packages/web/src/pages/Login.tsx @@ -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 ( +
+
+

Sign In to Agent Fox

+ {error &&

{error}

} +
+ setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required /> + setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" required /> + +
+

+ Don't have an account? Sign Up +

+
+
+ ); +} diff --git a/packages/web/src/pages/ProjectDetail.tsx b/packages/web/src/pages/ProjectDetail.tsx new file mode 100644 index 0000000..2552556 --- /dev/null +++ b/packages/web/src/pages/ProjectDetail.tsx @@ -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('Documentation'); + + const { data: project, isLoading } = useQuery({ + queryKey: ['project', id], + queryFn: () => apiFetch(`/projects/${id}`), + }); + + if (isLoading) return
Loading...
; + if (!project) return
Project not found
; + + return ( +
+
← Back to projects
+
+
+

{project.name}

+ {project.description &&

{project.description}

} +
+
OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints
+
+
+
+ {tabs.map((tab) => ( + + ))} +
+
+ {activeTab === 'Documentation' && } + {activeTab === 'Modules' && } + {activeTab === 'MCP Integration' && } + {activeTab === 'Settings' && } +
+ ); +} diff --git a/packages/web/src/pages/Projects.tsx b/packages/web/src/pages/Projects.tsx new file mode 100644 index 0000000..adf4e74 --- /dev/null +++ b/packages/web/src/pages/Projects.tsx @@ -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('/projects'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }), + }); + + if (isLoading) return
Loading projects...
; + + return ( +
+
+

Projects

+ +
+ {projects?.length === 0 &&

No projects yet. Import an OpenAPI document to get started.

} +
+ {projects?.map((p) => ( +
+ +

{p.name}

+ {p.description &&

{p.description}

} +
+ OpenAPI {p.openApiVersion} + {p._count.modules} modules + {p._count.endpoints} endpoints +
+ + +
+ ))} +
+ {showImport && setShowImport(false)} />} +
+ ); +} diff --git a/packages/web/src/pages/Register.tsx b/packages/web/src/pages/Register.tsx new file mode 100644 index 0000000..702aa77 --- /dev/null +++ b/packages/web/src/pages/Register.tsx @@ -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 ( +
+
+

Create Account

+ {error &&

{error}

} +
+ setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" required /> + setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required /> + setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" minLength={8} required /> + +
+

+ Already have an account? Sign In +

+
+
+ ); +} diff --git a/packages/web/src/pages/tabs/DocPreview.tsx b/packages/web/src/pages/tabs/DocPreview.tsx new file mode 100644 index 0000000..a0ccf0c --- /dev/null +++ b/packages/web/src/pages/tabs/DocPreview.tsx @@ -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 = { + 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(null); + const [expandedEndpoint, setExpandedEndpoint] = useState(null); + + const { data: modules } = useQuery({ + queryKey: ['modules', projectId], + queryFn: () => apiFetch(`/projects/${projectId}/modules`), + }); + + const { data: endpoints } = useQuery({ + queryKey: ['endpoints', projectId, selectedModule], + queryFn: () => apiFetch(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`), + }); + + const { data: endpointDetail } = useQuery({ + queryKey: ['endpoint-detail', projectId, expandedEndpoint], + queryFn: () => apiFetch(`/projects/${projectId}/endpoints/${expandedEndpoint}`), + enabled: !!expandedEndpoint, + }); + + return ( +
+
+

Modules

+ + {modules?.map((m) => ( + + ))} +
+
+ {endpoints?.map((ep) => ( +
+ + {expandedEndpoint === ep.id && endpointDetail && ( +
+ {endpointDetail.description &&

{endpointDetail.description}

} + {Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && ( +

Parameters

{JSON.stringify(endpointDetail.parameters, null, 2)}
+ )} + {endpointDetail.requestBody != null && ( +

Request Body

{JSON.stringify(endpointDetail.requestBody, null, 2)}
+ )} + {endpointDetail.responses != null && ( +

Responses

{JSON.stringify(endpointDetail.responses, null, 2)}
+ )} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/packages/web/src/pages/tabs/McpIntegration.tsx b/packages/web/src/pages/tabs/McpIntegration.tsx new file mode 100644 index 0000000..a273e6e --- /dev/null +++ b/packages/web/src/pages/tabs/McpIntegration.tsx @@ -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(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 || ''}` }, + }, + }, + }, null, 2); + + return ( +
+
+

MCP Service URL

+
+ {mcpUrl} + +
+
+
+

API Key

+ {apiKey ? ( +
+

Save this key — it won't be shown again.

+ {apiKey} +
+ ) : ( +

API key is hidden. Rotate to generate a new one.

+ )} + +
+
+

Configuration for Claude Code / Cursor

+
+
{configSnippet}
+ +
+
+
+

Available Tools

+
+ {[ + { 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) => ( +
+ {t.name} +

{t.desc}

+
+ ))} +
+
+
+ ); +} diff --git a/packages/web/src/pages/tabs/ModuleManagement.tsx b/packages/web/src/pages/tabs/ModuleManagement.tsx new file mode 100644 index 0000000..0884c27 --- /dev/null +++ b/packages/web/src/pages/tabs/ModuleManagement.tsx @@ -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(`/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 ( +
+
+ setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" /> + +
+
+ {modules?.map((m) => ( +
+
+ {m.name} + ({m.source}) + {m._count.endpoints} endpoints +
+ +
+ ))} +
+
+ ); +} diff --git a/packages/web/src/pages/tabs/ProjectSettings.tsx b/packages/web/src/pages/tabs/ProjectSettings.tsx new file mode 100644 index 0000000..ee421bd --- /dev/null +++ b/packages/web/src/pages/tabs/ProjectSettings.tsx @@ -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 ( +
+
+
+ setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" />
+
+