forked from 0xWheatyz/SPARC
feat(frontend): add React dashboard with TypeScript
Add modern React frontend to replace Streamlit dashboard: - Vite build system with TypeScript - Tailwind CSS for styling - Component structure in src/ - Production Dockerfile with nginx - Development server on port 5173 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor directories
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Debug logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SPARC Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# Handle React Router (SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "sparc-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.51.0",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"lucide-react": "^0.400.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.24.0",
|
||||||
|
"recharts": "^2.12.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.6.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^9.6.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"globals": "^15.8.0",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "~5.5.3",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vite": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { Login } from './pages/Login';
|
||||||
|
import { Register } from './pages/Register';
|
||||||
|
import { Analysis } from './pages/Analysis';
|
||||||
|
import { Batch } from './pages/Batch';
|
||||||
|
import { AnalyticsPage } from './pages/Analytics';
|
||||||
|
import { About } from './pages/About';
|
||||||
|
import { AdminUsers } from './pages/AdminUsers';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/analysis" element={<Analysis />} />
|
||||||
|
<Route path="/batch" element={<Batch />} />
|
||||||
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/about" element={<About />} />
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/users"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminUsers />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Default redirect */}
|
||||||
|
<Route path="/" element={<Navigate to="/analysis" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/analysis" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics } from '../types';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token management
|
||||||
|
let accessToken: string | null = localStorage.getItem('access_token');
|
||||||
|
let refreshToken: string | null = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
export const setTokens = (tokens: TokenResponse) => {
|
||||||
|
accessToken = tokens.access_token;
|
||||||
|
refreshToken = tokens.refresh_token;
|
||||||
|
localStorage.setItem('access_token', tokens.access_token);
|
||||||
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearTokens = () => {
|
||||||
|
accessToken = null;
|
||||||
|
refreshToken = null;
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAccessToken = () => accessToken;
|
||||||
|
|
||||||
|
// Request interceptor to add auth header
|
||||||
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor to handle token refresh
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry && refreshToken) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<TokenResponse>(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTokens(response.data);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`;
|
||||||
|
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch {
|
||||||
|
clearTokens();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authApi = {
|
||||||
|
register: async (email: string, password: string): Promise<User> => {
|
||||||
|
const response = await api.post<User>('/auth/register', { email, password });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email: string, password: string): Promise<TokenResponse> => {
|
||||||
|
const response = await api.post<TokenResponse>('/auth/login', { email, password });
|
||||||
|
setTokens(response.data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: async (): Promise<User> => {
|
||||||
|
const response = await api.get<User>('/auth/me');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
clearTokens();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analysis API
|
||||||
|
export const analysisApi = {
|
||||||
|
analyzeCompany: async (companyName: string): Promise<CompanyAnalysis> => {
|
||||||
|
const response = await api.get<CompanyAnalysis>(`/analyze/${encodeURIComponent(companyName)}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
analyzeBatch: async (companies: string[], maxWorkers = 3): Promise<BatchAnalysisResult> => {
|
||||||
|
const response = await api.post<BatchAnalysisResult>('/analyze/batch', {
|
||||||
|
companies,
|
||||||
|
max_workers: maxWorkers,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
analyzeBatchAsync: async (companies: string[], maxWorkers = 3): Promise<JobStatus> => {
|
||||||
|
const response = await api.post<JobStatus>('/analyze/batch/async', {
|
||||||
|
companies,
|
||||||
|
max_workers: maxWorkers,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getJobStatus: async (jobId: string): Promise<JobStatus> => {
|
||||||
|
const response = await api.get<JobStatus>(`/jobs/${jobId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
listJobs: async (status?: string, limit = 10): Promise<JobStatus[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status) params.append('status', status);
|
||||||
|
params.append('limit', limit.toString());
|
||||||
|
const response = await api.get<JobStatus[]>(`/jobs?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analytics API
|
||||||
|
export const analyticsApi = {
|
||||||
|
getAnalytics: async (days = 30): Promise<Analytics> => {
|
||||||
|
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export const adminApi = {
|
||||||
|
listUsers: async (limit = 100, offset = 0): Promise<User[]> => {
|
||||||
|
const response = await api.get<User[]>(`/admin/users?limit=${limit}&offset=${offset}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUserRole: async (userId: number, role: 'admin' | 'user'): Promise<User> => {
|
||||||
|
const response = await api.patch<User>(`/admin/users/${userId}/role`, { role });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (userId: number): Promise<void> => {
|
||||||
|
await api.delete(`/admin/users/${userId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { Search, Layers, BarChart3, Info, Users, LogOut } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
||||||
|
{ to: '/batch', icon: Layers, label: 'Batch' },
|
||||||
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
|
{ to: '/about', icon: Info, label: 'About' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
navItems.push({ to: '/admin/users', icon: Users, label: 'Users' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-bg-card/80 backdrop-blur-lg border-b border-primary/20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">⚡</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
|
SPARC
|
||||||
|
</h1>
|
||||||
|
<span className="text-xs text-text-secondary uppercase tracking-wider">
|
||||||
|
Semiconductor Patent Analytics
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-1 bg-bg-card/60 rounded-xl p-1 border border-primary/15">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-primary to-primary-dark text-white'
|
||||||
|
: 'text-text-secondary hover:text-text-primary hover:bg-bg-card-hover'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<div className="text-sm font-medium text-text-primary">{user?.email}</div>
|
||||||
|
<div className="text-xs text-text-secondary capitalize">{user?.role}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-text-secondary hover:text-error hover:bg-error/10 transition-all"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span className="hidden sm:inline">Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-bg-card/95 backdrop-blur-lg border-t border-primary/20 z-50">
|
||||||
|
<div className="flex justify-around py-2">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex flex-col items-center gap-1 px-3 py-2 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
isActive ? 'text-primary' : 'text-text-secondary'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-24 md:pb-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, isAdmin, isLoading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && !isAdmin) {
|
||||||
|
return <Navigate to="/analysis" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { authApi, getAccessToken } from '../api/client';
|
||||||
|
import type { User } from '../types';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await authApi.getMe();
|
||||||
|
setUser(userData);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
if (getAccessToken()) {
|
||||||
|
await refreshUser();
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
await authApi.login(email, password);
|
||||||
|
await refreshUser();
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string) => {
|
||||||
|
await authApi.register(email, password);
|
||||||
|
await authApi.login(email, password);
|
||||||
|
await refreshUser();
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
authApi.logout();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isAdmin: user?.role === 'admin',
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Search, FileText, Bot, Zap, Globe, BarChart3, CheckCircle, AlertTriangle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
export function About() {
|
||||||
|
const { data: health } = useQuery({
|
||||||
|
queryKey: ['health'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_BASE_URL}/health`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Search,
|
||||||
|
title: 'Patent Retrieval',
|
||||||
|
description: 'Automated collection via SerpAPI\'s Google Patents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: 'Intelligent Parsing',
|
||||||
|
description: 'Extracts key sections from patent documents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
title: 'AI Analysis',
|
||||||
|
description: 'Deep analysis powered by Claude 3.5 Sonnet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
title: 'Batch Processing',
|
||||||
|
description: 'Analyze multiple companies concurrently',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
title: 'REST API',
|
||||||
|
description: 'FastAPI web service for seamless integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BarChart3,
|
||||||
|
title: 'Analytics',
|
||||||
|
description: 'Track and visualize historical analysis data',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const techStack = [
|
||||||
|
{ label: 'Backend', value: 'Python, FastAPI' },
|
||||||
|
{ label: 'AI Model', value: 'Claude 3.5 Sonnet' },
|
||||||
|
{ label: 'Database', value: 'PostgreSQL' },
|
||||||
|
{ label: 'Frontend', value: 'React, TailwindCSS' },
|
||||||
|
{ label: 'Data Source', value: 'SerpAPI Patents' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
About SPARC
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-text-secondary leading-relaxed">
|
||||||
|
<strong className="text-text-primary">SPARC</strong> (Semiconductor Patent & Analytics Report Core)
|
||||||
|
is an AI-powered patent analysis platform that evaluates company performance by analyzing their
|
||||||
|
patent portfolios with cutting-edge language models.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Key Features</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{features.map(({ icon: Icon, title, description }) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="flex items-start gap-4 py-3 border-b border-primary/10 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Icon className="text-primary" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-text-primary">{title}</div>
|
||||||
|
<div className="text-sm text-text-secondary">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Tech Stack */}
|
||||||
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/5 border border-primary/20 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-4">Technology Stack</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{techStack.map(({ label, value }) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="text-primary text-sm">{label}</div>
|
||||||
|
<div className="text-text-secondary text-sm">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Endpoints */}
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-text-primary mb-4">API Endpoints</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<code className="block bg-bg-dark px-3 py-2 rounded text-sm text-text-secondary">
|
||||||
|
http://localhost:8000/docs
|
||||||
|
</code>
|
||||||
|
<code className="block bg-bg-dark px-3 py-2 rounded text-sm text-text-secondary">
|
||||||
|
http://localhost:8000/health
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Status */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||||
|
System Status
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<StatusCard
|
||||||
|
label="API"
|
||||||
|
status={health ? 'online' : 'offline'}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="Database"
|
||||||
|
status="configured"
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="Dashboard"
|
||||||
|
status="online"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ label, status }: { label: string; status: 'online' | 'offline' | 'configured' }) {
|
||||||
|
const statusConfig = {
|
||||||
|
online: { icon: CheckCircle, color: 'text-success', bg: 'bg-success' },
|
||||||
|
offline: { icon: XCircle, color: 'text-error', bg: 'bg-error' },
|
||||||
|
configured: { icon: AlertTriangle, color: 'text-warning', bg: 'bg-warning' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon: Icon, color, bg } = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-5 text-center">
|
||||||
|
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full ${bg}/20 mb-2`}>
|
||||||
|
<Icon className={color} size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-secondary uppercase tracking-wide">{label}</div>
|
||||||
|
<div className={`font-semibold ${color} capitalize`}>{status}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../api/client';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { Users, Shield, User, Trash2, AlertCircle } from 'lucide-react';
|
||||||
|
import type { User as UserType } from '../types';
|
||||||
|
|
||||||
|
export function AdminUsers() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: users, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: () => adminApi.listUsers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRoleMutation = useMutation({
|
||||||
|
mutationFn: ({ userId, role }: { userId: number; role: 'admin' | 'user' }) =>
|
||||||
|
adminApi.updateUserRole(userId, role),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (userId: number) => adminApi.deleteUser(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRoleChange = (user: UserType) => {
|
||||||
|
const newRole = user.role === 'admin' ? 'user' : 'admin';
|
||||||
|
updateRoleMutation.mutate({ userId: user.id, role: newRole });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (userId: number) => {
|
||||||
|
deleteMutation.mutate(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Failed to load users.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">Manage user accounts and permissions.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-xl px-4 py-2">
|
||||||
|
<Users size={18} className="text-primary" />
|
||||||
|
<span className="text-text-primary font-semibold">{users?.length || 0} Users</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-primary/10">
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-6 py-4 text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-primary/10">
|
||||||
|
{users?.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-bg-card-hover/50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary/20 to-secondary/20 flex items-center justify-center">
|
||||||
|
{user.role === 'admin' ? (
|
||||||
|
<Shield className="text-primary" size={18} />
|
||||||
|
) : (
|
||||||
|
<User className="text-secondary" size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-text-primary">{user.email}</div>
|
||||||
|
{user.id === currentUser?.id && (
|
||||||
|
<span className="text-xs text-primary">(You)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-semibold uppercase ${
|
||||||
|
user.role === 'admin'
|
||||||
|
? 'bg-primary/20 text-primary border border-primary/30'
|
||||||
|
: 'bg-secondary/20 text-secondary border border-secondary/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.role === 'admin' ? <Shield size={12} /> : <User size={12} />}
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-text-secondary">
|
||||||
|
{new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{user.id !== currentUser?.id && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRoleChange(user)}
|
||||||
|
disabled={updateRoleMutation.isPending}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
user.role === 'admin'
|
||||||
|
? 'bg-secondary/10 text-secondary hover:bg-secondary/20 border border-secondary/30'
|
||||||
|
: 'bg-primary/10 text-primary hover:bg-primary/20 border border-primary/30'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{deleteConfirm === user.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-error text-white hover:bg-error/80 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-bg-card-hover text-text-secondary hover:text-text-primary transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(user.id)}
|
||||||
|
className="p-1.5 rounded-lg text-error/70 hover:text-error hover:bg-error/10 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { analysisApi } from '../api/client';
|
||||||
|
import { Search, CheckCircle, AlertCircle, Clock, FileText } from 'lucide-react';
|
||||||
|
import type { CompanyAnalysis } from '../types';
|
||||||
|
|
||||||
|
export function Analysis() {
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [result, setResult] = useState<CompanyAnalysis | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (name: string) => analysisApi.analyzeCompany(name),
|
||||||
|
onSuccess: (data) => setResult(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (companyName.trim()) {
|
||||||
|
mutation.mutate(companyName.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Single Company Analysis
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
Analyze a company's patent portfolio using AI-powered insights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={companyName}
|
||||||
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
|
placeholder="Enter company name (e.g., nvidia, intel, amd)"
|
||||||
|
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending || !companyName.trim()}
|
||||||
|
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search size={18} />
|
||||||
|
Analyze
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{mutation.isError && (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Analysis failed. Please try again.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Success/Failure Status */}
|
||||||
|
{result.success ? (
|
||||||
|
<div className="flex items-center gap-2 bg-success/10 border border-success/20 text-success rounded-xl px-4 py-3">
|
||||||
|
<CheckCircle size={18} />
|
||||||
|
<span>Analysis complete for {result.company_name.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Analysis failed: {result.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
icon={FileText}
|
||||||
|
label="Patents Found"
|
||||||
|
value={result.patent_count.toString()}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
icon={CheckCircle}
|
||||||
|
label="Analysis Status"
|
||||||
|
value={result.success ? 'Complete' : 'Failed'}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Timestamp"
|
||||||
|
value={new Date(result.timestamp).toLocaleTimeString()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Content */}
|
||||||
|
{result.success && result.analysis && (
|
||||||
|
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||||
|
AI Analysis Results
|
||||||
|
</h3>
|
||||||
|
<div className="prose prose-invert max-w-none">
|
||||||
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||||
|
{result.analysis}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ icon: Icon, label, value }: { icon: typeof FileText; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-5 text-center">
|
||||||
|
<Icon className="mx-auto mb-2 text-primary" size={24} />
|
||||||
|
<div className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-secondary uppercase tracking-wide mt-1">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { analyticsApi } from '../api/client';
|
||||||
|
import { AlertCircle, Database } from 'lucide-react';
|
||||||
|
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
|
||||||
|
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['analytics', days],
|
||||||
|
queryFn: () => analyticsApi.getAnalytics(days),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Analytics Dashboard
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/5 border border-primary/20 rounded-xl p-6">
|
||||||
|
<div className="flex items-center gap-3 text-warning mb-2">
|
||||||
|
<Database size={24} />
|
||||||
|
<span className="font-semibold">Database Not Connected</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
Set <code className="bg-bg-card px-2 py-1 rounded">USE_DATABASE=true</code> in your .env file to enable analytics tracking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-secondary/10 border border-secondary/20 text-secondary rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Analytics features require storing analysis results in PostgreSQL for historical tracking.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || (data.total_messages === 0 && data.by_company.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Analytics Dashboard
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">Track historical analysis data and view insights.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-secondary/10 border border-secondary/20 text-secondary rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>No analytics data available yet. Run some analyses first!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyData = data.by_company.map((c) => ({
|
||||||
|
name: (c.company_name || 'Unknown').toUpperCase(),
|
||||||
|
value: c.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const typeData = data.by_type.map((t) => ({
|
||||||
|
name: t.analysis_type || 'Unknown',
|
||||||
|
count: t.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Analytics Dashboard
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">Track historical analysis data and view insights.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Range Selector */}
|
||||||
|
<select
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(Number(e.target.value))}
|
||||||
|
className="bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2 text-text-primary focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value={7}>Last 7 days</option>
|
||||||
|
<option value={14}>Last 14 days</option>
|
||||||
|
<option value={30}>Last 30 days</option>
|
||||||
|
<option value={90}>Last 90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Metrics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<MetricCard label="Total Analyses" value={data.total_messages} />
|
||||||
|
<MetricCard label="Companies Analyzed" value={data.by_company.length} />
|
||||||
|
<MetricCard label="Analysis Types" value={data.by_type.length} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Pie Chart - Distribution by Company */}
|
||||||
|
{companyData.length > 0 && (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Distribution by Company</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={companyData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{companyData.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bar Chart - Analysis Types */}
|
||||||
|
{typeData.length > 0 && (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Analysis Types</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={typeData}>
|
||||||
|
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
|
||||||
|
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-5 text-center">
|
||||||
|
<div className="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-secondary uppercase tracking-wide mt-1">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { analysisApi } from '../api/client';
|
||||||
|
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import type { BatchAnalysisResult } from '../types';
|
||||||
|
|
||||||
|
export function Batch() {
|
||||||
|
const [companiesInput, setCompaniesInput] = useState('');
|
||||||
|
const [maxWorkers, setMaxWorkers] = useState(3);
|
||||||
|
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) =>
|
||||||
|
analysisApi.analyzeBatch(companies, workers),
|
||||||
|
onSuccess: (data) => setResult(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const companies = companiesInput
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter((c) => c.length > 0);
|
||||||
|
|
||||||
|
if (companies.length > 0) {
|
||||||
|
mutation.mutate({ companies, workers: maxWorkers });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (company: string) => {
|
||||||
|
const newExpanded = new Set(expandedItems);
|
||||||
|
if (newExpanded.has(company)) {
|
||||||
|
newExpanded.delete(company);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(company);
|
||||||
|
}
|
||||||
|
setExpandedItems(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = result?.results.map((r) => ({
|
||||||
|
name: r.company_name.toUpperCase(),
|
||||||
|
patents: r.patent_count,
|
||||||
|
success: r.success,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Batch Company Analysis
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
Analyze multiple companies simultaneously for comparative insights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<textarea
|
||||||
|
value={companiesInput}
|
||||||
|
onChange={(e) => setCompaniesInput(e.target.value)}
|
||||||
|
placeholder="Enter company names (one per line or comma-separated): nvidia amd intel qualcomm"
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Concurrent Workers
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={maxWorkers}
|
||||||
|
onChange={(e) => setMaxWorkers(Number(e.target.value))}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
<div className="text-center text-text-primary font-semibold">{maxWorkers}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending || !companiesInput.trim()}
|
||||||
|
className="w-full bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Rocket size={18} />
|
||||||
|
Run Batch Analysis
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{mutation.isPending && (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-secondary">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-secondary"></div>
|
||||||
|
<span>Analyzing companies...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{mutation.isError && (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Batch analysis failed. Please try again.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Metrics */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||||
|
Results Summary
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<SummaryCard label="Total Companies" value={result.total_companies} />
|
||||||
|
<SummaryCard label="Successful" value={result.successful} color="success" />
|
||||||
|
<SummaryCard label="Failed" value={result.failed} color="error" />
|
||||||
|
<SummaryCard
|
||||||
|
label="Success Rate"
|
||||||
|
value={`${Math.round((result.successful / result.total_companies) * 100)}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{chartData && chartData.length > 0 && (
|
||||||
|
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
|
||||||
|
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={entry.success ? '#10b981' : '#ef4444'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detailed Results */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||||
|
Detailed Results
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.company_name}
|
||||||
|
className="bg-bg-card/60 border border-primary/15 rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(r.company_name)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-bg-card-hover transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{r.success ? (
|
||||||
|
<CheckCircle className="text-success" size={20} />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="text-error" size={20} />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-text-primary">
|
||||||
|
{r.company_name.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-secondary">
|
||||||
|
{r.patent_count} patents
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedItems.has(r.company_name) ? (
|
||||||
|
<ChevronUp className="text-text-secondary" size={20} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="text-text-secondary" size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expandedItems.has(r.company_name) && (
|
||||||
|
<div className="border-t border-primary/10 p-4 bg-bg-dark/40">
|
||||||
|
{r.success ? (
|
||||||
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||||
|
{r.analysis}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-error">{r.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
color?: 'success' | 'error';
|
||||||
|
}) {
|
||||||
|
const colorClass = color === 'success' ? 'text-success' : color === 'error' ? 'text-error' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-4 text-center">
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
colorClass || 'bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-secondary uppercase tracking-wide mt-1">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { LogIn, Mail, Lock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/analysis';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Invalid email or password');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-4">
|
||||||
|
<span className="text-4xl">⚡</span>
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
|
SPARC
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary">Semiconductor Patent Analytics Dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Card */}
|
||||||
|
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary mb-6">Sign in to your account</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-lg px-4 py-3 mb-6">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-bg-dark/80 border border-primary/30 rounded-xl pl-10 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-bg-dark/80 border border-primary/30 rounded-xl pl-10 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-4 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn size={18} />
|
||||||
|
Sign In
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<span className="text-text-secondary text-sm">Don't have an account? </span>
|
||||||
|
<Link to="/register" className="text-primary hover:text-primary-dark font-medium text-sm">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { UserPlus, Mail, Lock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Register() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { register } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(email, password);
|
||||||
|
navigate('/analysis', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-4">
|
||||||
|
<span className="text-4xl">⚡</span>
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
|
SPARC
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-secondary">Semiconductor Patent Analytics Dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Register Card */}
|
||||||
|
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary mb-6">Create your account</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-lg px-4 py-3 mb-6">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-bg-dark/80 border border-primary/30 rounded-xl pl-10 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full bg-bg-dark/80 border border-primary/30 rounded-xl pl-10 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-bg-dark/80 border border-primary/30 rounded-xl pl-10 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-4 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus size={18} />
|
||||||
|
Create Account
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<span className="text-text-secondary text-sm">Already have an account? </span>
|
||||||
|
<Link to="/login" className="text-primary hover:text-primary-dark font-medium text-sm">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-text-secondary">
|
||||||
|
The first registered user will automatically become an admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyAnalysis {
|
||||||
|
company_name: string;
|
||||||
|
analysis: string;
|
||||||
|
patent_count: number;
|
||||||
|
success: boolean;
|
||||||
|
error: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchAnalysisResult {
|
||||||
|
results: CompanyAnalysis[];
|
||||||
|
total_companies: number;
|
||||||
|
successful: number;
|
||||||
|
failed: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobStatus {
|
||||||
|
job_id: string;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
progress: number;
|
||||||
|
total_companies: number;
|
||||||
|
completed_companies: number;
|
||||||
|
result: BatchAnalysisResult | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Analytics {
|
||||||
|
total_messages: number;
|
||||||
|
by_company: Array<{ company_name: string; count: number }>;
|
||||||
|
by_type: Array<{ analysis_type: string; count: number }>;
|
||||||
|
period_days: number;
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#6366f1',
|
||||||
|
dark: '#4f46e5',
|
||||||
|
},
|
||||||
|
secondary: '#0ea5e9',
|
||||||
|
success: '#10b981',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
bg: {
|
||||||
|
dark: '#0f172a',
|
||||||
|
card: '#1e293b',
|
||||||
|
'card-hover': '#334155',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#f8fafc',
|
||||||
|
secondary: '#94a3b8',
|
||||||
|
},
|
||||||
|
border: '#334155',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user