diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index c53fff7..003cabf 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -88,6 +88,56 @@ router.post('/refresh', async (req, res) => { } }); +const changePasswordSchema = z.object({ + currentPassword: z.string(), + newPassword: z.string().min(8), +}); + +router.post('/change-password', requireAuth, async (req, res) => { + const parsed = changePasswordSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + + const { currentPassword, newPassword } = parsed.data; + const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); + + if (!user || !user.passwordHash) { + res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set for this account' } }); + return; + } + + const valid = await verifyPassword(currentPassword, user.passwordHash); + if (!valid) { + res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Current password is incorrect' } }); + return; + } + + const newHash = await hashPassword(newPassword); + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: newHash } }); + res.json({ success: true, data: { message: 'Password changed' } }); +}); + +const profileSchema = z.object({ + name: z.string().min(1).max(100), +}); + +router.put('/profile', requireAuth, async (req, res) => { + const parsed = profileSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + + const user = await prisma.user.update({ + where: { id: req.user!.userId }, + data: { name: parsed.data.name }, + select: { id: true, email: true, name: true }, + }); + res.json({ success: true, data: user }); +}); + router.get('/me', requireAuth, async (req, res) => { const user = await prisma.user.findUnique({ where: { id: req.user!.userId }, diff --git a/packages/web/index.html b/packages/web/index.html index 95a4a58..759e65f 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -4,6 +4,9 @@ + + + Agent Fox diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 8256aeb..51ca5e4 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,30 +1,35 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from './lib/auth'; +import { ThemeProvider } from './lib/theme'; import Login from './pages/Login'; import Register from './pages/Register'; import Layout from './pages/Layout'; import Projects from './pages/Projects'; import ProjectDetail from './pages/ProjectDetail'; +import Settings from './pages/Settings'; const queryClient = new QueryClient(); export default function App() { return ( - - - - } /> - } /> - }> - } /> - } /> - - } /> - - - + + + + + } /> + } /> + }> + } /> + } /> + } /> + + } /> + + + + ); } diff --git a/packages/web/src/components/Badge.tsx b/packages/web/src/components/Badge.tsx new file mode 100644 index 0000000..0857eb4 --- /dev/null +++ b/packages/web/src/components/Badge.tsx @@ -0,0 +1,26 @@ +type BadgeProps = { + children: React.ReactNode; + variant?: 'default' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'accent' | 'warning'; +}; + +export default function Badge({ children, variant = 'default' }: BadgeProps) { + if (['get', 'post', 'put', 'delete', 'patch'].includes(variant)) { + return ( + + {children} + + ); + } + + const styles: Record = { + default: 'bg-bg-tertiary text-text-secondary', + accent: 'bg-accent-muted text-accent', + warning: 'bg-warning-muted text-warning', + }; + + return ( + + {children} + + ); +} diff --git a/packages/web/src/components/ConfirmDialog.tsx b/packages/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..dff3b81 --- /dev/null +++ b/packages/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,42 @@ +import Modal from './Modal'; + +type ConfirmDialogProps = { + open: boolean; + onConfirm: () => void; + onCancel: () => void; + title: string; + description: string; + confirmText?: string; + variant?: 'danger' | 'warning'; +}; + +export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText = 'Confirm', variant = 'danger' }: ConfirmDialogProps) { + const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted'; + + return ( + +
+
+
+ + + +
+
+

{title}

+

{description}

+
+
+
+ + +
+
+
+ ); +} diff --git a/packages/web/src/components/EmptyState.tsx b/packages/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..255de53 --- /dev/null +++ b/packages/web/src/components/EmptyState.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +type EmptyStateProps = { + icon?: ReactNode; + title: string; + description?: string; + action?: ReactNode; +}; + +export default function EmptyState({ icon, title, description, action }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} diff --git a/packages/web/src/components/Modal.tsx b/packages/web/src/components/Modal.tsx new file mode 100644 index 0000000..a2fa2c8 --- /dev/null +++ b/packages/web/src/components/Modal.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef, type ReactNode } from 'react'; + +type ModalProps = { + open: boolean; + onClose: () => void; + children: ReactNode; + size?: 'sm' | 'md' | 'lg'; +}; + +const widths = { sm: '384px', md: '512px', lg: '672px' }; + +export default function Modal({ open, onClose, children, size = 'md' }: ModalProps) { + const ref = useRef(null); + + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + else if (!open && dialog.open) dialog.close(); + }, [open]); + + return ( + { if (e.target === ref.current) onClose(); }} + style={{ width: widths[size] }} + className="rounded-xl border border-border-default bg-bg-elevated p-0 shadow-lg" + > +
{children}
+
+ ); +} diff --git a/packages/web/src/components/Skeleton.tsx b/packages/web/src/components/Skeleton.tsx new file mode 100644 index 0000000..ae7060b --- /dev/null +++ b/packages/web/src/components/Skeleton.tsx @@ -0,0 +1,7 @@ +type SkeletonProps = { + className?: string; +}; + +export default function Skeleton({ className = 'h-4 w-full' }: SkeletonProps) { + return
; +} diff --git a/packages/web/src/components/ThemeToggle.tsx b/packages/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..4a1380a --- /dev/null +++ b/packages/web/src/components/ThemeToggle.tsx @@ -0,0 +1,45 @@ +import { useTheme } from '../lib/theme'; + +const icons = { + light: ( + + + + ), + dark: ( + + + + ), + system: ( + + + + ), +}; + +const labels = { light: '浅色', dark: '深色', system: '跟随系统' } as const; +const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; + +export default function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( +
+ {order.map((t) => ( + + ))} +
+ ); +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index f1d8c73..1ec1fb4 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -1 +1,338 @@ @import "tailwindcss"; + +/* ===== Theme Variables ===== */ +:root { + /* Light theme (default) */ + --bg-primary: #ffffff; + --bg-secondary: #f8f9fb; + --bg-tertiary: #f0f1f4; + --bg-elevated: #ffffff; + --bg-inset: #e8e9ed; + --bg-sidebar: #fafbfc; + --border-default: #e2e4e9; + --border-muted: #eef0f3; + --border-strong: #cdd0d5; + --text-primary: #0f1115; + --text-secondary: #4a4f5a; + --text-muted: #868c98; + --text-inverted: #ffffff; + --accent: #6366f1; + --accent-hover: #4f46e5; + --accent-subtle: #eef2ff; + --accent-muted: rgba(99, 102, 241, 0.1); + --danger: #e5484d; + --danger-muted: rgba(229, 72, 77, 0.08); + --success: #30a46c; + --success-muted: rgba(48, 164, 108, 0.08); + --warning: #e5a000; + --warning-muted: rgba(229, 160, 0, 0.08); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 2px 8px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.02); + --code-bg: #1a1b26; + --code-text: #9ece6a; + --code-comment: #565f89; + --code-keyword: #bb9af7; + --overlay: rgba(0, 0, 0, 0.4); + --method-get: #30a46c; + --method-get-bg: rgba(48, 164, 108, 0.1); + --method-post: #3b82f6; + --method-post-bg: rgba(59, 130, 246, 0.1); + --method-put: #e5a000; + --method-put-bg: rgba(229, 160, 0, 0.1); + --method-delete: #e5484d; + --method-delete-bg: rgba(229, 72, 77, 0.1); + --method-patch: #8b5cf6; + --method-patch-bg: rgba(139, 92, 246, 0.1); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg-primary: #0a0a0c; + --bg-secondary: #101012; + --bg-tertiary: #18181b; + --bg-elevated: #1a1a1e; + --bg-inset: #232326; + --bg-sidebar: #0e0e10; + --border-default: #27272a; + --border-muted: #1e1e21; + --border-strong: #3f3f46; + --text-primary: #ececef; + --text-secondary: #a0a0ab; + --text-muted: #63636e; + --text-inverted: #0a0a0c; + --accent: #818cf8; + --accent-hover: #6366f1; + --accent-subtle: rgba(129, 140, 248, 0.08); + --accent-muted: rgba(129, 140, 248, 0.12); + --danger: #f87171; + --danger-muted: rgba(248, 113, 113, 0.1); + --success: #4ade80; + --success-muted: rgba(74, 222, 128, 0.1); + --warning: #fbbf24; + --warning-muted: rgba(251, 191, 36, 0.1); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); + --shadow-md: 0 2px 8px rgba(0,0,0,0.5); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.6); + --code-bg: #0c0c0f; + --code-text: #9ece6a; + --code-comment: #565f89; + --code-keyword: #bb9af7; + --overlay: rgba(0, 0, 0, 0.65); + --method-get: #4ade80; + --method-get-bg: rgba(74, 222, 128, 0.12); + --method-post: #60a5fa; + --method-post-bg: rgba(96, 165, 250, 0.12); + --method-put: #fbbf24; + --method-put-bg: rgba(251, 191, 36, 0.12); + --method-delete: #f87171; + --method-delete-bg: rgba(248, 113, 113, 0.12); + --method-patch: #a78bfa; + --method-patch-bg: rgba(167, 139, 250, 0.12); + } +} + +[data-theme="dark"] { + --bg-primary: #0a0a0c; + --bg-secondary: #101012; + --bg-tertiary: #18181b; + --bg-elevated: #1a1a1e; + --bg-inset: #232326; + --bg-sidebar: #0e0e10; + --border-default: #27272a; + --border-muted: #1e1e21; + --border-strong: #3f3f46; + --text-primary: #ececef; + --text-secondary: #a0a0ab; + --text-muted: #63636e; + --text-inverted: #0a0a0c; + --accent: #818cf8; + --accent-hover: #6366f1; + --accent-subtle: rgba(129, 140, 248, 0.08); + --accent-muted: rgba(129, 140, 248, 0.12); + --danger: #f87171; + --danger-muted: rgba(248, 113, 113, 0.1); + --success: #4ade80; + --success-muted: rgba(74, 222, 128, 0.1); + --warning: #fbbf24; + --warning-muted: rgba(251, 191, 36, 0.1); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); + --shadow-md: 0 2px 8px rgba(0,0,0,0.5); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.6); + --code-bg: #0c0c0f; + --code-text: #9ece6a; + --code-comment: #565f89; + --code-keyword: #bb9af7; + --overlay: rgba(0, 0, 0, 0.65); + --method-get: #4ade80; + --method-get-bg: rgba(74, 222, 128, 0.12); + --method-post: #60a5fa; + --method-post-bg: rgba(96, 165, 250, 0.12); + --method-put: #fbbf24; + --method-put-bg: rgba(251, 191, 36, 0.12); + --method-delete: #f87171; + --method-delete-bg: rgba(248, 113, 113, 0.12); + --method-patch: #a78bfa; + --method-patch-bg: rgba(167, 139, 250, 0.12); +} + +/* ===== Tailwind Theme ===== */ +@theme { + --font-sans: 'DM Sans', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + --font-display: 'DM Sans', system-ui, sans-serif; + + --color-bg-primary: var(--bg-primary); + --color-bg-secondary: var(--bg-secondary); + --color-bg-tertiary: var(--bg-tertiary); + --color-bg-elevated: var(--bg-elevated); + --color-bg-inset: var(--bg-inset); + --color-bg-sidebar: var(--bg-sidebar); + --color-border-default: var(--border-default); + --color-border-muted: var(--border-muted); + --color-border-strong: var(--border-strong); + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-muted: var(--text-muted); + --color-text-inverted: var(--text-inverted); + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + --color-accent-subtle: var(--accent-subtle); + --color-accent-muted: var(--accent-muted); + --color-danger: var(--danger); + --color-danger-muted: var(--danger-muted); + --color-success: var(--success); + --color-success-muted: var(--success-muted); + --color-warning: var(--warning); + --color-warning-muted: var(--warning-muted); + --color-code-bg: var(--code-bg); + --color-code-text: var(--code-text); + --color-overlay: var(--overlay); + + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + + --animate-fade-in: fade-in 0.2s ease-out both; + --animate-slide-up: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; + --animate-shimmer: shimmer 1.8s ease-in-out infinite; + --animate-pulse-soft: pulse-soft 2s ease-in-out infinite; + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slide-up { + from { opacity: 0; transform: translateY(10px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + @keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } + } + @keyframes pulse-soft { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } +} + +/* ===== Base ===== */ +body { + background: var(--bg-secondary); + color: var(--text-secondary); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +::selection { + background: var(--accent); + color: white; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 99px; } +::-webkit-scrollbar-thumb:hover { background: var(--border-strong); } + +/* ===== Component Utilities ===== */ +@layer components { + .btn-primary { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer; + background: var(--accent); + color: white; + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1); + &:hover { background: var(--accent-hover); transform: translateY(-0.5px); } + &:active { transform: translateY(0); } + &:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + } + .btn-ghost { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer; + color: var(--text-secondary); + &:hover { background: var(--bg-tertiary); color: var(--text-primary); } + } + .btn-danger { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer; + background: var(--danger); + color: white; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + &:hover { opacity: 0.9; transform: translateY(-0.5px); } + &:active { transform: translateY(0); } + &:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + } + .btn-outline { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer; + border: 1px solid var(--border-default); + color: var(--text-secondary); + &:hover { border-color: var(--border-strong); color: var(--text-primary); background: var(--bg-tertiary); } + } + .input-base { + @apply w-full px-3.5 py-2.5 rounded-lg text-sm transition-all duration-150 outline-none; + background: var(--bg-primary); + border: 1px solid var(--border-default); + color: var(--text-primary); + &::placeholder { color: var(--text-muted); } + &:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); } + } + .card { + @apply rounded-xl transition-all duration-200; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + } + .card-hover { + &:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); } + } + .skeleton { + background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-inset) 50%, var(--bg-tertiary) 75%); + background-size: 200% 100%; + animation: shimmer 1.8s ease-in-out infinite; + border-radius: 0.5rem; + } + .code-block { + @apply rounded-lg p-4 text-sm font-mono overflow-auto relative; + background: var(--code-bg); + color: var(--code-text); + border: 1px solid var(--border-default); + } + .section-label { + @apply text-[11px] font-semibold uppercase tracking-[0.08em]; + color: var(--text-muted); + letter-spacing: 0.08em; + } + .section-title { + @apply text-sm font-semibold; + color: var(--text-primary); + } + .section-desc { + @apply text-[13px] mt-0.5; + color: var(--text-muted); + } + .copy-btn { + @apply px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 cursor-pointer; + background: rgba(255,255,255,0.08); + color: rgba(255,255,255,0.6); + &:hover { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); } + } +} + +/* ===== Method Badges ===== */ +.method-badge { + @apply inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold font-mono tracking-wide; +} +.method-get { background: var(--method-get-bg); color: var(--method-get); } +.method-post { background: var(--method-post-bg); color: var(--method-post); } +.method-put { background: var(--method-put-bg); color: var(--method-put); } +.method-delete { background: var(--method-delete-bg); color: var(--method-delete); } +.method-patch { background: var(--method-patch-bg); color: var(--method-patch); } + +/* ===== Dialog ===== */ +dialog { + color: var(--text-secondary); + max-height: calc(100vh - 4rem); + max-width: calc(100vw - 2rem); +} +dialog[open] { + position: fixed; + inset: 0; + margin: auto; + animation: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; +} +dialog::backdrop { + background: var(--overlay); + backdrop-filter: blur(6px); +} + +/* ===== Staggered children animation ===== */ +.stagger-children > * { + animation: fade-in 0.3s ease-out both; +} +.stagger-children > *:nth-child(1) { animation-delay: 0ms; } +.stagger-children > *:nth-child(2) { animation-delay: 40ms; } +.stagger-children > *:nth-child(3) { animation-delay: 80ms; } +.stagger-children > *:nth-child(4) { animation-delay: 120ms; } +.stagger-children > *:nth-child(5) { animation-delay: 160ms; } +.stagger-children > *:nth-child(6) { animation-delay: 200ms; } +.stagger-children > *:nth-child(7) { animation-delay: 240ms; } +.stagger-children > *:nth-child(8) { animation-delay: 280ms; } +.stagger-children > *:nth-child(n+9) { animation-delay: 320ms; } diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx index 9dc8948..9c48e0a 100644 --- a/packages/web/src/lib/auth.tsx +++ b/packages/web/src/lib/auth.tsx @@ -9,6 +9,7 @@ type AuthContextType = { login: (email: string, password: string) => Promise; register: (email: string, password: string, name: string) => Promise; logout: () => void; + updateUser: (updates: Partial) => void; }; const AuthContext = createContext(null); @@ -48,8 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = () => { clearTokens(); setUser(null); }; + const updateUser = (updates: Partial) => { + setUser(prev => prev ? { ...prev, ...updates } : null); + }; + return ( - + {children} ); diff --git a/packages/web/src/lib/theme.tsx b/packages/web/src/lib/theme.tsx new file mode 100644 index 0000000..acec28f --- /dev/null +++ b/packages/web/src/lib/theme.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +type ThemeContextType = { + theme: Theme; + resolved: 'light' | 'dark'; + setTheme: (t: Theme) => void; +}; + +const ThemeContext = createContext(null); + +function getSystemTheme(): 'light' | 'dark' { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme: Theme) { + const resolved = theme === 'system' ? getSystemTheme() : theme; + if (theme === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', theme); + } + return resolved; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { + return (localStorage.getItem('agent-fox-theme') as Theme) || 'system'; + }); + const [resolved, setResolved] = useState<'light' | 'dark'>(() => applyTheme( + (localStorage.getItem('agent-fox-theme') as Theme) || 'system' + )); + + const setTheme = (t: Theme) => { + localStorage.setItem('agent-fox-theme', t); + setThemeState(t); + setResolved(applyTheme(t)); + }; + + useEffect(() => { + setResolved(applyTheme(theme)); + }, [theme]); + + useEffect(() => { + if (theme !== 'system') return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => setResolved(applyTheme('system')); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [theme]); + + return ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} diff --git a/packages/web/src/pages/ImportDialog.tsx b/packages/web/src/pages/ImportDialog.tsx index 271a111..85a6032 100644 --- a/packages/web/src/pages/ImportDialog.tsx +++ b/packages/web/src/pages/ImportDialog.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { apiFetch } from '../lib/api'; +import Modal from '../components/Modal'; type ImportResult = { project: { id: string; name: string }; @@ -13,20 +14,30 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { const [mode, setMode] = useState<'url' | 'file'>('url'); const [url, setUrl] = useState(''); const [fileContent, setFileContent] = useState(''); + const [fileName, setFileName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); const navigate = useNavigate(); const queryClient = useQueryClient(); - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const handleFile = (file: File) => { + setFileName(file.name); const reader = new FileReader(); reader.onload = () => setFileContent(reader.result as string); reader.readAsText(file); }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }; + const handleImport = async () => { setLoading(true); setError(''); @@ -49,48 +60,113 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) { } }; + const copyKey = () => { + if (result?.apiKey) { + navigator.clipboard.writeText(result.apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + return ( -
-
- {!result ? ( - <> -

Import OpenAPI Document

-
- - + + {!result ? ( +
+
+

Import OpenAPI Document

+

Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.

+
+ + {/* Mode toggle */} +
+ + +
+ + {mode === 'url' ? ( + setUrl(e.target.value)} className="input-base" /> + ) : ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${ + dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong' + }`} + > + e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" /> + + + + {fileName ? ( +

{fileName}

+ ) : ( + <> +

Drop your OpenAPI file here

+

JSON or YAML

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

{error}

} -
- - + +
+
+ ) : ( +
+
+
+ +
+
+

Import Successful

+

{result.project.name}

+
+
+ +
+
+
{result.stats.modules}
+
Modules
+
+
+
{result.stats.endpoints}
+
Endpoints
+
+
+ +
+
+
+ +

API Key — save it now

+
+
- - ) : ( - <> -

Import Successful!

-
-

Project: {result.project.name}

-

Modules: {result.stats.modules}

-

Endpoints: {result.stats.endpoints}

-
-

API Key (save it now):

- {result.apiKey} -
-
-
- -
- - )} -
-
+ {result.apiKey} +
+ +
+ +
+
+ )} + ); } diff --git a/packages/web/src/pages/Layout.tsx b/packages/web/src/pages/Layout.tsx index ac598c1..e03dd10 100644 --- a/packages/web/src/pages/Layout.tsx +++ b/packages/web/src/pages/Layout.tsx @@ -1,22 +1,128 @@ -import { Navigate, Outlet } from 'react-router-dom'; +import { useState } from 'react'; +import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom'; import { useAuth } from '../lib/auth'; +import ThemeToggle from '../components/ThemeToggle'; export default function Layout() { const { user, loading, logout } = useAuth(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const location = useLocation(); - if (loading) return
Loading...
; + if (loading) { + return ( +
+
+
+ ); + } if (!user) return ; + const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); + const isSettings = location.pathname === '/settings'; + return ( -
-
-

Agent Fox

-
- {user.name} - +
+ {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + {/* Sidebar */} +
-
+ + {/* Navigation */} + + + {/* Bottom section */} +
+
+ +
+
+ +
+ {initials} +
+
+
{user.name}
+
{user.email}
+
+ + +
+
+ + + {/* Main */} +
+ {/* Mobile header */} +
+ + Agent Fox +
+
+
+ +
+
+
); } diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx index 7810a2e..1153fd6 100644 --- a/packages/web/src/pages/Login.tsx +++ b/packages/web/src/pages/Login.tsx @@ -6,32 +6,76 @@ export default function Login() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); + setLoading(true); try { await login(email, password); navigate('/'); } catch (err) { setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setLoading(false); } }; return ( -
-
-

Sign In to Agent Fox

- {error &&

{error}

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

- Don't have an account? Sign Up +

+ {/* Subtle grid background */} +
+ {/* Radial fade */} +
+ +
+ {/* Brand */} +
+
+ + + +
+

Sign in to Agent Fox

+

API documentation for LLMs

+
+ + {/* Card */} +
+ {error && ( +
+ + {error} +
+ )} +
+
+ + setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required /> +
+
+ + setPassword(e.target.value)} className="input-base" placeholder="Enter your password" required /> +
+ +
+
+ +

+ Don't have an account?{' '} + Sign Up

diff --git a/packages/web/src/pages/ProjectDetail.tsx b/packages/web/src/pages/ProjectDetail.tsx index 2552556..c21e7ae 100644 --- a/packages/web/src/pages/ProjectDetail.tsx +++ b/packages/web/src/pages/ProjectDetail.tsx @@ -6,53 +6,104 @@ import DocPreview from './tabs/DocPreview'; import ModuleManagement from './tabs/ModuleManagement'; import McpIntegration from './tabs/McpIntegration'; import ProjectSettings from './tabs/ProjectSettings'; +import Badge from '../components/Badge'; +import Skeleton from '../components/Skeleton'; type ProjectData = { id: string; name: string; description: string | null; baseUrl: string | null; openApiVersion: string; modules: Array<{ id: string; name: string; description: string | null; _count: { endpoints: number } }>; - _count: { endpoints: number }; + _count: { endpoints: number; modules: number }; }; -const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const; -type Tab = (typeof tabs)[number]; +const tabs = [ + { key: 'docs', label: 'Documentation', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, + { key: 'modules', label: 'Modules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, + { key: 'mcp', label: 'MCP', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' }, + { key: 'settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }, +] as const; + +type TabKey = (typeof tabs)[number]['key']; export default function ProjectDetail() { const { id } = useParams<{ id: string }>(); - const [activeTab, setActiveTab] = useState('Documentation'); + const [activeTab, setActiveTab] = useState('docs'); const { data: project, isLoading } = useQuery({ queryKey: ['project', id], queryFn: () => apiFetch(`/projects/${id}`), }); - if (isLoading) return
Loading...
; - if (!project) return
Project not found
; + if (isLoading) { + return ( +
+ +
+ + +
+ + +
+ ); + } + + if (!project) { + return ( +
+ +

Project not found

+ Back to projects +
+ ); + } return (
-
← Back to projects
-
+ {/* Breadcrumb */} +
+ Projects + + {project.name} +
+ + {/* Header */} +
-

{project.name}

- {project.description &&

{project.description}

} +

{project.name}

+ {project.description &&

{project.description}

}
-
OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints
-
-
-
- {tabs.map((tab) => ( - - ))} +
+ OpenAPI {project.openApiVersion} + {project._count.endpoints} endpoints
- {activeTab === 'Documentation' && } - {activeTab === 'Modules' && } - {activeTab === 'MCP Integration' && } - {activeTab === 'Settings' && } + + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {activeTab === 'docs' && } + {activeTab === 'modules' && } + {activeTab === 'mcp' && } + {activeTab === 'settings' && } +
); } diff --git a/packages/web/src/pages/Projects.tsx b/packages/web/src/pages/Projects.tsx index adf4e74..b8424db 100644 --- a/packages/web/src/pages/Projects.tsx +++ b/packages/web/src/pages/Projects.tsx @@ -3,6 +3,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { apiFetch } from '../lib/api'; import ImportDialog from './ImportDialog'; +import ConfirmDialog from '../components/ConfirmDialog'; +import EmptyState from '../components/EmptyState'; +import Skeleton from '../components/Skeleton'; +import Badge from '../components/Badge'; type ProjectSummary = { id: string; name: string; description: string | null; openApiVersion: string; @@ -11,6 +15,7 @@ type ProjectSummary = { export default function Projects() { const [showImport, setShowImport] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); const queryClient = useQueryClient(); const { data: projects, isLoading } = useQuery({ @@ -20,36 +25,91 @@ export default function Projects() { const deleteMutation = useMutation({ mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setDeleteTarget(null); }, }); - if (isLoading) return
Loading projects...
; - return (
-

Projects

- +

Projects

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

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

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

{p.name}

- {p.description &&

{p.description}

} -
- OpenAPI {p.openApiVersion} - {p._count.modules} modules - {p._count.endpoints} endpoints + + {isLoading ? ( +
+ {[1, 2, 3].map(i => ( +
+ + +
+ + +
- - -
- ))} -
+
+ ))} +
+ ) : projects?.length === 0 ? ( + + + + } + title="No projects yet" + description="Import an OpenAPI document to get started with MCP-powered API documentation." + action={ + + } + /> + ) : ( +
+ {projects?.map((p) => ( +
+ +

{p.name}

+ {p.description &&

{p.description}

} +
+ OpenAPI {p.openApiVersion} + {p._count.modules} modules + {p._count.endpoints} endpoints +
+ + +
+ ))} +
+ )} + {showImport && setShowImport(false)} />} + + setDeleteTarget(null)} + onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)} + title="Delete project" + description={`Are you sure you want to delete "${deleteTarget?.name}"? This will permanently remove all modules, endpoints, and MCP configuration.`} + confirmText="Delete" + variant="danger" + />
); } diff --git a/packages/web/src/pages/Register.tsx b/packages/web/src/pages/Register.tsx index 702aa77..5eb23b3 100644 --- a/packages/web/src/pages/Register.tsx +++ b/packages/web/src/pages/Register.tsx @@ -7,33 +7,76 @@ export default function Register() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); const { register } = useAuth(); const navigate = useNavigate(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); + setLoading(true); try { await register(email, password, name); navigate('/'); } catch (err) { setError(err instanceof Error ? err.message : 'Registration failed'); + } finally { + setLoading(false); } }; return ( -
-
-

Create Account

- {error &&

{error}

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

- Already have an account? Sign In +

+
+
+ +
+
+
+ + + +
+

Create your account

+

Get started with Agent Fox

+
+ +
+ {error && ( +
+ + {error} +
+ )} +
+
+ + setName(e.target.value)} className="input-base" placeholder="Your name" required /> +
+
+ + setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required /> +
+
+ + setPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} required /> +
+ +
+
+ +

+ Already have an account?{' '} + Sign In

diff --git a/packages/web/src/pages/ReimportDialog.tsx b/packages/web/src/pages/ReimportDialog.tsx new file mode 100644 index 0000000..15b7210 --- /dev/null +++ b/packages/web/src/pages/ReimportDialog.tsx @@ -0,0 +1,183 @@ +import { useState, useRef } from 'react'; +import { apiFetch } from '../lib/api'; +import Modal from '../components/Modal'; + +type ReimportResult = { + stats: { modules: number; endpoints: number }; +}; + +type ReimportDialogProps = { + projectId: string; + currentStats: { modules: number; endpoints: number }; + onClose: () => void; + onSuccess: () => void; +}; + +type Step = 'confirm' | 'import' | 'success'; + +export default function ReimportDialog({ projectId, currentStats, onClose, onSuccess }: ReimportDialogProps) { + const [step, setStep] = useState('confirm'); + const [mode, setMode] = useState<'url' | 'file'>('url'); + const [url, setUrl] = useState(''); + const [fileContent, setFileContent] = useState(''); + const [fileName, setFileName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleFile = (file: File) => { + setFileName(file.name); + const reader = new FileReader(); + reader.onload = () => setFileContent(reader.result as string); + reader.readAsText(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }; + + const handleReimport = async () => { + setLoading(true); + setError(''); + try { + let body: Record; + if (mode === 'url') { + body = { specUrl: url }; + } else { + try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; } + } + const data = await apiFetch(`/projects/${projectId}/reimport`, { + method: 'POST', body: JSON.stringify(body), + }); + setResult(data); + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Re-import failed'); + } finally { + setLoading(false); + } + }; + + return ( + + {step === 'confirm' && ( +
+
+

Re-import API Document

+

This action will replace all existing data.

+
+ +
+
+ + + +
+

The following data will be permanently deleted:

+
    +
  • {currentStats.modules} module{currentStats.modules !== 1 ? 's' : ''}
  • +
  • {currentStats.endpoints} endpoint{currentStats.endpoints !== 1 ? 's' : ''}
  • +
+

New modules and endpoints will be created from the imported document. The API key will remain unchanged.

+
+
+
+ +
+ + +
+
+ )} + + {step === 'import' && ( +
+
+

Import New Document

+

Provide a Swagger 2.0 or OpenAPI 3.x document.

+
+ +
+ + +
+ + {mode === 'url' ? ( + setUrl(e.target.value)} className="input-base" /> + ) : ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${ + dragging ? 'border-accent bg-accent-muted' : 'border-border-default hover:border-border-strong' + }`} + > + e.target.files?.[0] && handleFile(e.target.files[0])} className="hidden" /> + + + + {fileName ? ( +

{fileName}

+ ) : ( + <> +

Drop your OpenAPI file here

+

JSON or YAML

+ + )} +
+ )} + + {error &&
{error}
} + +
+ + +
+
+ )} + + {step === 'success' && result && ( +
+
+
+ +
+
+

Re-import Successful

+

API documentation has been updated.

+
+
+ +
+
+
{result.stats.modules}
+
Modules
+
+
+
{result.stats.endpoints}
+
Endpoints
+
+
+ +
+ +
+
+ )} +
+ ); +} diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx new file mode 100644 index 0000000..870c93a --- /dev/null +++ b/packages/web/src/pages/Settings.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { useAuth } from '../lib/auth'; +import { apiFetch } from '../lib/api'; + +export default function Settings() { + const { user, updateUser } = useAuth(); + + const [name, setName] = useState(user?.name || ''); + const [profileLoading, setProfileLoading] = useState(false); + const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + const handleProfileSave = async () => { + setProfileLoading(true); + setProfileMsg(null); + try { + const data = await apiFetch<{ id: string; email: string; name: string }>('/auth/profile', { + method: 'PUT', body: JSON.stringify({ name }), + }); + updateUser({ name: data.name }); + setProfileMsg({ type: 'success', text: 'Profile updated' }); + setTimeout(() => setProfileMsg(null), 3000); + } catch (err) { + setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' }); + } finally { + setProfileLoading(false); + } + }; + + const handlePasswordChange = async () => { + if (newPassword !== confirmPassword) { + setPasswordMsg({ type: 'error', text: 'Passwords do not match' }); + return; + } + setPasswordLoading(true); + setPasswordMsg(null); + try { + await apiFetch('/auth/change-password', { + method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), + }); + setPasswordMsg({ type: 'success', text: 'Password changed successfully' }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => setPasswordMsg(null), 3000); + } catch (err) { + setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' }); + } finally { + setPasswordLoading(false); + } + }; + + const initials = user?.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?'; + + return ( +
+

Settings

+ + {/* Profile */} +
+

Profile

+

Manage your personal information.

+ +
+
+ {initials} +
+
+
{user?.name}
+
{user?.email}
+
+
+ +
+
+ + setName(e.target.value)} className="input-base max-w-sm" /> +
+ +
+ +
+ + {user?.email} +
+
+ + {profileMsg && ( +
+ + {profileMsg.type === 'success' ? : } + + {profileMsg.text} +
+ )} + + +
+
+ + {/* Password */} +
+

Change Password

+

Update your password to keep your account secure.

+ +
+
+ + setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" /> +
+
+ + setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} /> +
+
+ + setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" /> +
+ + {passwordMsg && ( +
+ + {passwordMsg.type === 'success' ? : } + + {passwordMsg.text} +
+ )} + + +
+
+
+ ); +} diff --git a/packages/web/src/pages/tabs/DocPreview.tsx b/packages/web/src/pages/tabs/DocPreview.tsx index a0ccf0c..3686cae 100644 --- a/packages/web/src/pages/tabs/DocPreview.tsx +++ b/packages/web/src/pages/tabs/DocPreview.tsx @@ -1,27 +1,28 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; +import Badge from '../../components/Badge'; +import Skeleton from '../../components/Skeleton'; +import EmptyState from '../../components/EmptyState'; type Module = { id: string; name: string; description: string | null; _count: { endpoints: number } }; type EndpointSummary = { id: string; method: string; path: string; summary: string | null; deprecated: boolean; module: { name: string } }; type EndpointFull = EndpointSummary & { description: string | null; operationId: string | null; parameters: unknown; requestBody: unknown; responses: unknown }; -const methodColors: Record = { - GET: 'bg-green-100 text-green-800', POST: 'bg-blue-100 text-blue-800', - PUT: 'bg-yellow-100 text-yellow-800', DELETE: 'bg-red-100 text-red-800', - PATCH: 'bg-purple-100 text-purple-800', +const methodVariant: Record = { + GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete', PATCH: 'patch', }; export default function DocPreview({ projectId }: { projectId: string }) { const [selectedModule, setSelectedModule] = useState(null); const [expandedEndpoint, setExpandedEndpoint] = useState(null); - const { data: modules } = useQuery({ + const { data: modules, isLoading: modulesLoading } = useQuery({ queryKey: ['modules', projectId], queryFn: () => apiFetch(`/projects/${projectId}/modules`), }); - const { data: endpoints } = useQuery({ + const { data: endpoints, isLoading: endpointsLoading } = useQuery({ queryKey: ['endpoints', projectId, selectedModule], queryFn: () => apiFetch(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`), }); @@ -32,46 +33,104 @@ export default function DocPreview({ projectId }: { projectId: string }) { enabled: !!expandedEndpoint, }); + const totalEndpoints = modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0; + return ( -
-
-

Modules

- - {modules?.map((m) => ( - - ))} +
+ {/* Module sidebar */} +
+
+

Modules

+ {modulesLoading ? ( +
{[1,2,3].map(i => )}
+ ) : modules?.length === 0 ? ( +

No modules

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

{endpointDetail.description}

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

Parameters

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

Request Body

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

Responses

{JSON.stringify(endpointDetail.responses, null, 2)}
+ + {/* Endpoints */} +
+ {endpointsLoading ? ( +
{[1,2,3,4,5].map(i => )}
+ ) : endpoints?.length === 0 ? ( + + + + } + title="No endpoints" + description={selectedModule ? "This module has no endpoints." : "No endpoints in this project yet. Import an API document to get started."} + /> + ) : ( +
+ {endpoints?.map((ep) => ( +
+ + {expandedEndpoint === ep.id && endpointDetail && ( +
+ {endpointDetail.description &&

{endpointDetail.description}

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

Parameters

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

Request Body

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

Responses

+
{JSON.stringify(endpointDetail.responses, null, 2)}
+
+ )} +
)}
- )} + ))}
- ))} + )}
); diff --git a/packages/web/src/pages/tabs/McpIntegration.tsx b/packages/web/src/pages/tabs/McpIntegration.tsx index 5e585d6..7ec095b 100644 --- a/packages/web/src/pages/tabs/McpIntegration.tsx +++ b/packages/web/src/pages/tabs/McpIntegration.tsx @@ -1,21 +1,23 @@ import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; +import ConfirmDialog from '../../components/ConfirmDialog'; type Project = { id: string; name: string }; export default function McpIntegration({ project }: { project: Project }) { const [apiKey, setApiKey] = useState(null); + const [showRotateConfirm, setShowRotateConfirm] = useState(false); + const [copied, setCopied] = useState(null); const mcpHost = window.location.hostname; const mcpUrl = `http://${mcpHost}:3001/mcp/${project.id}`; const rotateMutation = useMutation({ mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }), - onSuccess: (data) => setApiKey(data.apiKey), + onSuccess: (data) => { setApiKey(data.apiKey); setShowRotateConfirm(false); }, }); const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); - const configSnippet = JSON.stringify({ mcpServers: { [serverName]: { @@ -26,52 +28,105 @@ export default function McpIntegration({ project }: { project: Project }) { }, }, null, 2); + const copyText = (text: string, key: string) => { + navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied(null), 2000); + }; + return ( -
-
-

MCP Service URL

+
+ {/* MCP URL */} +
+

MCP Service URL

+

Connect your LLM client to this endpoint.

- {mcpUrl} - + {mcpUrl} +
-
-
-

API Key

+ + + {/* API Key */} +
+

API Key

+

Used to authenticate MCP requests. Each project has its own key.

{apiKey ? ( -
-

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

- {apiKey} +
+
+
+ +

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

+
+ +
+ {apiKey}
) : ( -

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

+
+ +

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

+
)} - -
-
-

Configuration for Claude Code / Cursor

+ +
+ + {/* Config snippet */} +
+

Configuration for Claude Code / Cursor

+

Add this to your MCP client configuration.

-
{configSnippet}
- +
{configSnippet}
+
-
-
-

Available Tools

-
+ + + {/* Available tools */} +
+

Available MCP Tools

+

5 tools for progressive drill-down, designed for minimal token usage.

+
{[ - { name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.' }, - { name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.' }, - { name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.' }, - { name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.' }, - { name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.' }, + { name: 'get_project_overview', desc: 'Get project name, version, base URL, and module summary. Call this first.', num: '1' }, + { name: 'list_modules', desc: 'List all modules with descriptions and endpoint counts.', num: '2' }, + { name: 'list_endpoints', desc: 'List endpoints in a module. Provide moduleId.', num: '3' }, + { name: 'get_endpoint_detail', desc: 'Get full endpoint details: parameters, request body, responses.', num: '4' }, + { name: 'search_endpoints', desc: 'Search by keyword across all endpoints. Optional moduleId filter.', num: '5' }, ].map((t) => ( -
- {t.name} -

{t.desc}

+
+ {t.num} +
+ {t.name} +

{t.desc}

+
))}
-
+
+ + setShowRotateConfirm(false)} + onConfirm={() => rotateMutation.mutate()} + title="Rotate API Key" + description="This will invalidate the current API key immediately. Any MCP clients using the old key will stop working." + confirmText="Rotate Key" + variant="warning" + />
); } diff --git a/packages/web/src/pages/tabs/ModuleManagement.tsx b/packages/web/src/pages/tabs/ModuleManagement.tsx index 0884c27..c49c8f0 100644 --- a/packages/web/src/pages/tabs/ModuleManagement.tsx +++ b/packages/web/src/pages/tabs/ModuleManagement.tsx @@ -1,14 +1,19 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; +import Badge from '../../components/Badge'; +import ConfirmDialog from '../../components/ConfirmDialog'; +import EmptyState from '../../components/EmptyState'; +import Skeleton from '../../components/Skeleton'; type Module = { id: string; name: string; description: string | null; sortOrder: number; source: string; _count: { endpoints: number } }; export default function ModuleManagement({ projectId }: { projectId: string }) { const [newModuleName, setNewModuleName] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); const queryClient = useQueryClient(); - const { data: modules } = useQuery({ + const { data: modules, isLoading } = useQuery({ queryKey: ['modules', projectId], queryFn: () => apiFetch(`/projects/${projectId}/modules`), }); @@ -20,29 +25,84 @@ export default function ModuleManagement({ projectId }: { projectId: string }) { const deleteMutation = useMutation({ mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setDeleteTarget(null); }, }); return ( -
-
- setNewModuleName(e.target.value)} className="flex-1 px-3 py-2 border rounded-md text-sm" /> - -
-
- {modules?.map((m) => ( -
-
- {m.name} - ({m.source}) - {m._count.endpoints} endpoints -
- +
+ {/* Add module */} +
+

Add Manual Module

+
+ setNewModuleName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && newModuleName && createMutation.mutate(newModuleName)} + className="input-base flex-1" + /> + +
+
+ + {/* Module list */} +
+
+

All Modules

+ {modules && {modules.length} total} +
+ + {isLoading ? ( +
{[1,2,3].map(i => )}
+ ) : modules?.length === 0 ? ( + + + + } + title="No modules yet" + description="Modules are automatically created when you import an API document. You can also add manual modules above." + /> + ) : ( +
+ {modules?.map((m) => ( +
+
+ {m.name} + {m.source} +
+
+ {m._count.endpoints} endpoints + +
+
+ ))}
- ))} -
+ )} + + + setDeleteTarget(null)} + onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)} + title="Delete module" + description={`Delete "${deleteTarget?.name}"? This will also remove its ${deleteTarget?._count.endpoints ?? 0} endpoints.`} + confirmText="Delete" + variant="danger" + />
); } diff --git a/packages/web/src/pages/tabs/ProjectSettings.tsx b/packages/web/src/pages/tabs/ProjectSettings.tsx index ee421bd..0d086fb 100644 --- a/packages/web/src/pages/tabs/ProjectSettings.tsx +++ b/packages/web/src/pages/tabs/ProjectSettings.tsx @@ -2,18 +2,27 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; +import ConfirmDialog from '../../components/ConfirmDialog'; +import ReimportDialog from '../ReimportDialog'; -type Project = { id: string; name: string; description: string | null }; +type Project = { id: string; name: string; description: string | null; _count: { endpoints: number; modules: number } }; export default function ProjectSettings({ project }: { project: Project }) { const [name, setName] = useState(project.name); const [description, setDescription] = useState(project.description || ''); + const [showDelete, setShowDelete] = useState(false); + const [showReimport, setShowReimport] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); const navigate = useNavigate(); const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'PUT', body: JSON.stringify({ name, description: description || undefined }) }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project', project.id] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['project', project.id] }); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + }, }); const deleteMutation = useMutation({ @@ -21,20 +30,78 @@ export default function ProjectSettings({ project }: { project: Project }) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); }, }); + const handleReimportSuccess = () => { + queryClient.invalidateQueries({ queryKey: ['project', project.id] }); + queryClient.invalidateQueries({ queryKey: ['modules', project.id] }); + queryClient.invalidateQueries({ queryKey: ['endpoints', project.id] }); + setShowReimport(false); + }; + return ( -
-
-
- setName(e.target.value)} className="w-full px-3 py-2 border rounded-md" />
-
-