feat: optimize web ui
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<title>Agent Fox</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Projects />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Projects />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
26
packages/web/src/components/Badge.tsx
Normal file
26
packages/web/src/components/Badge.tsx
Normal file
@@ -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 (
|
||||
<span className={`method-badge method-${variant}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, string> = {
|
||||
default: 'bg-bg-tertiary text-text-secondary',
|
||||
accent: 'bg-accent-muted text-accent',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium ${styles[variant] || styles.default}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
42
packages/web/src/components/ConfirmDialog.tsx
Normal file
42
packages/web/src/components/ConfirmDialog.tsx
Normal file
@@ -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 (
|
||||
<Modal open={open} onClose={onCancel} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg ${iconColor} flex items-center justify-center shrink-0`}>
|
||||
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[15px] font-semibold text-text-primary">{title}</h3>
|
||||
<p className="mt-1.5 text-[13px] text-text-secondary leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2.5 pt-1">
|
||||
<button onClick={onCancel} className="btn-ghost">Cancel</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
19
packages/web/src/components/EmptyState.tsx
Normal file
19
packages/web/src/components/EmptyState.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-in">
|
||||
{icon && <div className="mb-4 text-text-muted">{icon}</div>}
|
||||
<h3 className="text-base font-medium text-text-primary">{title}</h3>
|
||||
{description && <p className="mt-1 text-sm text-text-muted max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
packages/web/src/components/Modal.tsx
Normal file
33
packages/web/src/components/Modal.tsx
Normal file
@@ -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<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onClick={(e) => { if (e.target === ref.current) onClose(); }}
|
||||
style={{ width: widths[size] }}
|
||||
className="rounded-xl border border-border-default bg-bg-elevated p-0 shadow-lg"
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
7
packages/web/src/components/Skeleton.tsx
Normal file
7
packages/web/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
type SkeletonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
|
||||
return <div className={`skeleton ${className}`} />;
|
||||
}
|
||||
45
packages/web/src/components/ThemeToggle.tsx
Normal file
45
packages/web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useTheme } from '../lib/theme';
|
||||
|
||||
const icons = {
|
||||
light: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
),
|
||||
dark: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
),
|
||||
system: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-bg-tertiary">
|
||||
{order.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => 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]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -9,6 +9,7 @@ type AuthContextType = {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (updates: Partial<User>) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@@ -48,8 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const logout = () => { clearTokens(); setUser(null); };
|
||||
|
||||
const updateUser = (updates: Partial<User>) => {
|
||||
setUser(prev => prev ? { ...prev, ...updates } : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
64
packages/web/src/lib/theme.tsx
Normal file
64
packages/web/src/lib/theme.tsx
Normal file
@@ -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<ThemeContextType | null>(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<Theme>(() => {
|
||||
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 (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
type ImportResult = {
|
||||
project: { id: string; name: string };
|
||||
@@ -13,20 +14,30 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
const [mode, setMode] = useState<'url' | 'file'>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileContent, setFileContent] = useState<string>('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const handleFile = (file: File) => {
|
||||
setFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setFileContent(reader.result as string);
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -49,48 +60,113 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = () => {
|
||||
if (result?.apiKey) {
|
||||
navigator.clipboard.writeText(result.apiKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
|
||||
{!result ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button onClick={() => setMode('url')} className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>From URL</button>
|
||||
<button onClick={() => setMode('file')} className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>Upload File</button>
|
||||
<Modal open onClose={onClose} size="md">
|
||||
{!result ? (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">Import OpenAPI Document</h2>
|
||||
<p className="section-desc">Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.</p>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary max-w-fit border border-border-muted">
|
||||
<button onClick={() => setMode('url')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>From URL</button>
|
||||
<button onClick={() => setMode('file')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>Upload File</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||||
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
|
||||
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
{fileName ? (
|
||||
<p className="text-[13px] text-text-primary font-medium">{fileName}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[13px] text-text-secondary">Drop your OpenAPI file here</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">JSON or YAML</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{mode === 'url' ? (
|
||||
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" />
|
||||
) : (
|
||||
<input type="file" accept=".json,.yaml,.yml" onChange={handleFileChange} className="w-full mb-4" />
|
||||
)}
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">Cancel</button>
|
||||
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? 'Importing...' : 'Import'}
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<button onClick={onClose} className="btn-ghost">Cancel</button>
|
||||
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
|
||||
{loading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Importing...</>
|
||||
) : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-muted flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">Import Successful</h2>
|
||||
<p className="text-[13px] text-text-muted">{result.project.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.modules}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">Modules</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.endpoints}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">Endpoints</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] font-medium text-warning">API Key — save it now</p>
|
||||
</div>
|
||||
<button onClick={copyKey} className="text-[11px] font-medium text-warning hover:underline">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p><strong>Project:</strong> {result.project.name}</p>
|
||||
<p><strong>Modules:</strong> {result.stats.modules}</p>
|
||||
<p><strong>Endpoints:</strong> {result.stats.endpoints}</p>
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="font-medium text-yellow-800 mb-1">API Key (save it now):</p>
|
||||
<code className="text-xs break-all">{result.apiKey}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Go to Project</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-xs break-all text-text-primary font-mono bg-bg-primary/30 rounded p-2 block">{result.apiKey}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="btn-primary">Go to Project</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,128 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-secondary">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
const isSettings = location.pathname === '/settings';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Agent Fox</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{user.name}</span>
|
||||
<button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button>
|
||||
<div className="min-h-screen bg-bg-secondary flex">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-[220px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:translate-x-0 lg:static lg:z-auto ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
{/* Brand */}
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-6"><Outlet /></main>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive && !isSettings
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-muted text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="px-2.5 pb-3 space-y-2.5">
|
||||
<div className="px-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="border-t border-border-default pt-2.5">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-text-primary truncate leading-tight">{user.name}</div>
|
||||
<div className="text-[11px] text-text-muted truncate leading-tight mt-0.5">{user.email}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-2.5 w-full px-2.5 py-[7px] rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mt-0.5"
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header */}
|
||||
<header className="lg:hidden h-14 border-b border-border-default bg-bg-sidebar px-4 flex items-center">
|
||||
<button onClick={() => setSidebarOpen(true)} className="p-2 -ml-2 text-text-secondary hover:text-text-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ml-3 font-semibold text-[15px] text-text-primary">Agent Fox</span>
|
||||
</header>
|
||||
<main className="flex-1 p-5 lg:p-8 overflow-auto">
|
||||
<div className="animate-fade-in">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,32 +6,76 @@ export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Sign In</button>
|
||||
</form>
|
||||
<p className="text-center text-sm mt-4">
|
||||
Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link>
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
|
||||
{/* Subtle grid background */}
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
{/* Radial fade */}
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||
}} />
|
||||
|
||||
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
|
||||
{/* Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
||||
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to Agent Fox</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">API documentation for LLMs</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card p-6 shadow-md">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="Enter your password" required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Signing in...</>
|
||||
) : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-accent hover:underline font-medium">Sign Up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,53 +6,104 @@ import DocPreview from './tabs/DocPreview';
|
||||
import ModuleManagement from './tabs/ModuleManagement';
|
||||
import McpIntegration from './tabs/McpIntegration';
|
||||
import ProjectSettings from './tabs/ProjectSettings';
|
||||
import Badge from '../components/Badge';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
|
||||
type ProjectData = {
|
||||
id: string; name: string; description: string | null; baseUrl: string | null;
|
||||
openApiVersion: string;
|
||||
modules: Array<{ id: string; name: string; description: string | null; _count: { endpoints: number } }>;
|
||||
_count: { endpoints: number };
|
||||
_count: { endpoints: number; modules: number };
|
||||
};
|
||||
|
||||
const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
const tabs = [
|
||||
{ key: 'docs', label: 'Documentation', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ key: 'modules', label: 'Modules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ key: 'mcp', label: 'MCP', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' },
|
||||
{ key: 'settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof tabs)[number]['key'];
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Documentation');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('docs');
|
||||
|
||||
const { data: project, isLoading } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => apiFetch<ProjectData>(`/projects/${id}`),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!project) return <div>Project not found</div>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-96" />
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<svg className="w-10 h-10 mx-auto text-text-muted mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<p className="text-text-muted text-sm">Project not found</p>
|
||||
<Link to="/" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4"><Link to="/" className="text-sm text-blue-600 hover:underline">← Back to projects</Link></div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-text-muted mb-5">
|
||||
<Link to="/" className="hover:text-text-primary transition-colors">Projects</Link>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-text-secondary font-medium">{project.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{project.name}</h2>
|
||||
{project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
|
||||
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{project.name}</h2>
|
||||
{project.description && <p className="text-[13px] text-text-muted mt-1 max-w-xl">{project.description}</p>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints</div>
|
||||
</div>
|
||||
<div className="border-b mb-6">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-4">
|
||||
<Badge>OpenAPI {project.openApiVersion}</Badge>
|
||||
<Badge>{project._count.endpoints} endpoints</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
|
||||
{activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
|
||||
{activeTab === 'MCP Integration' && <McpIntegration project={project} />}
|
||||
{activeTab === 'Settings' && <ProjectSettings project={project} />}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary mb-6 max-w-fit border border-border-muted">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[6px] rounded-md text-[13px] font-medium transition-all duration-150 ${
|
||||
activeTab === tab.key
|
||||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-[14px] h-[14px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d={tab.icon} /></svg>
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div key={activeTab} className="animate-fade-in">
|
||||
{activeTab === 'docs' && <DocPreview projectId={project.id} />}
|
||||
{activeTab === 'modules' && <ModuleManagement projectId={project.id} />}
|
||||
{activeTab === 'mcp' && <McpIntegration project={project} />}
|
||||
{activeTab === 'settings' && <ProjectSettings project={{ ...project, _count: project._count }} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ImportDialog from './ImportDialog';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Badge from '../components/Badge';
|
||||
|
||||
type ProjectSummary = {
|
||||
id: string; name: string; description: string | null; openApiVersion: string;
|
||||
@@ -11,6 +15,7 @@ type ProjectSummary = {
|
||||
|
||||
export default function Projects() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
@@ -20,36 +25,91 @@ export default function Projects() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setDeleteTarget(null); },
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading projects...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">Projects</h2>
|
||||
<button onClick={() => setShowImport(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Import API Doc</button>
|
||||
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Projects</h2>
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Import API Doc
|
||||
</button>
|
||||
</div>
|
||||
{projects?.length === 0 && <p className="text-gray-500 text-center py-12">No projects yet. Import an OpenAPI document to get started.</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects?.map((p) => (
|
||||
<div key={p.id} className="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow">
|
||||
<Link to={`/projects/${p.id}`}>
|
||||
<h3 className="font-medium text-lg">{p.name}</h3>
|
||||
{p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>OpenAPI {p.openApiVersion}</span>
|
||||
<span>{p._count.modules} modules</span>
|
||||
<span>{p._count.endpoints} endpoints</span>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="card p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
<Skeleton className="h-5 w-20 rounded-md" />
|
||||
<Skeleton className="h-5 w-20 rounded-md" />
|
||||
</div>
|
||||
</Link>
|
||||
<button onClick={() => { if (confirm('Delete this project?')) deleteMutation.mutate(p.id); }}
|
||||
className="mt-2 text-xs text-red-500 hover:underline">Delete</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projects?.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
}
|
||||
title="No projects yet"
|
||||
description="Import an OpenAPI document to get started with MCP-powered API documentation."
|
||||
action={
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Import Your First API
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
|
||||
{projects?.map((p) => (
|
||||
<div key={p.id} className="card card-hover group relative">
|
||||
<Link to={`/projects/${p.id}`} className="block p-5">
|
||||
<h3 className="text-[14px] font-medium text-text-primary group-hover:text-accent transition-colors">{p.name}</h3>
|
||||
{p.description && <p className="text-[13px] text-text-muted mt-1.5 line-clamp-2 leading-relaxed">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
<Badge>OpenAPI {p.openApiVersion}</Badge>
|
||||
<Badge>{p._count.modules} modules</Badge>
|
||||
<Badge>{p._count.endpoints} endpoints</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setDeleteTarget(p); }}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-md text-text-muted opacity-0 group-hover:opacity-100 hover:text-danger hover:bg-danger-muted transition-all"
|
||||
title="Delete project"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
title="Delete project"
|
||||
description={`Are you sure you want to delete "${deleteTarget?.name}"? This will permanently remove all modules, endpoints, and MCP configuration.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,33 +7,76 @@ export default function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
|
||||
<input type="password" placeholder="Password (min 8 chars)" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" minLength={8} required />
|
||||
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Create Account</button>
|
||||
</form>
|
||||
<p className="text-center text-sm mt-4">
|
||||
Already have an account? <Link to="/login" className="text-blue-600 hover:underline">Sign In</Link>
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||
}} />
|
||||
|
||||
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
||||
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Create your account</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">Get started with Agent Fox</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-md">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" placeholder="Your name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Creating account...</>
|
||||
) : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline font-medium">Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
183
packages/web/src/pages/ReimportDialog.tsx
Normal file
183
packages/web/src/pages/ReimportDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
type ReimportResult = {
|
||||
stats: { modules: number; endpoints: number };
|
||||
};
|
||||
|
||||
type ReimportDialogProps = {
|
||||
projectId: string;
|
||||
currentStats: { modules: number; endpoints: number };
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
type Step = 'confirm' | 'import' | 'success';
|
||||
|
||||
export default function ReimportDialog({ projectId, currentStats, onClose, onSuccess }: ReimportDialogProps) {
|
||||
const [step, setStep] = useState<Step>('confirm');
|
||||
const [mode, setMode] = useState<'url' | 'file'>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ReimportResult | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setFileContent(reader.result as string);
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const handleReimport = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
let body: Record<string, unknown>;
|
||||
if (mode === 'url') {
|
||||
body = { specUrl: url };
|
||||
} else {
|
||||
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
|
||||
}
|
||||
const data = await apiFetch<ReimportResult>(`/projects/${projectId}/reimport`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
});
|
||||
setResult(data);
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Re-import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} size="md">
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Re-import API Document</h2>
|
||||
<p className="text-sm text-text-muted mt-1">This action will replace all existing data.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-warning shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-warning mb-1">The following data will be permanently deleted:</p>
|
||||
<ul className="text-text-secondary space-y-1">
|
||||
<li>{currentStats.modules} module{currentStats.modules !== 1 ? 's' : ''}</li>
|
||||
<li>{currentStats.endpoints} endpoint{currentStats.endpoints !== 1 ? 's' : ''}</li>
|
||||
</ul>
|
||||
<p className="text-text-muted mt-2">New modules and endpoints will be created from the imported document. The API key will remain unchanged.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="btn-ghost">Cancel</button>
|
||||
<button onClick={() => setStep('import')} className="btn-primary">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'import' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Import New Document</h2>
|
||||
<p className="text-sm text-text-muted mt-1">Provide a Swagger 2.0 or OpenAPI 3.x document.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-bg-tertiary max-w-fit">
|
||||
<button onClick={() => setMode('url')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>From URL</button>
|
||||
<button onClick={() => setMode('file')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>Upload File</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||||
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
|
||||
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
{fileName ? (
|
||||
<p className="text-sm text-text-primary font-medium">{fileName}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-text-secondary">Drop your OpenAPI file here</p>
|
||||
<p className="text-xs text-text-muted mt-1">JSON or YAML</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="p-3 rounded-lg bg-danger-muted text-danger text-sm">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setStep('confirm')} className="btn-ghost">Back</button>
|
||||
<button onClick={handleReimport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
Importing...
|
||||
</>
|
||||
) : 'Re-import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && result && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-success-muted flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Re-import Successful</h2>
|
||||
<p className="text-sm text-text-muted">API documentation has been updated.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="card p-3 text-center">
|
||||
<div className="text-2xl font-semibold text-text-primary">{result.stats.modules}</div>
|
||||
<div className="text-xs text-text-muted">Modules</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center">
|
||||
<div className="text-2xl font-semibold text-text-primary">{result.stats.endpoints}</div>
|
||||
<div className="text-xs text-text-muted">Endpoints</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { onSuccess(); onClose(); }} className="btn-primary">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
147
packages/web/src/pages/Settings.tsx
Normal file
147
packages/web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export default function Settings() {
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const handleProfileSave = async () => {
|
||||
setProfileLoading(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const data = await apiFetch<{ id: string; email: string; name: string }>('/auth/profile', {
|
||||
method: 'PUT', body: JSON.stringify({ name }),
|
||||
});
|
||||
updateUser({ name: data.name });
|
||||
setProfileMsg({ type: 'success', text: 'Profile updated' });
|
||||
setTimeout(() => setProfileMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' });
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ type: 'error', text: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
setPasswordLoading(true);
|
||||
setPasswordMsg(null);
|
||||
try {
|
||||
await apiFetch('/auth/change-password', {
|
||||
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
setPasswordMsg({ type: 'success', text: 'Password changed successfully' });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => setPasswordMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' });
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = user?.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em] mb-8">Settings</h2>
|
||||
|
||||
{/* Profile */}
|
||||
<section className="mb-8">
|
||||
<p className="section-title">Profile</p>
|
||||
<p className="section-desc mb-5">Manage your personal information.</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-14 h-14 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-lg font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-medium text-text-primary">{user?.name}</div>
|
||||
<div className="text-[13px] text-text-muted">{user?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Display Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base max-w-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Email</label>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] text-text-muted max-w-sm">
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
{user?.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 max-w-sm ${profileMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{profileMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||
</svg>
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
|
||||
{profileLoading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Password */}
|
||||
<section className="border-t border-border-default pt-8">
|
||||
<p className="section-title">Change Password</p>
|
||||
<p className="section-desc mb-5">Update your password to keep your account secure.</p>
|
||||
|
||||
<div className="space-y-4 max-w-sm">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">New Password</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" />
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
|
||||
</svg>
|
||||
{passwordMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
|
||||
className="btn-primary"
|
||||
>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import Badge from '../../components/Badge';
|
||||
import Skeleton from '../../components/Skeleton';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
|
||||
type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } };
|
||||
type EndpointSummary = { id: string; method: string; path: string; summary: string | null; deprecated: boolean; module: { name: string } };
|
||||
type EndpointFull = EndpointSummary & { description: string | null; operationId: string | null; parameters: unknown; requestBody: unknown; responses: unknown };
|
||||
|
||||
const methodColors: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-800', POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-yellow-100 text-yellow-800', DELETE: 'bg-red-100 text-red-800',
|
||||
PATCH: 'bg-purple-100 text-purple-800',
|
||||
const methodVariant: Record<string, 'get' | 'post' | 'put' | 'delete' | 'patch'> = {
|
||||
GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete', PATCH: 'patch',
|
||||
};
|
||||
|
||||
export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
|
||||
const { data: modules } = useQuery({
|
||||
const { data: modules, isLoading: modulesLoading } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
const { data: endpoints, isLoading: endpointsLoading } = useQuery({
|
||||
queryKey: ['endpoints', projectId, selectedModule],
|
||||
queryFn: () => apiFetch<EndpointSummary[]>(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`),
|
||||
});
|
||||
@@ -32,46 +33,104 @@ export default function DocPreview({ projectId }: { projectId: string }) {
|
||||
enabled: !!expandedEndpoint,
|
||||
});
|
||||
|
||||
const totalEndpoints = modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="w-56 shrink-0">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Modules</h3>
|
||||
<button onClick={() => setSelectedModule(null)}
|
||||
className={`block w-full text-left px-3 py-2 rounded text-sm ${!selectedModule ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
|
||||
All ({modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0})
|
||||
</button>
|
||||
{modules?.map((m) => (
|
||||
<button key={m.id} onClick={() => setSelectedModule(m.id)}
|
||||
className={`block w-full text-left px-3 py-2 rounded text-sm ${selectedModule === m.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
|
||||
{m.name} ({m._count.endpoints})
|
||||
</button>
|
||||
))}
|
||||
<div className="flex gap-6 min-h-[400px]">
|
||||
{/* Module sidebar */}
|
||||
<div className="w-52 shrink-0">
|
||||
<div className="sticky top-0">
|
||||
<p className="section-label px-3 mb-3">Modules</p>
|
||||
{modulesLoading ? (
|
||||
<div className="space-y-1.5 px-1">{[1,2,3].map(i => <Skeleton key={i} className="h-9 w-full" />)}</div>
|
||||
) : modules?.length === 0 ? (
|
||||
<p className="text-sm text-text-muted px-3">No modules</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
<button onClick={() => setSelectedModule(null)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-[13px] transition-all duration-150 ${
|
||||
!selectedModule
|
||||
? 'bg-accent-muted text-accent font-medium'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}>
|
||||
All endpoints <span className="text-text-muted ml-1">{totalEndpoints}</span>
|
||||
</button>
|
||||
{modules?.map((m) => (
|
||||
<button key={m.id} onClick={() => setSelectedModule(m.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-[13px] transition-all duration-150 ${
|
||||
selectedModule === m.id
|
||||
? 'bg-accent-muted text-accent font-medium'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}>
|
||||
{m.name} <span className="text-text-muted ml-1">{m._count.endpoints}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{endpoints?.map((ep) => (
|
||||
<div key={ep.id} className="bg-white rounded-lg border">
|
||||
<button onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)} className="w-full text-left px-4 py-3 flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-mono font-medium ${methodColors[ep.method] || 'bg-gray-100'}`}>{ep.method}</span>
|
||||
<span className="font-mono text-sm">{ep.path}</span>
|
||||
{ep.summary && <span className="text-sm text-gray-500 ml-auto truncate max-w-xs">{ep.summary}</span>}
|
||||
{ep.deprecated && <span className="text-xs text-orange-500 ml-2">deprecated</span>}
|
||||
</button>
|
||||
{expandedEndpoint === ep.id && endpointDetail && (
|
||||
<div className="border-t px-4 py-3 text-sm space-y-3">
|
||||
{endpointDetail.description && <p className="text-gray-600">{endpointDetail.description}</p>}
|
||||
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
|
||||
<div><h4 className="font-medium mb-1">Parameters</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre></div>
|
||||
)}
|
||||
{endpointDetail.requestBody != null && (
|
||||
<div><h4 className="font-medium mb-1">Request Body</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre></div>
|
||||
)}
|
||||
{endpointDetail.responses != null && (
|
||||
<div><h4 className="font-medium mb-1">Responses</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.responses, null, 2)}</pre></div>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{endpointsLoading ? (
|
||||
<div className="space-y-2">{[1,2,3,4,5].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
|
||||
) : endpoints?.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.757 8.257" />
|
||||
</svg>
|
||||
}
|
||||
title="No endpoints"
|
||||
description={selectedModule ? "This module has no endpoints." : "No endpoints in this project yet. Import an API document to get started."}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5 stagger-children">
|
||||
{endpoints?.map((ep) => (
|
||||
<div key={ep.id} className="card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)}
|
||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-bg-tertiary/50 transition-colors"
|
||||
>
|
||||
<Badge variant={methodVariant[ep.method] || 'default'}>{ep.method}</Badge>
|
||||
<code className="text-[13px] text-text-primary font-mono truncate">{ep.path}</code>
|
||||
{ep.summary && <span className="text-[13px] text-text-muted ml-auto truncate max-w-[240px] hidden lg:block">{ep.summary}</span>}
|
||||
{ep.deprecated && <Badge variant="warning">deprecated</Badge>}
|
||||
<svg className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform duration-200 ${expandedEndpoint === ep.id ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{expandedEndpoint === ep.id && endpointDetail && (
|
||||
<div className="border-t border-border-muted px-5 py-5 space-y-5 animate-fade-in">
|
||||
{endpointDetail.description && <p className="text-[13px] text-text-secondary leading-relaxed">{endpointDetail.description}</p>}
|
||||
{endpointDetail.operationId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="section-label">Operation ID</span>
|
||||
<code className="text-xs font-mono text-text-secondary bg-bg-tertiary px-1.5 py-0.5 rounded">{endpointDetail.operationId}</code>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
|
||||
<div>
|
||||
<p className="section-label mb-2">Parameters</p>
|
||||
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{endpointDetail.requestBody != null && (
|
||||
<div>
|
||||
<p className="section-label mb-2">Request Body</p>
|
||||
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{endpointDetail.responses != null && (
|
||||
<div>
|
||||
<p className="section-label mb-2">Responses</p>
|
||||
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.responses, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
|
||||
type Project = { id: string; name: string };
|
||||
|
||||
export default function McpIntegration({ project }: { project: Project }) {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [showRotateConfirm, setShowRotateConfirm] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const mcpHost = window.location.hostname;
|
||||
const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`;
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
|
||||
onSuccess: (data) => setApiKey(data.apiKey),
|
||||
onSuccess: (data) => { setApiKey(data.apiKey); setShowRotateConfirm(false); },
|
||||
});
|
||||
|
||||
const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
|
||||
const configSnippet = JSON.stringify({
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
@@ -26,52 +28,105 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
},
|
||||
}, null, 2);
|
||||
|
||||
const copyText = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">MCP Service URL</h3>
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* MCP URL */}
|
||||
<section>
|
||||
<p className="section-title">MCP Service URL</p>
|
||||
<p className="section-desc mb-3">Connect your LLM client to this endpoint.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono">{mcpUrl}</code>
|
||||
<button onClick={() => navigator.clipboard.writeText(mcpUrl)} className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300">Copy</button>
|
||||
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-primary truncate">{mcpUrl}</code>
|
||||
<button onClick={() => copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
|
||||
{copied === 'url' ? (
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Copied</>
|
||||
) : (
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> Copy</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">API Key</h3>
|
||||
</section>
|
||||
|
||||
{/* API Key */}
|
||||
<section>
|
||||
<p className="section-title">API Key</p>
|
||||
<p className="section-desc mb-3">Used to authenticate MCP requests. Each project has its own key.</p>
|
||||
{apiKey ? (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-xs text-yellow-700 mb-1">Save this key — it won't be shown again.</p>
|
||||
<code className="text-sm break-all">{apiKey}</code>
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-xs font-medium text-warning">Save this key — it won't be shown again.</p>
|
||||
</div>
|
||||
<button onClick={() => copyText(apiKey, 'key')} className="text-xs font-medium text-warning hover:underline">
|
||||
{copied === 'key' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2">{apiKey}</code>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
|
||||
<div className="flex items-center gap-3 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] text-text-muted">API key is hidden. Rotate to generate a new one.</p>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => { if (confirm('This will invalidate the current API key. Continue?')) rotateMutation.mutate(); }}
|
||||
className="mt-2 px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200">Rotate API Key</button>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Configuration for Claude Code / Cursor</h3>
|
||||
<button onClick={() => setShowRotateConfirm(true)} className="btn-outline mt-3">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rotate API Key
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Config snippet */}
|
||||
<section>
|
||||
<p className="section-title">Configuration for Claude Code / Cursor</p>
|
||||
<p className="section-desc mb-3">Add this to your MCP client configuration.</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-green-400 p-4 rounded text-sm overflow-auto">{configSnippet}</pre>
|
||||
<button onClick={() => navigator.clipboard.writeText(configSnippet)} className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600">Copy</button>
|
||||
<pre className="code-block text-xs">{configSnippet}</pre>
|
||||
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn absolute top-2.5 right-2.5">
|
||||
{copied === 'config' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Available Tools</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
</section>
|
||||
|
||||
{/* Available tools */}
|
||||
<section>
|
||||
<p className="section-title">Available MCP Tools</p>
|
||||
<p className="section-desc mb-3">5 tools for progressive drill-down, designed for minimal token usage.</p>
|
||||
<div className="space-y-1.5 stagger-children">
|
||||
{[
|
||||
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.' },
|
||||
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.' },
|
||||
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.' },
|
||||
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.' },
|
||||
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.' },
|
||||
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.', num: '1' },
|
||||
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.', num: '2' },
|
||||
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.', num: '3' },
|
||||
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.', num: '4' },
|
||||
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.', num: '5' },
|
||||
].map((t) => (
|
||||
<div key={t.name} className="p-3 bg-gray-50 rounded">
|
||||
<code className="font-medium">{t.name}</code>
|
||||
<p className="text-gray-500 mt-1">{t.desc}</p>
|
||||
<div key={t.name} className="card px-4 py-3 flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full bg-accent-muted text-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">{t.num}</span>
|
||||
<div className="min-w-0">
|
||||
<code className="text-[13px] font-mono font-medium text-accent">{t.name}</code>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRotateConfirm}
|
||||
onCancel={() => setShowRotateConfirm(false)}
|
||||
onConfirm={() => rotateMutation.mutate()}
|
||||
title="Rotate API Key"
|
||||
description="This will invalidate the current API key immediately. Any MCP clients using the old key will stop working."
|
||||
confirmText="Rotate Key"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import Badge from '../../components/Badge';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import Skeleton from '../../components/Skeleton';
|
||||
|
||||
type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } };
|
||||
|
||||
export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
const [newModuleName, setNewModuleName] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Module | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: modules } = useQuery({
|
||||
const { data: modules, isLoading } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
|
||||
});
|
||||
@@ -20,29 +25,84 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setDeleteTarget(null); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<input type="text" placeholder="New module name" value={newModuleName} onChange={(e) => setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" />
|
||||
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50">Add Module</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{modules?.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between p-3 bg-white rounded-lg border">
|
||||
<div>
|
||||
<span className="font-medium">{m.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">({m.source})</span>
|
||||
<span className="text-xs text-gray-400 ml-2">{m._count.endpoints} endpoints</span>
|
||||
</div>
|
||||
<button onClick={() => { if (confirm(`Delete "${m.name}"?`)) deleteMutation.mutate(m.id); }}
|
||||
className="text-xs text-red-500 hover:underline">Delete</button>
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Add module */}
|
||||
<section>
|
||||
<p className="section-label mb-3">Add Manual Module</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Module name"
|
||||
value={newModuleName}
|
||||
onChange={(e) => setNewModuleName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newModuleName && createMutation.mutate(newModuleName)}
|
||||
className="input-base flex-1"
|
||||
/>
|
||||
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName} className="btn-primary shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 4v16m8-8H4" /></svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Module list */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="section-label">All Modules</p>
|
||||
{modules && <span className="text-xs text-text-muted">{modules.length} total</span>}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">{[1,2,3].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
|
||||
) : modules?.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25z" />
|
||||
</svg>
|
||||
}
|
||||
title="No modules yet"
|
||||
description="Modules are automatically created when you import an API document. You can also add manual modules above."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5 stagger-children">
|
||||
{modules?.map((m) => (
|
||||
<div key={m.id} className="card flex items-center justify-between px-4 py-3 group">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-[13px] font-medium text-text-primary truncate">{m.name}</span>
|
||||
<Badge>{m.source}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs text-text-muted">{m._count.endpoints} endpoints</span>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(m)}
|
||||
className="p-1.5 rounded-md text-text-muted opacity-0 group-hover:opacity-100 hover:text-danger hover:bg-danger-muted transition-all"
|
||||
title="Delete module"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
title="Delete module"
|
||||
description={`Delete "${deleteTarget?.name}"? This will also remove its ${deleteTarget?._count.endpoints ?? 0} endpoints.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import ReimportDialog from '../ReimportDialog';
|
||||
|
||||
type Project = { id: string; name: string; description: string | null };
|
||||
type Project = { id: string; name: string; description: string | null; _count: { endpoints: number; modules: number } };
|
||||
|
||||
export default function ProjectSettings({ project }: { project: Project }) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description || '');
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showReimport, setShowReimport] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description: description || undefined }) }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project', project.id] });
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -21,20 +30,78 @@ export default function ProjectSettings({ project }: { project: Project }) {
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
|
||||
});
|
||||
|
||||
const handleReimportSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project', project.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['modules', project.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints', project.id] });
|
||||
setShowReimport(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div><label className="block text-sm font-medium mb-1">Project Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" /></div>
|
||||
<div><label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded-md" /></div>
|
||||
<button onClick={() => updateMutation.mutate()} className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">Save Changes</button>
|
||||
</div>
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-red-600 font-medium mb-2">Danger Zone</h3>
|
||||
<button onClick={() => { if (confirm('Permanently delete this project?')) deleteMutation.mutate(); }}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700">Delete Project</button>
|
||||
</div>
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* General */}
|
||||
<section>
|
||||
<p className="section-title">General</p>
|
||||
<p className="section-desc mb-4">Update your project name and description.</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Project Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Description</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="input-base resize-none" />
|
||||
</div>
|
||||
<button onClick={() => updateMutation.mutate()} className="btn-primary">
|
||||
{saveSuccess ? (
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Saved</>
|
||||
) : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Re-import */}
|
||||
<section className="border-t border-border-default pt-8">
|
||||
<p className="section-title">Re-import API Document</p>
|
||||
<p className="section-desc mb-4">
|
||||
Replace the current API documentation with a new OpenAPI document. This will clear all existing modules ({project._count.modules}) and endpoints ({project._count.endpoints}), then recreate them from the new document.
|
||||
</p>
|
||||
<button onClick={() => setShowReimport(true)} className="btn-outline">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Re-import Document
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="border border-danger/15 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="section-title" style={{ color: 'var(--danger)' }}>Danger Zone</p>
|
||||
</div>
|
||||
<p className="text-[13px] text-text-muted">Permanently delete this project and all its data. This action cannot be undone.</p>
|
||||
<button onClick={() => setShowDelete(true)} className="btn-danger">Delete Project</button>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
title="Delete project"
|
||||
description={`Permanently delete "${project.name}"? All modules, endpoints, and MCP configuration will be removed.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{showReimport && (
|
||||
<ReimportDialog
|
||||
projectId={project.id}
|
||||
currentStats={project._count}
|
||||
onClose={() => setShowReimport(false)}
|
||||
onSuccess={handleReimportSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user