feat: add React frontend with auth, project list, import, and project detail pages
Converts packages/web from vanilla TypeScript Vite scaffold to React with: - React 19, react-router-dom v7, @tanstack/react-query v5, Tailwind CSS v4 - JWT auth context with auto-refresh token support - Login/Register pages, protected Layout with auth guard - Projects list with grid cards and delete action - ImportDialog supporting URL or file upload with API key display - ProjectDetail with 4 tabs: Documentation, Modules, MCP Integration, Settings - All TypeScript compiles cleanly (noEmit check passes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,10 @@
|
||||
<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" />
|
||||
<title>web</title>
|
||||
<title>Agent Fox</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "@agent-fox/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -9,7 +9,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/web/src/App.tsx
Normal file
30
packages/web/src/App.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './lib/auth';
|
||||
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';
|
||||
|
||||
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>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,9 +0,0 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
1
packages/web/src/index.css
Normal file
1
packages/web/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
74
packages/web/src/lib/api.ts
Normal file
74
packages/web/src/lib/api.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
let accessToken: string | null = localStorage.getItem('accessToken');
|
||||
let refreshToken: string | null = localStorage.getItem('refreshToken');
|
||||
|
||||
export function setTokens(access: string, refresh: string) {
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
localStorage.setItem('accessToken', access);
|
||||
localStorage.setItem('refreshToken', refresh);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json();
|
||||
if (json.success && json.data) {
|
||||
setTokens(json.data.accessToken, json.data.refreshToken);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401 && refreshToken) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
}
|
||||
}
|
||||
|
||||
const json: ApiResponse<T> = await res.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message || 'Request failed');
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
62
packages/web/src/lib/auth.tsx
Normal file
62
packages/web/src/lib/auth.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
|
||||
|
||||
type User = { id: string; email: string; name: string };
|
||||
|
||||
type AuthContextType = {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (getAccessToken()) {
|
||||
apiFetch<User>('/auth/me')
|
||||
.then(setUser)
|
||||
.catch(() => clearTokens())
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
|
||||
'/auth/login',
|
||||
{ method: 'POST', body: JSON.stringify({ email, password }) },
|
||||
);
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
setUser(data.user);
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name: string) => {
|
||||
const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>(
|
||||
'/auth/register',
|
||||
{ method: 'POST', body: JSON.stringify({ email, password, name }) },
|
||||
);
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
setUser(data.user);
|
||||
};
|
||||
|
||||
const logout = () => { clearTokens(); setUser(null); };
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import './style.css'
|
||||
import typescriptLogo from './assets/typescript.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import { setupCounter } from './counter.ts'
|
||||
|
||||
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img src="${heroImg}" class="base" width="170" height="179">
|
||||
<img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
|
||||
<img src=${viteLogo} class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button id="counter" type="button" class="counter"></button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" src=${viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.typescriptlang.org" target="_blank">
|
||||
<img class="button-icon" src="${typescriptLogo}" alt="">
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
|
||||
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
|
||||
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
|
||||
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
`
|
||||
|
||||
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
|
||||
10
packages/web/src/main.tsx
Normal file
10
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
96
packages/web/src/pages/ImportDialog.tsx
Normal file
96
packages/web/src/pages/ImportDialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
type ImportResult = {
|
||||
project: { id: string; name: string };
|
||||
apiKey: string;
|
||||
stats: { modules: number; endpoints: number };
|
||||
};
|
||||
|
||||
export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
const [mode, setMode] = useState<'url' | 'file'>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileContent, setFileContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setFileContent(reader.result as string);
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleImport = 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<ImportResult>('/projects', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
});
|
||||
setResult(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</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'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
22
packages/web/src/pages/Layout.tsx
Normal file
22
packages/web/src/pages/Layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
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>
|
||||
</header>
|
||||
<main className="p-6"><Outlet /></main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
packages/web/src/pages/Login.tsx
Normal file
39
packages/web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
packages/web/src/pages/ProjectDetail.tsx
Normal file
58
packages/web/src/pages/ProjectDetail.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import DocPreview from './tabs/DocPreview';
|
||||
import ModuleManagement from './tabs/ModuleManagement';
|
||||
import McpIntegration from './tabs/McpIntegration';
|
||||
import ProjectSettings from './tabs/ProjectSettings';
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
const tabs = ['Documentation', 'Modules', 'MCP Integration', 'Settings'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Documentation');
|
||||
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4"><Link to="/" className="text-sm text-blue-600 hover:underline">← Back to projects</Link></div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{project.name}</h2>
|
||||
{project.description && <p className="text-sm text-gray-500 mt-1">{project.description}</p>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">OpenAPI {project.openApiVersion} · {project._count.endpoints} endpoints</div>
|
||||
</div>
|
||||
<div className="border-b mb-6">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'Documentation' && <DocPreview projectId={project.id} />}
|
||||
{activeTab === 'Modules' && <ModuleManagement projectId={project.id} />}
|
||||
{activeTab === 'MCP Integration' && <McpIntegration project={project} />}
|
||||
{activeTab === 'Settings' && <ProjectSettings project={project} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
packages/web/src/pages/Projects.tsx
Normal file
55
packages/web/src/pages/Projects.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ImportDialog from './ImportDialog';
|
||||
|
||||
type ProjectSummary = {
|
||||
id: string; name: string; description: string | null; openApiVersion: string;
|
||||
updatedAt: string; _count: { endpoints: number; modules: number };
|
||||
};
|
||||
|
||||
export default function Projects() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/projects/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
|
||||
});
|
||||
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
{showImport && <ImportDialog onClose={() => setShowImport(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
packages/web/src/pages/Register.tsx
Normal file
41
packages/web/src/pages/Register.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await register(email, password, name);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
packages/web/src/pages/tabs/DocPreview.tsx
Normal file
78
packages/web/src/pages/tabs/DocPreview.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
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({
|
||||
queryKey: ['modules', projectId],
|
||||
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints', projectId, selectedModule],
|
||||
queryFn: () => apiFetch<EndpointSummary[]>(`/projects/${projectId}/endpoints${selectedModule ? `?moduleId=${selectedModule}` : ''}`),
|
||||
});
|
||||
|
||||
const { data: endpointDetail } = useQuery({
|
||||
queryKey: ['endpoint-detail', projectId, expandedEndpoint],
|
||||
queryFn: () => apiFetch<EndpointFull>(`/projects/${projectId}/endpoints/${expandedEndpoint}`),
|
||||
enabled: !!expandedEndpoint,
|
||||
});
|
||||
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
packages/web/src/pages/tabs/McpIntegration.tsx
Normal file
74
packages/web/src/pages/tabs/McpIntegration.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Project = { id: string; name: string };
|
||||
|
||||
export default function McpIntegration({ project }: { project: Project }) {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const mcpBaseUrl = window.location.origin;
|
||||
const mcpUrl = `${mcpBaseUrl}/mcp/${project.id}`;
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: () => apiFetch<{ apiKey: string }>(`/projects/${project.id}/api-key/rotate`, { method: 'POST' }),
|
||||
onSuccess: (data) => setApiKey(data.apiKey),
|
||||
});
|
||||
|
||||
const configSnippet = JSON.stringify({
|
||||
mcpServers: {
|
||||
[project.name.toLowerCase().replace(/\s+/g, '-')]: {
|
||||
url: mcpUrl,
|
||||
headers: { Authorization: `Bearer ${apiKey || '<your-api-key>'}` },
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">MCP Service URL</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">API Key</h3>
|
||||
{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>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">API key is hidden. Rotate to generate a new one.</p>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Available Tools</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
{ 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.' },
|
||||
].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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
packages/web/src/pages/tabs/ModuleManagement.tsx
Normal file
48
packages/web/src/pages/tabs/ModuleManagement.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
const { data: modules } = useQuery({
|
||||
queryKey: ['modules', projectId],
|
||||
queryFn: () => apiFetch<Module[]>(`/projects/${projectId}/modules`),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => apiFetch(`/projects/${projectId}/modules`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['modules', projectId] }); setNewModuleName(''); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (moduleId: string) => apiFetch(`/projects/${projectId}/modules/${moduleId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', projectId] }),
|
||||
});
|
||||
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
packages/web/src/pages/tabs/ProjectSettings.tsx
Normal file
40
packages/web/src/pages/tabs/ProjectSettings.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Project = { id: string; name: string; description: string | null };
|
||||
|
||||
export default function ProjectSettings({ project }: { project: Project }) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description || '');
|
||||
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] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/projects/${project.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); navigate('/'); },
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
13
packages/web/vite.config.ts
Normal file
13
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user