diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts
index c53fff7..003cabf 100644
--- a/packages/server/src/routes/auth.ts
+++ b/packages/server/src/routes/auth.ts
@@ -88,6 +88,56 @@ router.post('/refresh', async (req, res) => {
}
});
+const changePasswordSchema = z.object({
+ currentPassword: z.string(),
+ newPassword: z.string().min(8),
+});
+
+router.post('/change-password', requireAuth, async (req, res) => {
+ const parsed = changePasswordSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
+ return;
+ }
+
+ const { currentPassword, newPassword } = parsed.data;
+ const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
+
+ if (!user || !user.passwordHash) {
+ res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set for this account' } });
+ return;
+ }
+
+ const valid = await verifyPassword(currentPassword, user.passwordHash);
+ if (!valid) {
+ res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Current password is incorrect' } });
+ return;
+ }
+
+ const newHash = await hashPassword(newPassword);
+ await prisma.user.update({ where: { id: user.id }, data: { passwordHash: newHash } });
+ res.json({ success: true, data: { message: 'Password changed' } });
+});
+
+const profileSchema = z.object({
+ name: z.string().min(1).max(100),
+});
+
+router.put('/profile', requireAuth, async (req, res) => {
+ const parsed = profileSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
+ return;
+ }
+
+ const user = await prisma.user.update({
+ where: { id: req.user!.userId },
+ data: { name: parsed.data.name },
+ select: { id: true, email: true, name: true },
+ });
+ res.json({ success: true, data: user });
+});
+
router.get('/me', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
diff --git a/packages/web/index.html b/packages/web/index.html
index 95a4a58..759e65f 100644
--- a/packages/web/index.html
+++ b/packages/web/index.html
@@ -4,6 +4,9 @@
+
+
+
Agent Fox
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx
index 8256aeb..51ca5e4 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -1,30 +1,35 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './lib/auth';
+import { ThemeProvider } from './lib/theme';
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';
+import Settings from './pages/Settings';
const queryClient = new QueryClient();
export default function App() {
return (
-
-
-
- } />
- } />
- }>
- } />
- } />
-
- } />
-
-
-
+
+
+
+
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+
+ } />
+
+
+
+
);
}
diff --git a/packages/web/src/components/Badge.tsx b/packages/web/src/components/Badge.tsx
new file mode 100644
index 0000000..0857eb4
--- /dev/null
+++ b/packages/web/src/components/Badge.tsx
@@ -0,0 +1,26 @@
+type BadgeProps = {
+ children: React.ReactNode;
+ variant?: 'default' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'accent' | 'warning';
+};
+
+export default function Badge({ children, variant = 'default' }: BadgeProps) {
+ if (['get', 'post', 'put', 'delete', 'patch'].includes(variant)) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ const styles: Record = {
+ default: 'bg-bg-tertiary text-text-secondary',
+ accent: 'bg-accent-muted text-accent',
+ warning: 'bg-warning-muted text-warning',
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/web/src/components/ConfirmDialog.tsx b/packages/web/src/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..dff3b81
--- /dev/null
+++ b/packages/web/src/components/ConfirmDialog.tsx
@@ -0,0 +1,42 @@
+import Modal from './Modal';
+
+type ConfirmDialogProps = {
+ open: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+ title: string;
+ description: string;
+ confirmText?: string;
+ variant?: 'danger' | 'warning';
+};
+
+export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText = 'Confirm', variant = 'danger' }: ConfirmDialogProps) {
+ const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
+
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+ Cancel
+
+ {confirmText}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/EmptyState.tsx b/packages/web/src/components/EmptyState.tsx
new file mode 100644
index 0000000..255de53
--- /dev/null
+++ b/packages/web/src/components/EmptyState.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react';
+
+type EmptyStateProps = {
+ icon?: ReactNode;
+ title: string;
+ description?: string;
+ action?: ReactNode;
+};
+
+export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
+ return (
+
+ {icon &&
{icon}
}
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/packages/web/src/components/Modal.tsx b/packages/web/src/components/Modal.tsx
new file mode 100644
index 0000000..a2fa2c8
--- /dev/null
+++ b/packages/web/src/components/Modal.tsx
@@ -0,0 +1,33 @@
+import { useEffect, useRef, type ReactNode } from 'react';
+
+type ModalProps = {
+ open: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ size?: 'sm' | 'md' | 'lg';
+};
+
+const widths = { sm: '384px', md: '512px', lg: '672px' };
+
+export default function Modal({ open, onClose, children, size = 'md' }: ModalProps) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const dialog = ref.current;
+ if (!dialog) return;
+ if (open && !dialog.open) dialog.showModal();
+ else if (!open && dialog.open) dialog.close();
+ }, [open]);
+
+ return (
+ { if (e.target === ref.current) onClose(); }}
+ style={{ width: widths[size] }}
+ className="rounded-xl border border-border-default bg-bg-elevated p-0 shadow-lg"
+ >
+ {children}
+
+ );
+}
diff --git a/packages/web/src/components/Skeleton.tsx b/packages/web/src/components/Skeleton.tsx
new file mode 100644
index 0000000..ae7060b
--- /dev/null
+++ b/packages/web/src/components/Skeleton.tsx
@@ -0,0 +1,7 @@
+type SkeletonProps = {
+ className?: string;
+};
+
+export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
+ return
;
+}
diff --git a/packages/web/src/components/ThemeToggle.tsx b/packages/web/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..4a1380a
--- /dev/null
+++ b/packages/web/src/components/ThemeToggle.tsx
@@ -0,0 +1,45 @@
+import { useTheme } from '../lib/theme';
+
+const icons = {
+ light: (
+
+
+
+ ),
+ dark: (
+
+
+
+ ),
+ system: (
+
+
+
+ ),
+};
+
+const labels = { light: '浅色', dark: '深色', system: '跟随系统' } as const;
+const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
+
+export default function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+
+ return (
+
+ {order.map((t) => (
+ setTheme(t)}
+ title={labels[t]}
+ className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
+ theme === t
+ ? 'bg-bg-elevated text-text-primary shadow-sm'
+ : 'text-text-muted hover:text-text-secondary'
+ }`}
+ >
+ {icons[t]}
+
+ ))}
+
+ );
+}
diff --git a/packages/web/src/index.css b/packages/web/src/index.css
index f1d8c73..1ec1fb4 100644
--- a/packages/web/src/index.css
+++ b/packages/web/src/index.css
@@ -1 +1,338 @@
@import "tailwindcss";
+
+/* ===== Theme Variables ===== */
+:root {
+ /* Light theme (default) */
+ --bg-primary: #ffffff;
+ --bg-secondary: #f8f9fb;
+ --bg-tertiary: #f0f1f4;
+ --bg-elevated: #ffffff;
+ --bg-inset: #e8e9ed;
+ --bg-sidebar: #fafbfc;
+ --border-default: #e2e4e9;
+ --border-muted: #eef0f3;
+ --border-strong: #cdd0d5;
+ --text-primary: #0f1115;
+ --text-secondary: #4a4f5a;
+ --text-muted: #868c98;
+ --text-inverted: #ffffff;
+ --accent: #6366f1;
+ --accent-hover: #4f46e5;
+ --accent-subtle: #eef2ff;
+ --accent-muted: rgba(99, 102, 241, 0.1);
+ --danger: #e5484d;
+ --danger-muted: rgba(229, 72, 77, 0.08);
+ --success: #30a46c;
+ --success-muted: rgba(48, 164, 108, 0.08);
+ --warning: #e5a000;
+ --warning-muted: rgba(229, 160, 0, 0.08);
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.02);
+ --code-bg: #1a1b26;
+ --code-text: #9ece6a;
+ --code-comment: #565f89;
+ --code-keyword: #bb9af7;
+ --overlay: rgba(0, 0, 0, 0.4);
+ --method-get: #30a46c;
+ --method-get-bg: rgba(48, 164, 108, 0.1);
+ --method-post: #3b82f6;
+ --method-post-bg: rgba(59, 130, 246, 0.1);
+ --method-put: #e5a000;
+ --method-put-bg: rgba(229, 160, 0, 0.1);
+ --method-delete: #e5484d;
+ --method-delete-bg: rgba(229, 72, 77, 0.1);
+ --method-patch: #8b5cf6;
+ --method-patch-bg: rgba(139, 92, 246, 0.1);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg-primary: #0a0a0c;
+ --bg-secondary: #101012;
+ --bg-tertiary: #18181b;
+ --bg-elevated: #1a1a1e;
+ --bg-inset: #232326;
+ --bg-sidebar: #0e0e10;
+ --border-default: #27272a;
+ --border-muted: #1e1e21;
+ --border-strong: #3f3f46;
+ --text-primary: #ececef;
+ --text-secondary: #a0a0ab;
+ --text-muted: #63636e;
+ --text-inverted: #0a0a0c;
+ --accent: #818cf8;
+ --accent-hover: #6366f1;
+ --accent-subtle: rgba(129, 140, 248, 0.08);
+ --accent-muted: rgba(129, 140, 248, 0.12);
+ --danger: #f87171;
+ --danger-muted: rgba(248, 113, 113, 0.1);
+ --success: #4ade80;
+ --success-muted: rgba(74, 222, 128, 0.1);
+ --warning: #fbbf24;
+ --warning-muted: rgba(251, 191, 36, 0.1);
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.5);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
+ --code-bg: #0c0c0f;
+ --code-text: #9ece6a;
+ --code-comment: #565f89;
+ --code-keyword: #bb9af7;
+ --overlay: rgba(0, 0, 0, 0.65);
+ --method-get: #4ade80;
+ --method-get-bg: rgba(74, 222, 128, 0.12);
+ --method-post: #60a5fa;
+ --method-post-bg: rgba(96, 165, 250, 0.12);
+ --method-put: #fbbf24;
+ --method-put-bg: rgba(251, 191, 36, 0.12);
+ --method-delete: #f87171;
+ --method-delete-bg: rgba(248, 113, 113, 0.12);
+ --method-patch: #a78bfa;
+ --method-patch-bg: rgba(167, 139, 250, 0.12);
+ }
+}
+
+[data-theme="dark"] {
+ --bg-primary: #0a0a0c;
+ --bg-secondary: #101012;
+ --bg-tertiary: #18181b;
+ --bg-elevated: #1a1a1e;
+ --bg-inset: #232326;
+ --bg-sidebar: #0e0e10;
+ --border-default: #27272a;
+ --border-muted: #1e1e21;
+ --border-strong: #3f3f46;
+ --text-primary: #ececef;
+ --text-secondary: #a0a0ab;
+ --text-muted: #63636e;
+ --text-inverted: #0a0a0c;
+ --accent: #818cf8;
+ --accent-hover: #6366f1;
+ --accent-subtle: rgba(129, 140, 248, 0.08);
+ --accent-muted: rgba(129, 140, 248, 0.12);
+ --danger: #f87171;
+ --danger-muted: rgba(248, 113, 113, 0.1);
+ --success: #4ade80;
+ --success-muted: rgba(74, 222, 128, 0.1);
+ --warning: #fbbf24;
+ --warning-muted: rgba(251, 191, 36, 0.1);
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
+ --shadow-md: 0 2px 8px rgba(0,0,0,0.5);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
+ --code-bg: #0c0c0f;
+ --code-text: #9ece6a;
+ --code-comment: #565f89;
+ --code-keyword: #bb9af7;
+ --overlay: rgba(0, 0, 0, 0.65);
+ --method-get: #4ade80;
+ --method-get-bg: rgba(74, 222, 128, 0.12);
+ --method-post: #60a5fa;
+ --method-post-bg: rgba(96, 165, 250, 0.12);
+ --method-put: #fbbf24;
+ --method-put-bg: rgba(251, 191, 36, 0.12);
+ --method-delete: #f87171;
+ --method-delete-bg: rgba(248, 113, 113, 0.12);
+ --method-patch: #a78bfa;
+ --method-patch-bg: rgba(167, 139, 250, 0.12);
+}
+
+/* ===== Tailwind Theme ===== */
+@theme {
+ --font-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
+ --font-display: 'DM Sans', system-ui, sans-serif;
+
+ --color-bg-primary: var(--bg-primary);
+ --color-bg-secondary: var(--bg-secondary);
+ --color-bg-tertiary: var(--bg-tertiary);
+ --color-bg-elevated: var(--bg-elevated);
+ --color-bg-inset: var(--bg-inset);
+ --color-bg-sidebar: var(--bg-sidebar);
+ --color-border-default: var(--border-default);
+ --color-border-muted: var(--border-muted);
+ --color-border-strong: var(--border-strong);
+ --color-text-primary: var(--text-primary);
+ --color-text-secondary: var(--text-secondary);
+ --color-text-muted: var(--text-muted);
+ --color-text-inverted: var(--text-inverted);
+ --color-accent: var(--accent);
+ --color-accent-hover: var(--accent-hover);
+ --color-accent-subtle: var(--accent-subtle);
+ --color-accent-muted: var(--accent-muted);
+ --color-danger: var(--danger);
+ --color-danger-muted: var(--danger-muted);
+ --color-success: var(--success);
+ --color-success-muted: var(--success-muted);
+ --color-warning: var(--warning);
+ --color-warning-muted: var(--warning-muted);
+ --color-code-bg: var(--code-bg);
+ --color-code-text: var(--code-text);
+ --color-overlay: var(--overlay);
+
+ --shadow-sm: var(--shadow-sm);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+
+ --animate-fade-in: fade-in 0.2s ease-out both;
+ --animate-slide-up: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
+ --animate-shimmer: shimmer 1.8s ease-in-out infinite;
+ --animate-pulse-soft: pulse-soft 2s ease-in-out infinite;
+
+ @keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+ @keyframes slide-up {
+ from { opacity: 0; transform: translateY(10px) scale(0.97); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+ }
+ @keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+ }
+ @keyframes pulse-soft {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+ }
+}
+
+/* ===== Base ===== */
+body {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+::selection {
+ background: var(--accent);
+ color: white;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 5px; height: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 99px; }
+::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
+
+/* ===== Component Utilities ===== */
+@layer components {
+ .btn-primary {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
+ background: var(--accent);
+ color: white;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
+ &:hover { background: var(--accent-hover); transform: translateY(-0.5px); }
+ &:active { transform: translateY(0); }
+ &:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
+ }
+ .btn-ghost {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
+ color: var(--text-secondary);
+ &:hover { background: var(--bg-tertiary); color: var(--text-primary); }
+ }
+ .btn-danger {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
+ background: var(--danger);
+ color: white;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+ &:hover { opacity: 0.9; transform: translateY(-0.5px); }
+ &:active { transform: translateY(0); }
+ &:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
+ }
+ .btn-outline {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer;
+ border: 1px solid var(--border-default);
+ color: var(--text-secondary);
+ &:hover { border-color: var(--border-strong); color: var(--text-primary); background: var(--bg-tertiary); }
+ }
+ .input-base {
+ @apply w-full px-3.5 py-2.5 rounded-lg text-sm transition-all duration-150 outline-none;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-default);
+ color: var(--text-primary);
+ &::placeholder { color: var(--text-muted); }
+ &:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); }
+ }
+ .card {
+ @apply rounded-xl transition-all duration-200;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-default);
+ }
+ .card-hover {
+ &:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
+ }
+ .skeleton {
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-inset) 50%, var(--bg-tertiary) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.8s ease-in-out infinite;
+ border-radius: 0.5rem;
+ }
+ .code-block {
+ @apply rounded-lg p-4 text-sm font-mono overflow-auto relative;
+ background: var(--code-bg);
+ color: var(--code-text);
+ border: 1px solid var(--border-default);
+ }
+ .section-label {
+ @apply text-[11px] font-semibold uppercase tracking-[0.08em];
+ color: var(--text-muted);
+ letter-spacing: 0.08em;
+ }
+ .section-title {
+ @apply text-sm font-semibold;
+ color: var(--text-primary);
+ }
+ .section-desc {
+ @apply text-[13px] mt-0.5;
+ color: var(--text-muted);
+ }
+ .copy-btn {
+ @apply px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 cursor-pointer;
+ background: rgba(255,255,255,0.08);
+ color: rgba(255,255,255,0.6);
+ &:hover { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
+ }
+}
+
+/* ===== Method Badges ===== */
+.method-badge {
+ @apply inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold font-mono tracking-wide;
+}
+.method-get { background: var(--method-get-bg); color: var(--method-get); }
+.method-post { background: var(--method-post-bg); color: var(--method-post); }
+.method-put { background: var(--method-put-bg); color: var(--method-put); }
+.method-delete { background: var(--method-delete-bg); color: var(--method-delete); }
+.method-patch { background: var(--method-patch-bg); color: var(--method-patch); }
+
+/* ===== Dialog ===== */
+dialog {
+ color: var(--text-secondary);
+ max-height: calc(100vh - 4rem);
+ max-width: calc(100vw - 2rem);
+}
+dialog[open] {
+ position: fixed;
+ inset: 0;
+ margin: auto;
+ animation: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+dialog::backdrop {
+ background: var(--overlay);
+ backdrop-filter: blur(6px);
+}
+
+/* ===== Staggered children animation ===== */
+.stagger-children > * {
+ animation: fade-in 0.3s ease-out both;
+}
+.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
+.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
+.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
+.stagger-children > *:nth-child(4) { animation-delay: 120ms; }
+.stagger-children > *:nth-child(5) { animation-delay: 160ms; }
+.stagger-children > *:nth-child(6) { animation-delay: 200ms; }
+.stagger-children > *:nth-child(7) { animation-delay: 240ms; }
+.stagger-children > *:nth-child(8) { animation-delay: 280ms; }
+.stagger-children > *:nth-child(n+9) { animation-delay: 320ms; }
diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx
index 9dc8948..9c48e0a 100644
--- a/packages/web/src/lib/auth.tsx
+++ b/packages/web/src/lib/auth.tsx
@@ -9,6 +9,7 @@ type AuthContextType = {
login: (email: string, password: string) => Promise;
register: (email: string, password: string, name: string) => Promise;
logout: () => void;
+ updateUser: (updates: Partial) => void;
};
const AuthContext = createContext(null);
@@ -48,8 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = () => { clearTokens(); setUser(null); };
+ const updateUser = (updates: Partial) => {
+ setUser(prev => prev ? { ...prev, ...updates } : null);
+ };
+
return (
-
+
{children}
);
diff --git a/packages/web/src/lib/theme.tsx b/packages/web/src/lib/theme.tsx
new file mode 100644
index 0000000..acec28f
--- /dev/null
+++ b/packages/web/src/lib/theme.tsx
@@ -0,0 +1,64 @@
+import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
+
+type Theme = 'light' | 'dark' | 'system';
+
+type ThemeContextType = {
+ theme: Theme;
+ resolved: 'light' | 'dark';
+ setTheme: (t: Theme) => void;
+};
+
+const ThemeContext = createContext(null);
+
+function getSystemTheme(): 'light' | 'dark' {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function applyTheme(theme: Theme) {
+ const resolved = theme === 'system' ? getSystemTheme() : theme;
+ if (theme === 'system') {
+ document.documentElement.removeAttribute('data-theme');
+ } else {
+ document.documentElement.setAttribute('data-theme', theme);
+ }
+ return resolved;
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setThemeState] = useState(() => {
+ return (localStorage.getItem('agent-fox-theme') as Theme) || 'system';
+ });
+ const [resolved, setResolved] = useState<'light' | 'dark'>(() => applyTheme(
+ (localStorage.getItem('agent-fox-theme') as Theme) || 'system'
+ ));
+
+ const setTheme = (t: Theme) => {
+ localStorage.setItem('agent-fox-theme', t);
+ setThemeState(t);
+ setResolved(applyTheme(t));
+ };
+
+ useEffect(() => {
+ setResolved(applyTheme(theme));
+ }, [theme]);
+
+ useEffect(() => {
+ if (theme !== 'system') return;
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+ const handler = () => setResolved(applyTheme('system'));
+ mq.addEventListener('change', handler);
+ return () => mq.removeEventListener('change', handler);
+ }, [theme]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
+ return ctx;
+}
diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx
index 271a111..85a6032 100644
--- a/packages/web/src/pages/ImportDialog.tsx
+++ b/packages/web/src/pages/ImportDialog.tsx
@@ -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('');
+ const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [dragging, setDragging] = useState(false);
+ const fileInputRef = useRef(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
- const handleFileChange = (e: React.ChangeEvent) => {
- 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 (
-
-
- {!result ? (
- <>
-
Import OpenAPI Document
-
-
setMode('url')} className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>From URL
-
setMode('file')} className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>Upload File
+
+ {!result ? (
+
+
+
Import OpenAPI Document
+
Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.
+
+
+ {/* Mode toggle */}
+
+ 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
+ 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
+
+
+ {mode === 'url' ? (
+
setUrl(e.target.value)} className="input-base" />
+ ) : (
+
{ 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'
+ }`}
+ >
+
e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
+
+
+
+ {fileName ? (
+
{fileName}
+ ) : (
+ <>
+
Drop your OpenAPI file here
+
JSON or YAML
+ >
+ )}
- {mode === 'url' ? (
-
setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" />
- ) : (
-
- )}
- {error &&
{error}
}
-
-
Cancel
-
- {loading ? 'Importing...' : 'Import'}
+ )}
+
+ {error && (
+
+ )}
+
+
+
Cancel
+
+ {loading ? (
+ <> Importing...>
+ ) : 'Import'}
+
+
+
+ ) : (
+
+
+
+
+
Import Successful
+
{result.project.name}
+
+
+
+
+
+
{result.stats.modules}
+
Modules
+
+
+
{result.stats.endpoints}
+
Endpoints
+
+
+
+
+
+
+
+
API Key — save it now
+
+
+ {copied ? 'Copied!' : 'Copy'}
- >
- ) : (
- <>
-
Import Successful!
-
-
Project: {result.project.name}
-
Modules: {result.stats.modules}
-
Endpoints: {result.stats.endpoints}
-
-
API Key (save it now):
-
{result.apiKey}
-
-
-
- navigate(`/projects/${result.project.id}`)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Go to Project
-
- >
- )}
-
-
+
{result.apiKey}
+
+
+
+ navigate(`/projects/${result.project.id}`)} className="btn-primary">Go to Project
+
+
+ )}
+
);
}
diff --git a/packages/web/src/pages/Layout.tsx b/packages/web/src/pages/Layout.tsx
index ac598c1..e03dd10 100644
--- a/packages/web/src/pages/Layout.tsx
+++ b/packages/web/src/pages/Layout.tsx
@@ -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
Loading...
;
+ if (loading) {
+ return (
+
+ );
+ }
if (!user) return
;
+ const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
+ const isSettings = location.pathname === '/settings';
+
return (
-
-
- Agent Fox
-
-
{user.name}
-
Sign Out
+
+ {/* Mobile overlay */}
+ {sidebarOpen && (
+
setSidebarOpen(false)} />
+ )}
+
+ {/* Sidebar */}
+
+ {/* Brand */}
+
-
-
+
+ {/* Navigation */}
+
+
+ `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'
+ }`
+ }
+ >
+
+
+
+ Projects
+
+
+ `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'
+ }`
+ }
+ >
+
+
+
+
+ Settings
+
+
+
+ {/* Bottom section */}
+
+
+
+
+
+
+
+ {initials}
+
+
+
{user.name}
+
{user.email}
+
+
+
+
+
+
+ Sign Out
+
+
+
+
+
+ {/* Main */}
+
+ {/* Mobile header */}
+
+ setSidebarOpen(true)} className="p-2 -ml-2 text-text-secondary hover:text-text-primary">
+
+
+
+
+ Agent Fox
+
+
+
+
+
+
+
);
}
diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx
index 7810a2e..1153fd6 100644
--- a/packages/web/src/pages/Login.tsx
+++ b/packages/web/src/pages/Login.tsx
@@ -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 (
-
-
-
Sign In to Agent Fox
- {error &&
{error}
}
-
-
- Don't have an account? Sign Up
+
+ {/* Subtle grid background */}
+
+ {/* Radial fade */}
+
+
+
+ {/* Brand */}
+
+
+
Sign in to Agent Fox
+
API documentation for LLMs
+
+
+ {/* Card */}
+
+
+
+ Don't have an account?{' '}
+ Sign Up
diff --git a/packages/web/src/pages/ProjectDetail.tsx b/packages/web/src/pages/ProjectDetail.tsx
index 2552556..c21e7ae 100644
--- a/packages/web/src/pages/ProjectDetail.tsx
+++ b/packages/web/src/pages/ProjectDetail.tsx
@@ -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
('Documentation');
+ const [activeTab, setActiveTab] = useState('docs');
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],
queryFn: () => apiFetch(`/projects/${id}`),
});
- if (isLoading) return Loading...
;
- if (!project) return Project not found
;
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!project) {
+ return (
+
+
+
Project not found
+
Back to projects
+
+ );
+ }
return (
-
← Back to projects
-
+ {/* Breadcrumb */}
+
+
Projects
+
+
{project.name}
+
+
+ {/* Header */}
+
-
{project.name}
- {project.description &&
{project.description}
}
+
{project.name}
+ {project.description &&
{project.description}
}
-
OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints
-
-
-
- {tabs.map((tab) => (
-
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}
-
- ))}
+
+ OpenAPI {project.openApiVersion}
+ {project._count.endpoints} endpoints
- {activeTab === 'Documentation' &&
}
- {activeTab === 'Modules' &&
}
- {activeTab === 'MCP Integration' &&
}
- {activeTab === 'Settings' &&
}
+
+ {/* Tabs */}
+
+ {tabs.map((tab) => (
+
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'
+ }`}
+ >
+
+ {tab.label}
+
+ ))}
+
+
+ {/* Content */}
+
+ {activeTab === 'docs' &&
}
+ {activeTab === 'modules' &&
}
+ {activeTab === 'mcp' &&
}
+ {activeTab === 'settings' &&
}
+
);
}
diff --git a/packages/web/src/pages/Projects.tsx b/packages/web/src/pages/Projects.tsx
index adf4e74..b8424db 100644
--- a/packages/web/src/pages/Projects.tsx
+++ b/packages/web/src/pages/Projects.tsx
@@ -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
(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 Loading projects...
;
-
return (
-
Projects
-
setShowImport(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Import API Doc
+
Projects
+
setShowImport(true)} className="btn-primary">
+
+
+
+ Import API Doc
+
- {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
+
+ {isLoading ? (
+
+ {[1, 2, 3].map(i => (
+
+
+
+
+
+
+
-
-
{ if (confirm('Delete this project?')) deleteMutation.mutate(p.id); }}
- className="mt-2 text-xs text-red-500 hover:underline">Delete
-
- ))}
-
+
+ ))}
+
+ ) : projects?.length === 0 ? (
+
+
+
+ }
+ title="No projects yet"
+ description="Import an OpenAPI document to get started with MCP-powered API documentation."
+ action={
+ setShowImport(true)} className="btn-primary">
+
+
+
+ Import Your First API
+
+ }
+ />
+ ) : (
+
+ {projects?.map((p) => (
+
+
+
{p.name}
+ {p.description &&
{p.description}
}
+
+ OpenAPI {p.openApiVersion}
+ {p._count.modules} modules
+ {p._count.endpoints} endpoints
+
+
+
{ 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"
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
{showImport && setShowImport(false)} />}
+
+ 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"
+ />
);
}
diff --git a/packages/web/src/pages/Register.tsx b/packages/web/src/pages/Register.tsx
index 702aa77..5eb23b3 100644
--- a/packages/web/src/pages/Register.tsx
+++ b/packages/web/src/pages/Register.tsx
@@ -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 (
-
-
-
Create Account
- {error &&
{error}
}
-
-
- Already have an account? Sign In
+
+
+
+
+
+
+
+
Create your account
+
Get started with Agent Fox
+
+
+
+
+
+ Already have an account?{' '}
+ Sign In
diff --git a/packages/web/src/pages/ReimportDialog.tsx b/packages/web/src/pages/ReimportDialog.tsx
new file mode 100644
index 0000000..15b7210
--- /dev/null
+++ b/packages/web/src/pages/ReimportDialog.tsx
@@ -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
('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(null);
+ const [dragging, setDragging] = useState(false);
+ const fileInputRef = useRef(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;
+ if (mode === 'url') {
+ body = { specUrl: url };
+ } else {
+ try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
+ }
+ const data = await apiFetch(`/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 (
+
+ {step === 'confirm' && (
+
+
+
Re-import API Document
+
This action will replace all existing data.
+
+
+
+
+
+
+
+
+
The following data will be permanently deleted:
+
+ {currentStats.modules} module{currentStats.modules !== 1 ? 's' : ''}
+ {currentStats.endpoints} endpoint{currentStats.endpoints !== 1 ? 's' : ''}
+
+
New modules and endpoints will be created from the imported document. The API key will remain unchanged.
+
+
+
+
+
+ Cancel
+ setStep('import')} className="btn-primary">Continue
+
+
+ )}
+
+ {step === 'import' && (
+
+
+
Import New Document
+
Provide a Swagger 2.0 or OpenAPI 3.x document.
+
+
+
+ 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
+ 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
+
+
+ {mode === 'url' ? (
+
setUrl(e.target.value)} className="input-base" />
+ ) : (
+
{ 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'
+ }`}
+ >
+
e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
+
+
+
+ {fileName ? (
+
{fileName}
+ ) : (
+ <>
+
Drop your OpenAPI file here
+
JSON or YAML
+ >
+ )}
+
+ )}
+
+ {error &&
{error}
}
+
+
+
setStep('confirm')} className="btn-ghost">Back
+
+ {loading ? (
+ <>
+
+ Importing...
+ >
+ ) : 'Re-import'}
+
+
+
+ )}
+
+ {step === 'success' && result && (
+
+
+
+
+
Re-import Successful
+
API documentation has been updated.
+
+
+
+
+
+
{result.stats.modules}
+
Modules
+
+
+
{result.stats.endpoints}
+
Endpoints
+
+
+
+
+ { onSuccess(); onClose(); }} className="btn-primary">Done
+
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx
new file mode 100644
index 0000000..870c93a
--- /dev/null
+++ b/packages/web/src/pages/Settings.tsx
@@ -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 (
+
+
Settings
+
+ {/* Profile */}
+
+ Profile
+ Manage your personal information.
+
+
+
+ {initials}
+
+
+
{user?.name}
+
{user?.email}
+
+
+
+
+
+ Display Name
+ setName(e.target.value)} className="input-base max-w-sm" />
+
+
+
+
+ {profileMsg && (
+
+
+ {profileMsg.type === 'success' ? : }
+
+ {profileMsg.text}
+
+ )}
+
+
+ {profileLoading ? 'Saving...' : 'Save Profile'}
+
+
+
+
+ {/* Password */}
+
+ Change Password
+ Update your password to keep your account secure.
+
+
+
+ Current Password
+ setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" />
+
+
+ New Password
+ setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} />
+
+
+ Confirm New Password
+ setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" />
+
+
+ {passwordMsg && (
+
+
+ {passwordMsg.type === 'success' ? : }
+
+ {passwordMsg.text}
+
+ )}
+
+
+ {passwordLoading ? 'Changing...' : 'Change Password'}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/pages/tabs/DocPreview.tsx b/packages/web/src/pages/tabs/DocPreview.tsx
index a0ccf0c..3686cae 100644
--- a/packages/web/src/pages/tabs/DocPreview.tsx
+++ b/packages/web/src/pages/tabs/DocPreview.tsx
@@ -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 = {
- 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 = {
+ GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete', PATCH: 'patch',
};
export default function DocPreview({ projectId }: { projectId: string }) {
const [selectedModule, setSelectedModule] = useState(null);
const [expandedEndpoint, setExpandedEndpoint] = useState(null);
- const { data: modules } = useQuery({
+ const { data: modules, isLoading: modulesLoading } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch(`/projects/${projectId}/modules`),
});
- const { data: endpoints } = useQuery({
+ const { data: endpoints, isLoading: endpointsLoading } = useQuery({
queryKey: ['endpoints', projectId, selectedModule],
queryFn: () => apiFetch(`/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 (
-
-
-
Modules
-
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})
-
- {modules?.map((m) => (
-
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})
-
- ))}
+
+ {/* Module sidebar */}
+
+
+
Modules
+ {modulesLoading ? (
+
{[1,2,3].map(i => )}
+ ) : modules?.length === 0 ? (
+
No modules
+ ) : (
+
+ 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 {totalEndpoints}
+
+ {modules?.map((m) => (
+ 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} {m._count.endpoints}
+
+ ))}
+
+ )}
+
-
- {endpoints?.map((ep) => (
-
-
setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)} className="w-full text-left px-4 py-3 flex items-center gap-3">
- {ep.method}
- {ep.path}
- {ep.summary && {ep.summary} }
- {ep.deprecated && deprecated }
-
- {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)}
+
+ {/* Endpoints */}
+
+ {endpointsLoading ? (
+
{[1,2,3,4,5].map(i => )}
+ ) : endpoints?.length === 0 ? (
+
+
+
+ }
+ title="No endpoints"
+ description={selectedModule ? "This module has no endpoints." : "No endpoints in this project yet. Import an API document to get started."}
+ />
+ ) : (
+
+ {endpoints?.map((ep) => (
+
+
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"
+ >
+ {ep.method}
+ {ep.path}
+ {ep.summary && {ep.summary} }
+ {ep.deprecated && deprecated }
+
+
+ {expandedEndpoint === ep.id && endpointDetail && (
+
+ {endpointDetail.description &&
{endpointDetail.description}
}
+ {endpointDetail.operationId && (
+
+ Operation ID
+ {endpointDetail.operationId}
+
+ )}
+ {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
index 5e585d6..7ec095b 100644
--- a/packages/web/src/pages/tabs/McpIntegration.tsx
+++ b/packages/web/src/pages/tabs/McpIntegration.tsx
@@ -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
(null);
+ const [showRotateConfirm, setShowRotateConfirm] = useState(false);
+ const [copied, setCopied] = useState(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 (
-
-
-
MCP Service URL
+
+ {/* MCP URL */}
+
+ MCP Service URL
+ Connect your LLM client to this endpoint.
-
{mcpUrl}
-
navigator.clipboard.writeText(mcpUrl)} className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300">Copy
+
{mcpUrl}
+
copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
+ {copied === 'url' ? (
+ <> Copied>
+ ) : (
+ <> Copy>
+ )}
+
-
-
-
API Key
+
+
+ {/* API Key */}
+
+ API Key
+ Used to authenticate MCP requests. Each project has its own key.
{apiKey ? (
-
-
Save this key — it won't be shown again.
-
{apiKey}
+
+
+
+
+
Save this key — it won't be shown again.
+
+
copyText(apiKey, 'key')} className="text-xs font-medium text-warning hover:underline">
+ {copied === 'key' ? 'Copied!' : 'Copy'}
+
+
+
{apiKey}
) : (
-
API key is hidden. Rotate to generate a new one.
+
+
+
API key is hidden. Rotate to generate a new one.
+
)}
-
{ 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
-
-
-
Configuration for Claude Code / Cursor
+
setShowRotateConfirm(true)} className="btn-outline mt-3">
+
+
+
+ Rotate API Key
+
+
+
+ {/* Config snippet */}
+
+ Configuration for Claude Code / Cursor
+ Add this to your MCP client configuration.
-
{configSnippet}
-
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
+
{configSnippet}
+
copyText(configSnippet, 'config')} className="copy-btn absolute top-2.5 right-2.5">
+ {copied === 'config' ? 'Copied!' : 'Copy'}
+
-
-
-
Available Tools
-
+
+
+ {/* Available tools */}
+
+ Available MCP Tools
+ 5 tools for progressive drill-down, designed for minimal token usage.
+
{[
- { 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) => (
-
-
{t.name}
-
{t.desc}
+
))}
-
+
+
+
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"
+ />
);
}
diff --git a/packages/web/src/pages/tabs/ModuleManagement.tsx b/packages/web/src/pages/tabs/ModuleManagement.tsx
index 0884c27..c49c8f0 100644
--- a/packages/web/src/pages/tabs/ModuleManagement.tsx
+++ b/packages/web/src/pages/tabs/ModuleManagement.tsx
@@ -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
(null);
const queryClient = useQueryClient();
- const { data: modules } = useQuery({
+ const { data: modules, isLoading } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch(`/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 (
-
-
- setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" />
- 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
-
-
- {modules?.map((m) => (
-
-
- {m.name}
- ({m.source})
- {m._count.endpoints} endpoints
-
-
{ if (confirm(`Delete "${m.name}"?`)) deleteMutation.mutate(m.id); }}
- className="text-xs text-red-500 hover:underline">Delete
+
+ {/* Add module */}
+
+ Add Manual Module
+
+
setNewModuleName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && newModuleName && createMutation.mutate(newModuleName)}
+ className="input-base flex-1"
+ />
+
newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName} className="btn-primary shrink-0">
+
+ Add
+
+
+
+
+ {/* Module list */}
+
+
+
All Modules
+ {modules &&
{modules.length} total }
+
+
+ {isLoading ? (
+ {[1,2,3].map(i => )}
+ ) : modules?.length === 0 ? (
+
+
+
+ }
+ title="No modules yet"
+ description="Modules are automatically created when you import an API document. You can also add manual modules above."
+ />
+ ) : (
+
+ {modules?.map((m) => (
+
+
+ {m.name}
+ {m.source}
+
+
+
{m._count.endpoints} endpoints
+
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"
+ >
+
+
+
+
+
+
+ ))}
- ))}
-
+ )}
+
+
+
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"
+ />
);
}
diff --git a/packages/web/src/pages/tabs/ProjectSettings.tsx b/packages/web/src/pages/tabs/ProjectSettings.tsx
index ee421bd..0d086fb 100644
--- a/packages/web/src/pages/tabs/ProjectSettings.tsx
+++ b/packages/web/src/pages/tabs/ProjectSettings.tsx
@@ -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 (
-
-
-
Project Name
- setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" />
-
Description
-
-
updateMutation.mutate()} className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">Save Changes
-
-
-
Danger Zone
- { 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
-
+
+ {/* General */}
+
+ General
+ Update your project name and description.
+
+
+ Project Name
+ setName(e.target.value)} className="input-base" />
+
+
+ Description
+
+
updateMutation.mutate()} className="btn-primary">
+ {saveSuccess ? (
+ <> Saved>
+ ) : 'Save Changes'}
+
+
+
+
+ {/* Re-import */}
+
+ Re-import API Document
+
+ 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.
+
+ setShowReimport(true)} className="btn-outline">
+
+
+
+ Re-import Document
+
+
+
+ {/* Danger zone */}
+
+
+ Permanently delete this project and all its data. This action cannot be undone.
+ setShowDelete(true)} className="btn-danger">Delete Project
+
+
+
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 && (
+ setShowReimport(false)}
+ onSuccess={handleReimportSuccess}
+ />
+ )}
);
}