deploy: security hardening, multi-model support, S3 storage, analytics, CI improvements (70 commits) #4
@@ -10,6 +10,7 @@ import { Batch } from './pages/Batch';
|
|||||||
import { AnalyticsPage } from './pages/Analytics';
|
import { AnalyticsPage } from './pages/Analytics';
|
||||||
import { About } from './pages/About';
|
import { About } from './pages/About';
|
||||||
import { AdminUsers } from './pages/AdminUsers';
|
import { AdminUsers } from './pages/AdminUsers';
|
||||||
|
import { Compare } from './pages/Compare';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -41,6 +42,7 @@ function App() {
|
|||||||
<Route path="/analysis" element={<Analysis />} />
|
<Route path="/analysis" element={<Analysis />} />
|
||||||
<Route path="/batch" element={<Batch />} />
|
<Route path="/batch" element={<Batch />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/compare" element={<Compare />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Search, Layers, BarChart3, Info, Users, LogOut } from 'lucide-react';
|
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows } from 'lucide-react';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
@@ -15,6 +15,7 @@ export function Layout() {
|
|||||||
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
||||||
{ to: '/batch', icon: Layers, label: 'Batch' },
|
{ to: '/batch', icon: Layers, label: 'Batch' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
|
{ to: '/compare', icon: GitCompareArrows, label: 'Compare' },
|
||||||
{ to: '/about', icon: Info, label: 'About' },
|
{ to: '/about', icon: Info, label: 'About' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user