forked from 0xWheatyz/SPARC
merge: resolve openapi-client-gen conflicts with CI typecheck script
Keeps both generate scripts and typecheck script in package.json.
This commit is contained in:
@@ -7,6 +7,15 @@
|
||||
<title>SPARC Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Prevent FOUC: apply saved theme before first render
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"axios": "^1.7.2",
|
||||
"lucide-react": "^0.400.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
@@ -3452,9 +3452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.400.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz",
|
||||
"integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
"lint": "eslint .",
|
||||
"generate": "openapi-typescript http://localhost:8000/api/openapi.json -o src/api/schema.d.ts",
|
||||
"generate:local": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"axios": "^1.7.2",
|
||||
"lucide-react": "^0.400.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { Layout } from './components/Layout';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { Login } from './pages/Login';
|
||||
@@ -10,6 +11,7 @@ import { Batch } from './pages/Batch';
|
||||
import { AnalyticsPage } from './pages/Analytics';
|
||||
import { About } from './pages/About';
|
||||
import { AdminUsers } from './pages/AdminUsers';
|
||||
import { Compare } from './pages/Compare';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -22,6 +24,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
@@ -41,6 +44,7 @@ function App() {
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
<Route path="/batch" element={<Batch />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
@@ -61,6 +65,7 @@ function App() {
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,12 +126,40 @@ export const analysisApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Export API
|
||||
export const exportApi = {
|
||||
exportCsv: async (companyName: string): Promise<void> => {
|
||||
const response = await api.get(`/export/${encodeURIComponent(companyName)}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `sparc_${companyName.toLowerCase().replace(/\s+/g, '_')}_export.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
// Analytics API
|
||||
export interface TrendData {
|
||||
by_month: Array<{ month: string; company_name: string; count: number }>;
|
||||
by_type_over_time: Array<{ month: string; analysis_type: string; count: number }>;
|
||||
period_days: number;
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getAnalytics: async (days = 30): Promise<Analytics> => {
|
||||
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrends: async (days = 90): Promise<TrendData> => {
|
||||
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Admin API
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Search, Layers, BarChart3, Info, Users, LogOut } from 'lucide-react';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react';
|
||||
|
||||
export function Layout() {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -15,6 +17,7 @@ export function Layout() {
|
||||
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
||||
{ to: '/batch', icon: Layers, label: 'Batch' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/compare', icon: GitCompareArrows, label: 'Compare' },
|
||||
{ to: '/about', icon: Info, label: 'About' },
|
||||
];
|
||||
|
||||
@@ -23,7 +26,7 @@ export function Layout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-indigo-950">
|
||||
<div className="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 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">
|
||||
@@ -63,6 +66,13 @@ export function Layout() {
|
||||
|
||||
{/* User menu */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-card-hover transition-all"
|
||||
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRout
|
||||
|
||||
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="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
+22
-2
@@ -2,6 +2,26 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Light mode (default) */
|
||||
:root {
|
||||
--color-bg-dark: #f1f5f9;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-bg-card-hover: #e2e8f0;
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-border: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark {
|
||||
--color-bg-dark: #0f172a;
|
||||
--color-bg-card: #1e293b;
|
||||
--color-bg-card-hover: #334155;
|
||||
--color-text-primary: #f8fafc;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-border: #334155;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -15,7 +35,7 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@@ -30,5 +50,5 @@ body {
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
color: #f8fafc;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { analysisApi, exportApi } from '../api/client';
|
||||
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download } from 'lucide-react';
|
||||
import type { CompanyAnalysis } from '../types';
|
||||
|
||||
export function Analysis() {
|
||||
@@ -106,9 +106,18 @@ export function Analysis() {
|
||||
{/* 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="flex items-center justify-between border-b-2 border-primary/30 pb-2 mb-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
AI Analysis Results
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => exportApi.exportCsv(result.company_name)}
|
||||
className="flex items-center gap-2 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Download size={14} />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
||||
{result.analysis}
|
||||
|
||||
@@ -2,22 +2,50 @@ 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';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, 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({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['analytics', days],
|
||||
queryFn: () => analyticsApi.getAnalytics(days),
|
||||
});
|
||||
|
||||
const trendsQuery = useQuery({
|
||||
queryKey: ['analytics-trends', days],
|
||||
queryFn: () => analyticsApi.getTrends(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 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">Loading analytics data...</p>
|
||||
</div>
|
||||
{/* Skeleton cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-gradient-to-br from-primary/10 to-secondary/10 border border-primary/20 rounded-xl p-5 text-center animate-pulse">
|
||||
<div className="h-9 w-16 bg-primary/20 rounded mx-auto mb-2" />
|
||||
<div className="h-4 w-24 bg-primary/10 rounded mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Skeleton charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 animate-pulse">
|
||||
<div className="h-5 w-40 bg-primary/20 rounded mb-4" />
|
||||
<div className="h-[300px] bg-primary/5 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,15 +61,18 @@ export function AnalyticsPage() {
|
||||
<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>
|
||||
<span className="font-semibold">Unable to Load Analytics</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.
|
||||
Could not connect to the analytics database. Ensure PostgreSQL is running and
|
||||
<code className="bg-bg-card px-2 py-1 rounded mx-1">DATABASE_URL</code> is configured correctly.
|
||||
</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>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 text-sm bg-primary/20 hover:bg-primary/30 text-primary font-medium px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -163,6 +194,114 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend Charts */}
|
||||
{trendsQuery.data && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||
Trends Over Time
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Patent count over time per company (line chart) */}
|
||||
{trendsQuery.data.by_month.length > 0 && (() => {
|
||||
// Pivot data: each month as a row, companies as columns
|
||||
const companies = [...new Set(trendsQuery.data!.by_month.map(d => d.company_name))];
|
||||
const months = [...new Set(trendsQuery.data!.by_month.map(d => d.month))].sort();
|
||||
const pivoted = months.map(month => {
|
||||
const row: Record<string, string | number> = { month };
|
||||
for (const c of companies) {
|
||||
const entry = trendsQuery.data!.by_month.find(d => d.month === month && d.company_name === c);
|
||||
row[c] = entry?.count || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={pivoted}>
|
||||
<XAxis dataKey="month" 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' }}
|
||||
/>
|
||||
<Legend />
|
||||
{companies.map((company, idx) => (
|
||||
<Line
|
||||
key={company}
|
||||
type="monotone"
|
||||
dataKey={company}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
name={company.toUpperCase()}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Analysis type distribution over time (stacked bar) */}
|
||||
{trendsQuery.data.by_type_over_time.length > 0 && (() => {
|
||||
const types = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.analysis_type))];
|
||||
const months = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.month))].sort();
|
||||
const pivoted = months.map(month => {
|
||||
const row: Record<string, string | number> = { month };
|
||||
for (const t of types) {
|
||||
const entry = trendsQuery.data!.by_type_over_time.find(d => d.month === month && d.analysis_type === t);
|
||||
row[t] = entry?.count || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={pivoted}>
|
||||
<XAxis dataKey="month" 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' }}
|
||||
/>
|
||||
<Legend />
|
||||
{types.map((type, idx) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="types"
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
name={type}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{trendsQuery.data.by_month.length === 0 && (
|
||||
<div className="text-text-secondary text-center py-8">
|
||||
No trend data available yet. Run analyses over multiple days to see trends.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,9 +114,21 @@ export function Batch() {
|
||||
|
||||
{/* 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 className="bg-error/10 border border-error/20 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<AlertCircle size={18} />
|
||||
<span className="font-semibold">Batch analysis failed</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm mt-1 ml-7">
|
||||
{mutation.error instanceof Error ? mutation.error.message : 'An unexpected error occurred.'}
|
||||
{' '}Check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => mutation.reset()}
|
||||
className="ml-7 mt-2 text-sm text-primary hover:text-primary-dark underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analysisApi } from '../api/client';
|
||||
import { GitCompareArrows, AlertCircle, FileText, Clock } from 'lucide-react';
|
||||
import type { CompanyAnalysis } from '../types';
|
||||
|
||||
function CompanyPanel({ data, isLoading, isError }: { data?: CompanyAnalysis; isLoading: boolean; isError: boolean }) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 animate-pulse">
|
||||
<div className="h-6 w-32 bg-primary/20 rounded mb-4" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-primary/10 rounded w-full" />
|
||||
<div className="h-4 bg-primary/10 rounded w-3/4" />
|
||||
<div className="h-4 bg-primary/10 rounded w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="bg-error/10 border border-error/20 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<AlertCircle size={18} />
|
||||
<span>Failed to load analysis. Check the company name and try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="text-lg font-bold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||
{data.company_name.toUpperCase()}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-3 text-center">
|
||||
<FileText className="mx-auto mb-1 text-primary" size={18} />
|
||||
<div className="text-xl font-bold text-text-primary">{data.patent_count}</div>
|
||||
<div className="text-xs text-text-secondary uppercase">Patents</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 rounded-lg p-3 text-center">
|
||||
<Clock className="mx-auto mb-1 text-primary" size={18} />
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{new Date(data.timestamp).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary uppercase">Analyzed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.success && data.analysis ? (
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed text-sm">
|
||||
{data.analysis}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-error text-sm">{data.error || 'Analysis not available'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Compare() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [companyA, setCompanyA] = useState(searchParams.get('a') || '');
|
||||
const [companyB, setCompanyB] = useState(searchParams.get('b') || '');
|
||||
|
||||
const queryA = searchParams.get('a') || '';
|
||||
const queryB = searchParams.get('b') || '';
|
||||
|
||||
const resultA = useQuery({
|
||||
queryKey: ['analyze', queryA],
|
||||
queryFn: () => analysisApi.analyzeCompany(queryA),
|
||||
enabled: !!queryA,
|
||||
});
|
||||
|
||||
const resultB = useQuery({
|
||||
queryKey: ['analyze', queryB],
|
||||
queryFn: () => analysisApi.analyzeCompany(queryB),
|
||||
enabled: !!queryB,
|
||||
});
|
||||
|
||||
const handleCompare = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const a = companyA.trim();
|
||||
const b = companyB.trim();
|
||||
if (a && b) {
|
||||
setSearchParams({ a, b });
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
Portfolio Comparison
|
||||
</h2>
|
||||
<p className="text-text-secondary">
|
||||
Compare patent portfolios of two companies side by side.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input Form */}
|
||||
<form onSubmit={handleCompare} className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Company A</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyA}
|
||||
onChange={(e) => setCompanyA(e.target.value)}
|
||||
placeholder="e.g. nvidia"
|
||||
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Company B</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyB}
|
||||
onChange={(e) => setCompanyB(e.target.value)}
|
||||
placeholder="e.g. intel"
|
||||
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 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={!companyA.trim() || !companyB.trim() || resultA.isLoading || resultB.isLoading}
|
||||
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-2.5 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"
|
||||
>
|
||||
<GitCompareArrows size={18} />
|
||||
Compare
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Comparison Panels */}
|
||||
{(queryA || queryB) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{queryA && (
|
||||
<CompanyPanel
|
||||
data={resultA.data}
|
||||
isLoading={resultA.isLoading}
|
||||
isError={resultA.isError}
|
||||
/>
|
||||
)}
|
||||
{queryB && (
|
||||
<CompanyPanel
|
||||
data={resultB.data}
|
||||
isLoading={resultB.isLoading}
|
||||
isError={resultB.isError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function Login() {
|
||||
};
|
||||
|
||||
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="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 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">
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Register() {
|
||||
};
|
||||
|
||||
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="min-h-screen bg-gradient-to-br from-bg-dark to-slate-100 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">
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@@ -16,15 +17,15 @@ export default {
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
bg: {
|
||||
dark: '#0f172a',
|
||||
card: '#1e293b',
|
||||
'card-hover': '#334155',
|
||||
dark: 'var(--color-bg-dark)',
|
||||
card: 'var(--color-bg-card)',
|
||||
'card-hover': 'var(--color-bg-card-hover)',
|
||||
},
|
||||
text: {
|
||||
primary: '#f8fafc',
|
||||
secondary: '#94a3b8',
|
||||
primary: 'var(--color-text-primary)',
|
||||
secondary: 'var(--color-text-secondary)',
|
||||
},
|
||||
border: '#334155',
|
||||
border: 'var(--color-border)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user