feat: opt web ux

This commit is contained in:
2026-04-02 22:10:24 +08:00
parent 143b1e8c4b
commit 35511eb877
16 changed files with 1251 additions and 383 deletions

View File

@@ -6,7 +6,6 @@ import Modal from '../components/Modal';
type ImportResult = {
project: { id: string; name: string };
apiKey: string;
stats: { modules: number; endpoints: number };
};
@@ -18,7 +17,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
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();
@@ -60,14 +58,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
}
};
const copyKey = () => {
if (result?.apiKey) {
navigator.clipboard.writeText(result.apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Modal open onClose={onClose} size="md">
{!result ? (
@@ -149,19 +139,6 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
</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>
<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>

View File

@@ -1,12 +1,202 @@
import { useState } from 'react';
import { Navigate, Outlet, NavLink, Link, useLocation } from 'react-router-dom';
import { useState, useRef, useEffect } from 'react';
import { Navigate, Outlet, NavLink, Link, useLocation, useParams, useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../lib/auth';
import { apiFetch } from '../lib/api';
import ThemeToggle from '../components/ThemeToggle';
import SettingsDialog from '../components/SettingsDialog';
type LayoutContext = { onOpenSettings: () => void };
export function useLayoutContext() { return useOutletContext<LayoutContext>(); }
type ProjectSummary = {
id: string; name: string; description: string | null;
_count: { endpoints: number; modules: number };
};
function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2.5 px-2 py-1.5 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="hidden md:block text-left">
<div className="text-[13px] font-medium text-text-primary leading-tight">{user.name}</div>
</div>
<svg className="w-3.5 h-3.5 text-text-muted hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="user-dropdown">
{/* User info */}
<div className="px-3 py-2.5 border-b border-border-muted">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[11px] font-bold tracking-wide shrink-0">
{initials}
</div>
<div className="min-w-0">
<div className="text-[13px] font-medium text-text-primary truncate">{user.name}</div>
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
</div>
</div>
</div>
{/* Actions */}
<div className="py-1">
<button
onClick={() => { setOpen(false); onOpenSettings(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<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
</button>
<button
onClick={() => { setOpen(false); logout(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<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>
)}
</div>
);
}
function ProjectSidebar() {
const location = useLocation();
const params = useParams();
const activeProjectId = params.id;
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
});
const isProjectsRoot = location.pathname === '/';
return (
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
{/* Sidebar header */}
<div className="px-4 h-12 flex items-center justify-between border-b border-border-muted shrink-0">
<span className="section-label">Projects</span>
</div>
{/* Project list */}
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<NavLink
to="/"
end
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
isProjectsRoot
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`}
>
<svg className="w-[15px] h-[15px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
All Projects
</NavLink>
{projects && projects.length > 0 && (
<div className="border-t border-border-muted my-2!" />
)}
{isLoading && (
<div className="space-y-1.5 px-1">
{[1, 2, 3].map(i => (
<div key={i} className="h-8 rounded-lg skeleton" />
))}
</div>
)}
{projects?.map((p) => (
<NavLink
key={p.id}
to={`/projects/${p.id}`}
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] transition-all duration-150 group ${
activeProjectId === p.id
? 'bg-accent-muted text-accent font-medium'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`}
>
<svg className="w-[15px] h-[15px] shrink-0" 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>
<span className="truncate">{p.name}</span>
<span className="ml-auto text-[11px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{p._count.endpoints}
</span>
</NavLink>
))}
</nav>
</aside>
);
}
function OnboardingBanner({ onOpenSettings }: { onOpenSettings: () => void }) {
const [dismissed, setDismissed] = useState(() => localStorage.getItem('agent-fox-onboarding-dismissed') === 'true');
const { data: keyStatus } = useQuery({
queryKey: ['api-key-status'],
queryFn: () => apiFetch<{ hasKey: boolean }>('/auth/api-key/status'),
});
if (dismissed || keyStatus?.hasKey) return null;
// Still loading
if (!keyStatus) return null;
return (
<div className="mb-6 p-4 rounded-xl bg-accent-muted border border-accent/20 flex items-center gap-4 animate-fade-in">
<svg className="w-5 h-5 text-accent 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>
<div className="flex-1 min-w-0">
<p className="text-[13px] text-text-primary font-medium">Welcome! Generate an API key to start using MCP services.</p>
<p className="text-[12px] text-text-secondary mt-0.5">You'll need an API key to connect your LLM client to your projects.</p>
</div>
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5">
Generate API Key
</button>
<button
onClick={() => { setDismissed(true); localStorage.setItem('agent-fox-onboarding-dismissed', 'true'); }}
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors shrink-0"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
);
}
export default function Layout() {
const { user, loading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
if (loading) {
return (
@@ -17,112 +207,90 @@ export default function Layout() {
}
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-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>
{/* 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'
}`
}
<div className="h-screen bg-bg-secondary flex flex-col overflow-hidden">
{/* Top Header — fixed */}
<header className="h-14 border-b border-border-default bg-bg-sidebar flex items-center px-4 lg:px-5 shrink-0 z-30">
{/* Left: mobile menu + logo */}
<div className="flex items-center gap-3">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="lg:hidden p-1.5 -ml-1.5 text-text-secondary hover:text-text-primary rounded-md"
>
<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 />
<Link to="/" className="flex items-center gap-2.5">
<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>
</Link>
</div>
{/* Right: theme toggle + user */}
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
<div className="w-px h-5 bg-border-default mx-1" />
<UserDropdown user={user} logout={logout} onOpenSettings={() => setSettingsOpen(true)} />
</div>
</header>
{/* Body: sidebar + main — fills remaining height */}
<div className="flex-1 flex min-h-0">
{/* Mobile sidebar overlay */}
{mobileMenuOpen && (
<div className="fixed inset-0 z-40 bg-overlay lg:hidden" onClick={() => setMobileMenuOpen(false)} />
)}
{/* Mobile sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 w-[260px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:hidden ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<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>
<nav className="flex-1 overflow-y-auto px-2.5 py-3 space-y-0.5">
<NavLink
to="/"
end
onClick={() => setMobileMenuOpen(false)}
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="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>
</nav>
</aside>
{/* Desktop project sidebar — stays fixed, has its own scroll */}
<ProjectSidebar />
{/* Main content — only this area scrolls */}
<main className="flex-1 overflow-y-auto min-w-0">
<div className="p-5 lg:p-8 animate-fade-in">
<OnboardingBanner onOpenSettings={() => setSettingsOpen(true)} />
<Outlet context={{ onOpenSettings: () => setSettingsOpen(true) } satisfies LayoutContext} />
</div>
</main>
</div>
{/* Settings dialog */}
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
}

View File

@@ -17,9 +17,9 @@ type ProjectData = {
};
const tabs = [
{ 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: '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;
@@ -27,7 +27,7 @@ type TabKey = (typeof tabs)[number]['key'];
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<TabKey>('docs');
const [activeTab, setActiveTab] = useState<TabKey>('mcp');
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],

View File

@@ -1,147 +0,0 @@
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

@@ -4,6 +4,7 @@ import { apiFetch } from '../../lib/api';
import Badge from '../../components/Badge';
import Skeleton from '../../components/Skeleton';
import EmptyState from '../../components/EmptyState';
import { ParametersView, RequestBodyView, ResponsesView } from '../../components/SchemaView';
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 } };
@@ -36,10 +37,10 @@ export default function DocPreview({ projectId }: { projectId: string }) {
const totalEndpoints = modules?.reduce((sum, m) => sum + m._count.endpoints, 0) || 0;
return (
<div className="flex gap-6 min-h-[400px]">
<div className="flex gap-6 h-[calc(100vh-280px)] min-h-[400px]">
{/* Module sidebar */}
<div className="w-52 shrink-0">
<div className="sticky top-0">
<div className="w-52 shrink-0 overflow-y-auto">
<div>
<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>
@@ -71,7 +72,7 @@ export default function DocPreview({ projectId }: { projectId: string }) {
</div>
{/* Endpoints */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 overflow-y-auto">
{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 ? (
@@ -107,24 +108,9 @@ export default function DocPreview({ projectId }: { projectId: string }) {
<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>
)}
<ParametersView parameters={endpointDetail.parameters} />
<RequestBodyView requestBody={endpointDetail.requestBody} />
<ResponsesView responses={endpointDetail.responses} />
</div>
)}
</div>

View File

@@ -1,20 +1,19 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import ConfirmDialog from '../../components/ConfirmDialog';
import { useLayoutContext } from '../Layout';
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 { onOpenSettings } = useLayoutContext();
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); setShowRotateConfirm(false); },
const { data: keyStatus } = useQuery({
queryKey: ['api-key-status'],
queryFn: () => apiFetch<{ hasKey: boolean; prefix: string | null }>('/auth/api-key/status'),
});
const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
@@ -23,7 +22,7 @@ export default function McpIntegration({ project }: { project: Project }) {
[serverName]: {
type: 'http',
url: mcpUrl,
headers: { Authorization: `Bearer ${apiKey || '<your-api-key>'}` },
headers: { Authorization: 'Bearer <your-api-key>' },
},
},
}, null, 2);
@@ -52,37 +51,6 @@ export default function McpIntegration({ project }: { project: Project }) {
</div>
</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-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>
) : (
<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={() => 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>
@@ -93,6 +61,30 @@ export default function McpIntegration({ project }: { project: Project }) {
{copied === 'config' ? 'Copied!' : 'Copy'}
</button>
</div>
{/* API Key guidance */}
{keyStatus && (
<div className="mt-3">
{keyStatus.hasKey ? (
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
<svg className="w-4 h-4 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
<p className="text-[13px] text-text-secondary">
API key generated. Copy it from{' '}
<button onClick={onOpenSettings} className="text-accent hover:underline font-medium">Settings</button>
{' '}and replace <code className="text-xs font-mono bg-bg-inset px-1 py-0.5 rounded">&lt;your-api-key&gt;</code> above.
</p>
</div>
) : (
<div className="flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
<svg className="w-4 h-4 text-warning shrink-0" 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-[13px] text-text-secondary flex-1">You need to generate an API key before using MCP.</p>
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
Open Settings
</button>
</div>
)}
</div>
)}
</section>
{/* Available tools */}
@@ -117,16 +109,6 @@ export default function McpIntegration({ project }: { project: Project }) {
))}
</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>
);
}