feat: optimize web ui

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

View File

@@ -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>

View File

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
type SkeletonProps = {
className?: string;
};
export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) {
return <div className={`skeleton ${className}`} />;
}

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

View File

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

View File

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

View 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;
}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
import Modal from '../components/Modal';
type ImportResult = {
project: { id: string; name: string };
@@ -13,20 +14,30 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState<string>('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ImportResult | null>(null);
const [copied, setCopied] = useState(false);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const handleFile = (file: File) => {
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleImport = async () => {
setLoading(true);
setError('');
@@ -49,48 +60,113 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
}
};
const copyKey = () => {
if (result?.apiKey) {
navigator.clipboard.writeText(result.apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
{!result ? (
<>
<h2 className="text-lg font-semibold mb-4">Import OpenAPI Document</h2>
<div className="flex gap-2 mb-4">
<button onClick={() => setMode('url')} className={`px-3 py-1 rounded text-sm ${mode === 'url' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>From URL</button>
<button onClick={() => setMode('file')} className={`px-3 py-1 rounded text-sm ${mode === 'file' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'}`}>Upload File</button>
<Modal open onClose={onClose} size="md">
{!result ? (
<div className="space-y-5">
<div>
<h2 className="text-[15px] font-semibold text-text-primary">Import OpenAPI Document</h2>
<p className="section-desc">Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.</p>
</div>
{/* Mode toggle */}
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary max-w-fit border border-border-muted">
<button onClick={() => setMode('url')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>From URL</button>
<button onClick={() => setMode('file')} className={`px-4 py-[6px] rounded-md text-[13px] font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>Upload File</button>
</div>
{mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
}`}
>
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
{fileName ? (
<p className="text-[13px] text-text-primary font-medium">{fileName}</p>
) : (
<>
<p className="text-[13px] text-text-secondary">Drop your OpenAPI file here</p>
<p className="text-[11px] text-text-muted mt-1">JSON or YAML</p>
</>
)}
</div>
{mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="w-full px-3 py-2 border rounded-md mb-4" />
) : (
<input type="file" accept=".json,.yaml,.yml" onChange={handleFileChange} className="w-full mb-4" />
)}
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md">Cancel</button>
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Importing...' : 'Import'}
)}
{error && (
<div className="p-3 rounded-lg bg-danger-muted flex items-center gap-2">
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<div className="flex justify-end gap-2.5">
<button onClick={onClose} className="btn-ghost">Cancel</button>
<button onClick={handleImport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
{loading ? (
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Importing...</>
) : 'Import'}
</button>
</div>
</div>
) : (
<div className="space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-muted flex items-center justify-center">
<svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
</div>
<div>
<h2 className="text-[15px] font-semibold text-text-primary">Import Successful</h2>
<p className="text-[13px] text-text-muted">{result.project.name}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="card p-4 text-center">
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.modules}</div>
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">Modules</div>
</div>
<div className="card p-4 text-center">
<div className="text-2xl font-bold text-text-primary tabular-nums">{result.stats.endpoints}</div>
<div className="text-[11px] text-text-muted mt-0.5 uppercase tracking-wide">Endpoints</div>
</div>
</div>
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
<p className="text-[13px] font-medium text-warning">API Key save it now</p>
</div>
<button onClick={copyKey} className="text-[11px] font-medium text-warning hover:underline">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</>
) : (
<>
<h2 className="text-lg font-semibold mb-4 text-green-600">Import Successful!</h2>
<div className="space-y-3 text-sm">
<p><strong>Project:</strong> {result.project.name}</p>
<p><strong>Modules:</strong> {result.stats.modules}</p>
<p><strong>Endpoints:</strong> {result.stats.endpoints}</p>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="font-medium text-yellow-800 mb-1">API Key (save it now):</p>
<code className="text-xs break-all">{result.apiKey}</code>
</div>
</div>
<div className="flex justify-end mt-4">
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Go to Project</button>
</div>
</>
)}
</div>
</div>
<code className="text-xs break-all text-text-primary font-mono bg-bg-primary/30 rounded p-2 block">{result.apiKey}</code>
</div>
<div className="flex justify-end">
<button onClick={() => navigate(`/projects/${result.project.id}`)} className="btn-primary">Go to Project</button>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -1,22 +1,128 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useState } from 'react';
import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../lib/auth';
import ThemeToggle from '../components/ThemeToggle';
export default function Layout() {
const { user, loading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg-secondary">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
const isSettings = location.pathname === '/settings';
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Agent Fox</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.name}</span>
<button onClick={logout} className="text-sm text-red-600 hover:underline">Sign Out</button>
<div className="min-h-screen bg-bg-secondary flex">
{/* Mobile overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 w-[220px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:translate-x-0 lg:static lg:z-auto ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
{/* Brand */}
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
</div>
</header>
<main className="p-6"><Outlet /></main>
{/* Navigation */}
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
<NavLink
to="/"
end
className={({ isActive }) =>
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isActive && !isSettings
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Projects
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) =>
`flex items-center gap-2.5 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isActive
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<circle cx="12" cy="12" r="3" />
</svg>
Settings
</NavLink>
</nav>
{/* Bottom section */}
<div className="px-2.5 pb-3 space-y-2.5">
<div className="px-1">
<ThemeToggle />
</div>
<div className="border-t border-border-default pt-2.5">
<Link
to="/settings"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-bg-tertiary transition-colors"
>
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
{initials}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-text-primary truncate leading-tight">{user.name}</div>
<div className="text-[11px] text-text-muted truncate leading-tight mt-0.5">{user.email}</div>
</div>
</Link>
<button
onClick={logout}
className="flex items-center gap-2.5 w-full px-2.5 py-[7px] rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mt-0.5"
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign Out
</button>
</div>
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile header */}
<header className="lg:hidden h-14 border-b border-border-default bg-bg-sidebar px-4 flex items-center">
<button onClick={() => setSidebarOpen(true)} className="p-2 -ml-2 text-text-secondary hover:text-text-primary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="ml-3 font-semibold text-[15px] text-text-primary">Agent Fox</span>
</header>
<main className="flex-1 p-5 lg:p-8 overflow-auto">
<div className="animate-fade-in">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View File

@@ -6,32 +6,76 @@ export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold text-center mb-6">Sign In to Agent Fox</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Sign In</button>
</form>
<p className="text-center text-sm mt-4">
Don't have an account? <Link to="/register" className="text-blue-600 hover:underline">Sign Up</Link>
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
{/* Subtle grid background */}
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}} />
{/* Radial fade */}
<div className="absolute inset-0" style={{
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
}} />
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
{/* Brand */}
<div className="text-center mb-8">
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to Agent Fox</h1>
<p className="text-[13px] text-text-muted mt-1">API documentation for LLMs</p>
</div>
{/* Card */}
<div className="card p-6 shadow-md">
{error && (
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="Enter your password" required />
</div>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? (
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Signing in...</>
) : 'Sign In'}
</button>
</form>
</div>
<p className="text-center text-[13px] text-text-muted mt-6">
Don't have an account?{' '}
<Link to="/register" className="text-accent hover:underline font-medium">Sign Up</Link>
</p>
</div>
</div>

View File

@@ -6,53 +6,104 @@ import DocPreview from './tabs/DocPreview';
import ModuleManagement from './tabs/ModuleManagement';
import McpIntegration from './tabs/McpIntegration';
import ProjectSettings from './tabs/ProjectSettings';
import Badge from '../components/Badge';
import Skeleton from '../components/Skeleton';
type ProjectData = {
id: string; name: string; description: string | null; baseUrl: string | null;
openApiVersion: string;
modules: Array<{ id: string; name: string; description: string | null; _count: { endpoints: number } }>;
_count: { endpoints: number };
_count: { endpoints: number; modules: number };
};
const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
type Tab = (typeof tabs)[number];
const tabs = [
{ key: 'docs', label: 'Documentation', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ key: 'modules', label: 'Modules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ key: 'mcp', label: 'MCP', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' },
{ key: 'settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
] as const;
type TabKey = (typeof tabs)[number]['key'];
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<Tab>('Documentation');
const [activeTab, setActiveTab] = useState<TabKey>('docs');
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],
queryFn: () => apiFetch<ProjectData>(`/projects/${id}`),
});
if (isLoading) return <div>Loading...</div>;
if (!project) return <div>Project not found</div>;
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-4 w-28" />
<div className="space-y-2">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-10 w-96" />
<Skeleton className="h-[300px] w-full" />
</div>
);
}
if (!project) {
return (
<div className="text-center py-20">
<svg className="w-10 h-10 mx-auto text-text-muted mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<p className="text-text-muted text-sm">Project not found</p>
<Link to="/" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
</div>
);
}
return (
<div>
<div className="mb-4"><Link to="/" className="text-sm text-blue-600 hover:underline">&larr; Back to projects</Link></div>
<div className="flex items-center justify-between mb-6">
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-[13px] text-text-muted mb-5">
<Link to="/" className="hover:text-text-primary transition-colors">Projects</Link>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 5l7 7-7 7" /></svg>
<span className="text-text-secondary font-medium">{project.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{project.name}</h2>
{project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{project.name}</h2>
{project.description && <p className="text-[13px] text-text-muted mt-1 max-w-xl">{project.description}</p>}
</div>
<div className="text-sm text-gray-400">OpenAPI {project.openApiVersion} &middot; {project._count.endpoints} endpoints</div>
</div>
<div className="border-b mb-6">
<div className="flex gap-6">
{tabs.map((tab) => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{tab}
</button>
))}
<div className="flex items-center gap-1.5 shrink-0 ml-4">
<Badge>OpenAPI {project.openApiVersion}</Badge>
<Badge>{project._count.endpoints} endpoints</Badge>
</div>
</div>
{activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
{activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
{activeTab === 'MCP Integration' && <McpIntegration project={project} />}
{activeTab === 'Settings' && <ProjectSettings project={project} />}
{/* Tabs */}
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary mb-6 max-w-fit border border-border-muted">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-[6px] rounded-md text-[13px] font-medium transition-all duration-150 ${
activeTab === tab.key
? 'bg-bg-elevated text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
>
<svg className="w-[14px] h-[14px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d={tab.icon} /></svg>
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div>
{/* Content */}
<div key={activeTab} className="animate-fade-in">
{activeTab === 'docs' && <DocPreview projectId={project.id} />}
{activeTab === 'modules' && <ModuleManagement projectId={project.id} />}
{activeTab === 'mcp' && <McpIntegration project={project} />}
{activeTab === 'settings' && <ProjectSettings project={{ ...project, _count: project._count }} />}
</div>
</div>
);
}

View File

@@ -3,6 +3,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import ImportDialog from './ImportDialog';
import ConfirmDialog from '../components/ConfirmDialog';
import EmptyState from '../components/EmptyState';
import Skeleton from '../components/Skeleton';
import Badge from '../components/Badge';
type ProjectSummary = {
id: string; name: string; description: string | null; openApiVersion: string;
@@ -11,6 +15,7 @@ type ProjectSummary = {
export default function Projects() {
const [showImport, setShowImport] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
const queryClient = useQueryClient();
const { data: projects, isLoading } = useQuery({
@@ -20,36 +25,91 @@ export default function Projects() {
const deleteMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setDeleteTarget(null); },
});
if (isLoading) return <div>Loading projects...</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Projects</h2>
<button onClick={() => setShowImport(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Import API Doc</button>
<h2 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Projects</h2>
<button onClick={() => setShowImport(true)} className="btn-primary">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 4v16m8-8H4" />
</svg>
Import API Doc
</button>
</div>
{projects?.length === 0 && <p className="text-gray-500 text-center py-12">No projects yet. Import an OpenAPI document to get started.</p>}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects?.map((p) => (
<div key={p.id} className="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow">
<Link to={`/projects/${p.id}`}>
<h3 className="font-medium text-lg">{p.name}</h3>
{p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
<div className="mt-3 flex items-center gap-4 text-xs text-gray-400">
<span>OpenAPI {p.openApiVersion}</span>
<span>{p._count.modules} modules</span>
<span>{p._count.endpoints} endpoints</span>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="card p-5 space-y-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-full" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</div>
</Link>
<button onClick={() => { if (confirm('Delete this project?')) deleteMutation.mutate(p.id); }}
className="mt-2 text-xs text-red-500 hover:underline">Delete</button>
</div>
))}
</div>
</div>
))}
</div>
) : projects?.length === 0 ? (
<EmptyState
icon={
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
}
title="No projects yet"
description="Import an OpenAPI document to get started with MCP-powered API documentation."
action={
<button onClick={() => setShowImport(true)} className="btn-primary">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 4v16m8-8H4" />
</svg>
Import Your First API
</button>
}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{projects?.map((p) => (
<div key={p.id} className="card card-hover group relative">
<Link to={`/projects/${p.id}`} className="block p-5">
<h3 className="text-[14px] font-medium text-text-primary group-hover:text-accent transition-colors">{p.name}</h3>
{p.description && <p className="text-[13px] text-text-muted mt-1.5 line-clamp-2 leading-relaxed">{p.description}</p>}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
<Badge>OpenAPI {p.openApiVersion}</Badge>
<Badge>{p._count.modules} modules</Badge>
<Badge>{p._count.endpoints} endpoints</Badge>
</div>
</Link>
<button
onClick={(e) => { e.preventDefault(); setDeleteTarget(p); }}
className="absolute top-3 right-3 p-1.5 rounded-md text-text-muted opacity-0 group-hover:opacity-100 hover:text-danger hover:bg-danger-muted transition-all"
title="Delete project"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
title="Delete project"
description={`Are you sure you want to delete "${deleteTarget?.name}"? This will permanently remove all modules, endpoints, and MCP configuration.`}
confirmText="Delete"
variant="danger"
/>
</div>
);
}

View File

@@ -7,33 +7,76 @@ export default function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await register(email, password, name);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow">
<h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full px-3 py-2 border rounded-md" required />
<input type="password" placeholder="Password (min 8 chars)" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full px-3 py-2 border rounded-md" minLength={8} required />
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Create Account</button>
</form>
<p className="text-center text-sm mt-4">
Already have an account? <Link to="/login" className="text-blue-600 hover:underline">Sign In</Link>
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}} />
<div className="absolute inset-0" style={{
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
}} />
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
<div className="text-center mb-8">
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Create your account</h1>
<p className="text-[13px] text-text-muted mt-1">Get started with Agent Fox</p>
</div>
<div className="card p-6 shadow-md">
{error && (
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
<svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
<span className="text-danger text-[13px]">{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" placeholder="Your name" required />
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
</div>
<div>
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} required />
</div>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? (
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Creating account...</>
) : 'Create Account'}
</button>
</form>
</div>
<p className="text-center text-[13px] text-text-muted mt-6">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline font-medium">Sign In</Link>
</p>
</div>
</div>

View File

@@ -0,0 +1,183 @@
import { useState, useRef } from 'react';
import { apiFetch } from '../lib/api';
import Modal from '../components/Modal';
type ReimportResult = {
stats: { modules: number; endpoints: number };
};
type ReimportDialogProps = {
projectId: string;
currentStats: { modules: number; endpoints: number };
onClose: () => void;
onSuccess: () => void;
};
type Step = 'confirm' | 'import' | 'success';
export default function ReimportDialog({ projectId, currentStats, onClose, onSuccess }: ReimportDialogProps) {
const [step, setStep] = useState<Step>('confirm');
const [mode, setMode] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [fileContent, setFileContent] = useState('');
const [fileName, setFileName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<ReimportResult | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = (file: File) => {
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileContent(reader.result as string);
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleReimport = async () => {
setLoading(true);
setError('');
try {
let body: Record<string, unknown>;
if (mode === 'url') {
body = { specUrl: url };
} else {
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
}
const data = await apiFetch<ReimportResult>(`/projects/${projectId}/reimport`, {
method: 'POST', body: JSON.stringify(body),
});
setResult(data);
setStep('success');
} catch (err) {
setError(err instanceof Error ? err.message : 'Re-import failed');
} finally {
setLoading(false);
}
};
return (
<Modal open onClose={onClose} size="md">
{step === 'confirm' && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-text-primary">Re-import API Document</h2>
<p className="text-sm text-text-muted mt-1">This action will replace all existing data.</p>
</div>
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-warning shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div className="text-sm">
<p className="font-medium text-warning mb-1">The following data will be permanently deleted:</p>
<ul className="text-text-secondary space-y-1">
<li>{currentStats.modules} module{currentStats.modules !== 1 ? 's' : ''}</li>
<li>{currentStats.endpoints} endpoint{currentStats.endpoints !== 1 ? 's' : ''}</li>
</ul>
<p className="text-text-muted mt-2">New modules and endpoints will be created from the imported document. The API key will remain unchanged.</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn-ghost">Cancel</button>
<button onClick={() => setStep('import')} className="btn-primary">Continue</button>
</div>
</div>
)}
{step === 'import' && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-text-primary">Import New Document</h2>
<p className="text-sm text-text-muted mt-1">Provide a Swagger 2.0 or OpenAPI 3.x document.</p>
</div>
<div className="flex gap-1 p-1 rounded-lg bg-bg-tertiary max-w-fit">
<button onClick={() => setMode('url')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'url' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>From URL</button>
<button onClick={() => setMode('file')} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${mode === 'file' ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>Upload File</button>
</div>
{mode === 'url' ? (
<input type="url" placeholder="https://api.example.com/openapi.json" value={url} onChange={(e) => setUrl(e.target.value)} className="input-base" />
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong'
}`}
>
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" />
<svg className="w-8 h-8 mx-auto text-text-muted mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
{fileName ? (
<p className="text-sm text-text-primary font-medium">{fileName}</p>
) : (
<>
<p className="text-sm text-text-secondary">Drop your OpenAPI file here</p>
<p className="text-xs text-text-muted mt-1">JSON or YAML</p>
</>
)}
</div>
)}
{error && <div className="p-3 rounded-lg bg-danger-muted text-danger text-sm">{error}</div>}
<div className="flex justify-end gap-3">
<button onClick={() => setStep('confirm')} className="btn-ghost">Back</button>
<button onClick={handleReimport} disabled={loading || (mode === 'url' ? !url : !fileContent)} className="btn-primary">
{loading ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
Importing...
</>
) : 'Re-import'}
</button>
</div>
</div>
)}
{step === 'success' && result && (
<div className="space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-muted flex items-center justify-center">
<svg className="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary">Re-import Successful</h2>
<p className="text-sm text-text-muted">API documentation has been updated.</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="card p-3 text-center">
<div className="text-2xl font-semibold text-text-primary">{result.stats.modules}</div>
<div className="text-xs text-text-muted">Modules</div>
</div>
<div className="card p-3 text-center">
<div className="text-2xl font-semibold text-text-primary">{result.stats.endpoints}</div>
<div className="text-xs text-text-muted">Endpoints</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => { onSuccess(); onClose(); }} className="btn-primary">Done</button>
</div>
</div>
)}
</Modal>
);
}

View File

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

View File

@@ -1,27 +1,28 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import Badge from '../../components/Badge';
import Skeleton from '../../components/Skeleton';
import EmptyState from '../../components/EmptyState';
type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } };
type EndpointSummary = { id: string; method: string; path: string; summary: string | null; deprecated: boolean; module: { name: string } };
type EndpointFull = EndpointSummary & { description: string | null; operationId: string | null; parameters: unknown; requestBody: unknown; responses: unknown };
const methodColors: Record<string, string> = {
GET: 'bg-green-100 text-green-800', POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-yellow-100 text-yellow-800', DELETE: 'bg-red-100 text-red-800',
PATCH: 'bg-purple-100 text-purple-800',
const methodVariant: Record<string, 'get' | 'post' | 'put' | 'delete' | 'patch'> = {
GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete', PATCH: 'patch',
};
export default function DocPreview({ projectId }: { projectId: string }) {
const [selectedModule, setSelectedModule] = useState<string | null>(null);
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const { data: modules } = useQuery({
const { data: modules, isLoading: modulesLoading } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
const { data: endpoints } = useQuery({
const { data: endpoints, isLoading: endpointsLoading } = useQuery({
queryKey: ['endpoints', projectId, selectedModule],
queryFn: () => apiFetch<EndpointSummary[]>(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`),
});
@@ -32,46 +33,104 @@ export default function DocPreview({ projectId }: { projectId: string }) {
enabled: !!expandedEndpoint,
});
const totalEndpoints = modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0;
return (
<div className="flex gap-6">
<div className="w-56 shrink-0">
<h3 className="text-sm font-medium text-gray-700 mb-2">Modules</h3>
<button onClick={() => setSelectedModule(null)}
className={`block w-full text-left px-3 py-2 rounded text-sm ${!selectedModule ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
All ({modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0})
</button>
{modules?.map((m) => (
<button key={m.id} onClick={() => setSelectedModule(m.id)}
className={`block w-full text-left px-3 py-2 rounded text-sm ${selectedModule === m.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}`}>
{m.name} ({m._count.endpoints})
</button>
))}
<div className="flex gap-6 min-h-[400px]">
{/* Module sidebar */}
<div className="w-52 shrink-0">
<div className="sticky top-0">
<p className="section-label px-3 mb-3">Modules</p>
{modulesLoading ? (
<div className="space-y-1.5 px-1">{[1,2,3].map(i => <Skeleton key={i} className="h-9 w-full" />)}</div>
) : modules?.length === 0 ? (
<p className="text-sm text-text-muted px-3">No modules</p>
) : (
<div className="space-y-0.5">
<button onClick={() => setSelectedModule(null)}
className={`w-full text-left px-3 py-2 rounded-lg text-[13px] transition-all duration-150 ${
!selectedModule
? 'bg-accent-muted text-accent font-medium'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}>
All endpoints <span className="text-text-muted ml-1">{totalEndpoints}</span>
</button>
{modules?.map((m) => (
<button key={m.id} onClick={() => setSelectedModule(m.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-[13px] transition-all duration-150 ${
selectedModule === m.id
? 'bg-accent-muted text-accent font-medium'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}>
{m.name} <span className="text-text-muted ml-1">{m._count.endpoints}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex-1 space-y-2">
{endpoints?.map((ep) => (
<div key={ep.id} className="bg-white rounded-lg border">
<button onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)} className="w-full text-left px-4 py-3 flex items-center gap-3">
<span className={`px-2 py-0.5 rounded text-xs font-mono font-medium ${methodColors[ep.method] || 'bg-gray-100'}`}>{ep.method}</span>
<span className="font-mono text-sm">{ep.path}</span>
{ep.summary && <span className="text-sm text-gray-500 ml-auto truncate max-w-xs">{ep.summary}</span>}
{ep.deprecated && <span className="text-xs text-orange-500 ml-2">deprecated</span>}
</button>
{expandedEndpoint === ep.id && endpointDetail && (
<div className="border-t px-4 py-3 text-sm space-y-3">
{endpointDetail.description && <p className="text-gray-600">{endpointDetail.description}</p>}
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
<div><h4 className="font-medium mb-1">Parameters</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre></div>
)}
{endpointDetail.requestBody != null && (
<div><h4 className="font-medium mb-1">Request Body</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre></div>
)}
{endpointDetail.responses != null && (
<div><h4 className="font-medium mb-1">Responses</h4><pre className="bg-gray-50 p-2 rounded text-xs overflow-auto">{JSON.stringify(endpointDetail.responses, null, 2)}</pre></div>
{/* Endpoints */}
<div className="flex-1 min-w-0">
{endpointsLoading ? (
<div className="space-y-2">{[1,2,3,4,5].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
) : endpoints?.length === 0 ? (
<EmptyState
icon={
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.757 8.257" />
</svg>
}
title="No endpoints"
description={selectedModule ? "This module has no endpoints." : "No endpoints in this project yet. Import an API document to get started."}
/>
) : (
<div className="space-y-1.5 stagger-children">
{endpoints?.map((ep) => (
<div key={ep.id} className="card overflow-hidden">
<button
onClick={() => setExpandedEndpoint(expandedEndpoint === ep.id ? null : ep.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-bg-tertiary/50 transition-colors"
>
<Badge variant={methodVariant[ep.method] || 'default'}>{ep.method}</Badge>
<code className="text-[13px] text-text-primary font-mono truncate">{ep.path}</code>
{ep.summary && <span className="text-[13px] text-text-muted ml-auto truncate max-w-[240px] hidden lg:block">{ep.summary}</span>}
{ep.deprecated && <Badge variant="warning">deprecated</Badge>}
<svg className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform duration-200 ${expandedEndpoint === ep.id ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 9l-7 7-7-7" /></svg>
</button>
{expandedEndpoint === ep.id && endpointDetail && (
<div className="border-t border-border-muted px-5 py-5 space-y-5 animate-fade-in">
{endpointDetail.description && <p className="text-[13px] text-text-secondary leading-relaxed">{endpointDetail.description}</p>}
{endpointDetail.operationId && (
<div className="flex items-center gap-2">
<span className="section-label">Operation ID</span>
<code className="text-xs font-mono text-text-secondary bg-bg-tertiary px-1.5 py-0.5 rounded">{endpointDetail.operationId}</code>
</div>
)}
{Array.isArray(endpointDetail.parameters) && (endpointDetail.parameters as unknown[]).length > 0 && (
<div>
<p className="section-label mb-2">Parameters</p>
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.parameters, null, 2)}</pre>
</div>
)}
{endpointDetail.requestBody != null && (
<div>
<p className="section-label mb-2">Request Body</p>
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.requestBody, null, 2)}</pre>
</div>
)}
{endpointDetail.responses != null && (
<div>
<p className="section-label mb-2">Responses</p>
<pre className="code-block text-xs">{JSON.stringify(endpointDetail.responses, null, 2)}</pre>
</div>
)}
</div>
)}
</div>
)}
))}
</div>
))}
)}
</div>
</div>
);

View File

@@ -1,21 +1,23 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import ConfirmDialog from '../../components/ConfirmDialog';
type Project = { id: string; name: string };
export default function McpIntegration({ project }: { project: Project }) {
const [apiKey, setApiKey] = useState<string | null>(null);
const [showRotateConfirm, setShowRotateConfirm] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const mcpHost = window.location.hostname;
const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`;
const rotateMutation = useMutation({
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
onSuccess: (data) => setApiKey(data.apiKey),
onSuccess: (data) => { setApiKey(data.apiKey); setShowRotateConfirm(false); },
});
const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const configSnippet = JSON.stringify({
mcpServers: {
[serverName]: {
@@ -26,52 +28,105 @@ export default function McpIntegration({ project }: { project: Project }) {
},
}, null, 2);
const copyText = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="font-medium mb-2">MCP Service URL</h3>
<div className="max-w-2xl space-y-8">
{/* MCP URL */}
<section>
<p className="section-title">MCP Service URL</p>
<p className="section-desc mb-3">Connect your LLM client to this endpoint.</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono">{mcpUrl}</code>
<button onClick={() => navigator.clipboard.writeText(mcpUrl)} className="px-3 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300">Copy</button>
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-primary truncate">{mcpUrl}</code>
<button onClick={() => copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
{copied === 'url' ? (
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Copied</>
) : (
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> Copy</>
)}
</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">API Key</h3>
</section>
{/* API Key */}
<section>
<p className="section-title">API Key</p>
<p className="section-desc mb-3">Used to authenticate MCP requests. Each project has its own key.</p>
{apiKey ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-xs text-yellow-700 mb-1">Save this key it won't be shown again.</p>
<code className="text-sm break-all">{apiKey}</code>
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
<p className="text-xs font-medium text-warning">Save this key it won't be shown again.</p>
</div>
<button onClick={() => copyText(apiKey, 'key')} className="text-xs font-medium text-warning hover:underline">
{copied === 'key' ? 'Copied!' : 'Copy'}
</button>
</div>
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2">{apiKey}</code>
</div>
) : (
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
<div className="flex items-center gap-3 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
<p className="text-[13px] text-text-muted">API key is hidden. Rotate to generate a new one.</p>
</div>
)}
<button onClick={() => { if (confirm('This will invalidate the current API key. Continue?')) rotateMutation.mutate(); }}
className="mt-2 px-3 py-1 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200">Rotate API Key</button>
</div>
<div>
<h3 className="font-medium mb-2">Configuration for Claude Code / Cursor</h3>
<button onClick={() => setShowRotateConfirm(true)} className="btn-outline mt-3">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Rotate API Key
</button>
</section>
{/* Config snippet */}
<section>
<p className="section-title">Configuration for Claude Code / Cursor</p>
<p className="section-desc mb-3">Add this to your MCP client configuration.</p>
<div className="relative">
<pre className="bg-gray-900 text-green-400 p-4 rounded text-sm overflow-auto">{configSnippet}</pre>
<button onClick={() => navigator.clipboard.writeText(configSnippet)} className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600">Copy</button>
<pre className="code-block text-xs">{configSnippet}</pre>
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn absolute top-2.5 right-2.5">
{copied === 'config' ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<div>
<h3 className="font-medium mb-2">Available Tools</h3>
<div className="space-y-2 text-sm">
</section>
{/* Available tools */}
<section>
<p className="section-title">Available MCP Tools</p>
<p className="section-desc mb-3">5 tools for progressive drill-down, designed for minimal token usage.</p>
<div className="space-y-1.5 stagger-children">
{[
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.' },
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.' },
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.' },
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.' },
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.' },
{ name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.', num: '1' },
{ name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.', num: '2' },
{ name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.', num: '3' },
{ name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.', num: '4' },
{ name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.', num: '5' },
].map((t) => (
<div key={t.name} className="p-3 bg-gray-50 rounded">
<code className="font-medium">{t.name}</code>
<p className="text-gray-500 mt-1">{t.desc}</p>
<div key={t.name} className="card px-4 py-3 flex items-start gap-3">
<span className="w-5 h-5 rounded-full bg-accent-muted text-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">{t.num}</span>
<div className="min-w-0">
<code className="text-[13px] font-mono font-medium text-accent">{t.name}</code>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
<ConfirmDialog
open={showRotateConfirm}
onCancel={() => setShowRotateConfirm(false)}
onConfirm={() => rotateMutation.mutate()}
title="Rotate API Key"
description="This will invalidate the current API key immediately. Any MCP clients using the old key will stop working."
confirmText="Rotate Key"
variant="warning"
/>
</div>
);
}

View File

@@ -1,14 +1,19 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import Badge from '../../components/Badge';
import ConfirmDialog from '../../components/ConfirmDialog';
import EmptyState from '../../components/EmptyState';
import Skeleton from '../../components/Skeleton';
type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } };
export default function ModuleManagement({ projectId }: { projectId: string }) {
const [newModuleName, setNewModuleName] = useState('');
const [deleteTarget, setDeleteTarget] = useState<Module | null>(null);
const queryClient = useQueryClient();
const { data: modules } = useQuery({
const { data: modules, isLoading } = useQuery({
queryKey: ['modules', projectId],
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
});
@@ -20,29 +25,84 @@ export default function ModuleManagement({ projectId }: { projectId: string }) {
const deleteMutation = useMutation({
mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setDeleteTarget(null); },
});
return (
<div className="max-w-2xl space-y-4">
<div className="flex gap-2">
<input type="text" placeholder="New module name" value={newModuleName} onChange={(e) => setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" />
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 disabled:opacity-50">Add Module</button>
</div>
<div className="space-y-2">
{modules?.map((m) => (
<div key={m.id} className="flex items-center justify-between p-3 bg-white rounded-lg border">
<div>
<span className="font-medium">{m.name}</span>
<span className="text-xs text-gray-400 ml-2">({m.source})</span>
<span className="text-xs text-gray-400 ml-2">{m._count.endpoints} endpoints</span>
</div>
<button onClick={() => { if (confirm(`Delete "${m.name}"?`)) deleteMutation.mutate(m.id); }}
className="text-xs text-red-500 hover:underline">Delete</button>
<div className="max-w-2xl space-y-6">
{/* Add module */}
<section>
<p className="section-label mb-3">Add Manual Module</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Module name"
value={newModuleName}
onChange={(e) => setNewModuleName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && newModuleName && createMutation.mutate(newModuleName)}
className="input-base flex-1"
/>
<button onClick={() => newModuleName && createMutation.mutate(newModuleName)} disabled={!newModuleName} className="btn-primary shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 4v16m8-8H4" /></svg>
Add
</button>
</div>
</section>
{/* Module list */}
<section>
<div className="flex items-center justify-between mb-3">
<p className="section-label">All Modules</p>
{modules && <span className="text-xs text-text-muted">{modules.length} total</span>}
</div>
{isLoading ? (
<div className="space-y-2">{[1,2,3].map(i => <Skeleton key={i} className="h-[52px] w-full" />)}</div>
) : modules?.length === 0 ? (
<EmptyState
icon={
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25z" />
</svg>
}
title="No modules yet"
description="Modules are automatically created when you import an API document. You can also add manual modules above."
/>
) : (
<div className="space-y-1.5 stagger-children">
{modules?.map((m) => (
<div key={m.id} className="card flex items-center justify-between px-4 py-3 group">
<div className="flex items-center gap-3 min-w-0">
<span className="text-[13px] font-medium text-text-primary truncate">{m.name}</span>
<Badge>{m.source}</Badge>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-text-muted">{m._count.endpoints} endpoints</span>
<button
onClick={() => setDeleteTarget(m)}
className="p-1.5 rounded-md text-text-muted opacity-0 group-hover:opacity-100 hover:text-danger hover:bg-danger-muted transition-all"
title="Delete module"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
))}
</div>
)}
</section>
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
title="Delete module"
description={`Delete "${deleteTarget?.name}"? This will also remove its ${deleteTarget?._count.endpoints ?? 0} endpoints.`}
confirmText="Delete"
variant="danger"
/>
</div>
);
}

View File

@@ -2,18 +2,27 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import ConfirmDialog from '../../components/ConfirmDialog';
import ReimportDialog from '../ReimportDialog';
type Project = { id: string; name: string; description: string | null };
type Project = { id: string; name: string; description: string | null; _count: { endpoints: number; modules: number } };
export default function ProjectSettings({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || '');
const [showDelete, setShowDelete] = useState(false);
const [showReimport, setShowReimport] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description: description || undefined }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project', project.id] });
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
},
});
const deleteMutation = useMutation({
@@ -21,20 +30,78 @@ export default function ProjectSettings({ project }: { project: Project }) {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
});
const handleReimportSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['project', project.id] });
queryClient.invalidateQueries({ queryKey: ['modules', project.id] });
queryClient.invalidateQueries({ queryKey: ['endpoints', project.id] });
setShowReimport(false);
};
return (
<div className="max-w-lg space-y-6">
<div className="space-y-3">
<div><label className="block text-sm font-medium mb-1">Project Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" /></div>
<div><label className="block text-sm font-medium mb-1">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded-md" /></div>
<button onClick={() => updateMutation.mutate()} className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">Save Changes</button>
</div>
<div className="border-t pt-6">
<h3 className="text-red-600 font-medium mb-2">Danger Zone</h3>
<button onClick={() => { if (confirm('Permanently delete this project?')) deleteMutation.mutate(); }}
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700">Delete Project</button>
</div>
<div className="max-w-2xl space-y-8">
{/* General */}
<section>
<p className="section-title">General</p>
<p className="section-desc mb-4">Update your project name and description.</p>
<div className="space-y-4">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">Project Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="input-base resize-none" />
</div>
<button onClick={() => updateMutation.mutate()} className="btn-primary">
{saveSuccess ? (
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Saved</>
) : 'Save Changes'}
</button>
</div>
</section>
{/* Re-import */}
<section className="border-t border-border-default pt-8">
<p className="section-title">Re-import API Document</p>
<p className="section-desc mb-4">
Replace the current API documentation with a new OpenAPI document. This will clear all existing modules ({project._count.modules}) and endpoints ({project._count.endpoints}), then recreate them from the new document.
</p>
<button onClick={() => setShowReimport(true)} className="btn-outline">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Re-import Document
</button>
</section>
{/* Danger zone */}
<section className="border border-danger/15 rounded-xl p-5 space-y-3">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
<p className="section-title" style={{ color: 'var(--danger)' }}>Danger Zone</p>
</div>
<p className="text-[13px] text-text-muted">Permanently delete this project and all its data. This action cannot be undone.</p>
<button onClick={() => setShowDelete(true)} className="btn-danger">Delete Project</button>
</section>
<ConfirmDialog
open={showDelete}
onCancel={() => setShowDelete(false)}
onConfirm={() => deleteMutation.mutate()}
title="Delete project"
description={`Permanently delete "${project.name}"? All modules, endpoints, and MCP configuration will be removed.`}
confirmText="Delete"
variant="danger"
/>
{showReimport && (
<ReimportDialog
projectId={project.id}
currentStats={project._count}
onClose={() => setShowReimport(false)}
onSuccess={handleReimportSuccess}
/>
)}
</div>
);
}