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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user