feat: UI polish - toast notifications, skeletons, empty states, error boundary

- Replace emoji icons with Lucide icons throughout
- Add custom toast notification system (replaces alert/confirm)
- Add ConfirmModal for delete confirmations
- Add loading skeleton cards instead of basic spinner
- Add empty states for documents, analytics sections
- Add ErrorBoundary wrapping Dashboard
- Extract TabButton component from repeated inline pattern
- Fix color tokens (raw red/blue -> error/primary design tokens)
- Add CSS fade transition on tab changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
admin
2026-02-25 11:18:05 -05:00
parent 00c156b4fd
commit 4a25e551ce
12 changed files with 590 additions and 136 deletions

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ToastProvider, useToast } from './contexts/ToastContext';
import LoginForm from './components/LoginForm';
import ProtectedRoute from './components/ProtectedRoute';
import DocumentUpload from './components/DocumentUpload';
@@ -10,6 +11,11 @@ import Analytics from './components/Analytics';
import AlertBanner from './components/AlertBanner';
import AdminMonitoringDashboard from './components/AdminMonitoringDashboard';
import LogoutButton from './components/LogoutButton';
import ErrorBoundary from './components/ErrorBoundary';
import ConfirmModal from './components/ConfirmModal';
import TabButton from './components/TabButton';
import { DocumentSkeletonList } from './components/Skeleton';
import EmptyState from './components/EmptyState';
import { documentService, GCSErrorHandler, GCSError } from './services/documentService';
import { adminService, AlertEvent } from './services/adminService';
// import { debugAuth, testAPIAuth } from './utils/authDebug';
@@ -22,7 +28,9 @@ import {
Plus,
Search,
TrendingUp,
Activity
Activity,
AlertTriangle,
RefreshCw
} from 'lucide-react';
import { cn } from './utils/cn';
import bluepointLogo from './assets/bluepoint-logo.png';
@@ -30,11 +38,15 @@ import bluepointLogo from './assets/bluepoint-logo.png';
// Dashboard component
const Dashboard: React.FC = () => {
const { user, token } = useAuth();
const toast = useToast();
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [viewingDocument, setViewingDocument] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics' | 'monitoring'>('overview');
const [prevTab, setPrevTab] = useState<string>('overview');
const [tabTransition, setTabTransition] = useState(true);
const [confirmModal, setConfirmModal] = useState<{ open: boolean; documentId: string }>({ open: false, documentId: '' });
// Check if user is admin
const isAdmin = adminService.isAdmin(user?.email);
@@ -47,6 +59,18 @@ const Dashboard: React.FC = () => {
}
}, [isAdmin]);
// Tab transition effect
useEffect(() => {
if (activeTab !== prevTab) {
setTabTransition(false);
const timer = setTimeout(() => {
setTabTransition(true);
setPrevTab(activeTab);
}, 50);
return () => clearTimeout(timer);
}
}, [activeTab, prevTab]);
const handleAcknowledge = async (id: string) => {
setActiveAlerts(prev => prev.filter(a => a.id !== id));
try {
@@ -298,32 +322,28 @@ const Dashboard: React.FC = () => {
// Handle GCS-specific errors
if (GCSErrorHandler.isGCSError(error)) {
const gcsError = error as GCSError;
alert(`Download failed: ${GCSErrorHandler.getErrorMessage(gcsError)}`);
toast.error(`Download failed: ${GCSErrorHandler.getErrorMessage(gcsError)}`);
} else {
alert('Failed to download document. Please try again.');
toast.error('Failed to download document. Please try again.');
}
}
};
const handleDeleteDocument = async (documentId: string) => {
// Show confirmation dialog
const confirmed = window.confirm('Are you sure you want to delete this document? This action cannot be undone.');
if (!confirmed) {
return;
}
setConfirmModal({ open: true, documentId });
};
const handleConfirmDelete = async () => {
const documentId = confirmModal.documentId;
setConfirmModal({ open: false, documentId: '' });
try {
// Call the backend API to delete the document
await documentService.deleteDocument(documentId);
// Remove from local state
setDocuments(prev => prev.filter(doc => doc.id !== documentId));
// Show success message
alert('Document deleted successfully');
toast.success('Document deleted successfully');
} catch (error) {
console.error('Failed to delete document:', error);
alert('Failed to delete document. Please try again.');
toast.error('Failed to delete document. Please try again.');
}
};
@@ -456,68 +476,13 @@ const Dashboard: React.FC = () => {
<div className="bg-white shadow-soft border-b border-gray-200 mb-6">
<div className="px-4 sm:px-6 lg:px-8">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'overview'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Home className="h-4 w-4 mr-2" />
Overview
</button>
<button
onClick={() => setActiveTab('documents')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'documents'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<FileText className="h-4 w-4 mr-2" />
Documents
</button>
<button
onClick={() => setActiveTab('upload')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'upload'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Upload className="h-4 w-4 mr-2" />
Upload
</button>
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')} icon={<Home className="h-4 w-4" />} label="Overview" />
<TabButton active={activeTab === 'documents'} onClick={() => setActiveTab('documents')} icon={<FileText className="h-4 w-4" />} label="Documents" />
<TabButton active={activeTab === 'upload'} onClick={() => setActiveTab('upload')} icon={<Upload className="h-4 w-4" />} label="Upload" />
{isAdmin && (
<>
<button
onClick={() => setActiveTab('analytics')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'analytics'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<TrendingUp className="h-4 w-4 mr-2" />
Analytics
</button>
<button
onClick={() => setActiveTab('monitoring')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'monitoring'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Activity className="h-4 w-4 mr-2" />
Monitoring
</button>
<TabButton active={activeTab === 'analytics'} onClick={() => setActiveTab('analytics')} icon={<TrendingUp className="h-4 w-4" />} label="Analytics" />
<TabButton active={activeTab === 'monitoring'} onClick={() => setActiveTab('monitoring')} icon={<Activity className="h-4 w-4" />} label="Monitoring" />
</>
)}
</nav>
@@ -525,7 +490,7 @@ const Dashboard: React.FC = () => {
</div>
{/* Content */}
<div className="px-4 sm:px-0">
<div className={cn('px-4 sm:px-0 transition-opacity duration-200', tabTransition ? 'opacity-100' : 'opacity-0')}>
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Stats Cards */}
@@ -594,7 +559,7 @@ const Dashboard: React.FC = () => {
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-6 w-6 text-error-500"></div>
<AlertTriangle className="h-6 w-6 text-error-500" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
@@ -660,7 +625,7 @@ const Dashboard: React.FC = () => {
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-soft text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 transition-colors duration-200"
>
<div className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}>🔄</div>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
<button
@@ -676,9 +641,16 @@ const Dashboard: React.FC = () => {
{/* Documents List */}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500 mx-auto mb-4"></div>
<p className="text-gray-600">Loading documents...</p>
<DocumentSkeletonList count={4} />
) : filteredDocuments.length === 0 && !searchTerm ? (
<div className="bg-white shadow-soft rounded-lg border border-gray-100">
<EmptyState
icon={<Upload className="h-8 w-8" />}
title="No documents yet"
description="Upload your first CIM document to get started with AI-powered analysis."
actionLabel="Upload Document"
onAction={() => setActiveTab('upload')}
/>
</div>
) : (
<DocumentList
@@ -733,6 +705,17 @@ const Dashboard: React.FC = () => {
)}
</div>
</div>
<ConfirmModal
open={confirmModal.open}
title="Delete Document"
message="Are you sure you want to delete this document? This action cannot be undone."
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmModal({ open: false, documentId: '' })}
/>
</div>
);
};
@@ -784,21 +767,25 @@ const UnauthorizedPage: React.FC = () => {
const App: React.FC = () => {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
<ToastProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
</ToastProvider>
</AuthProvider>
);
};

View File

@@ -18,7 +18,7 @@ const AlertBanner: React.FC<AlertBannerProps> = ({ alerts, onAcknowledge }) => {
if (criticalAlerts.length === 0) return null;
return (
<div className={cn('bg-red-600 px-4 py-3')}>
<div className={cn('bg-error-600 px-4 py-3')}>
{criticalAlerts.map((alert) => (
<div key={alert.id} className="flex items-center justify-between text-white">
<div className="flex items-center space-x-2">

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { BarChart3, AlertCircle } from 'lucide-react';
import { documentService } from '../services/documentService';
import { cn } from '../utils/cn';
import EmptyState from './EmptyState';
interface AnalyticsData {
sessionStats: Array<{
@@ -115,23 +117,21 @@ const Analytics: React.FC = () => {
if (loading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="bg-error-50 border border-error-500 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<AlertCircle className="h-5 w-5 text-error-500" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading analytics</h3>
<p className="mt-1 text-sm text-red-700">{error}</p>
<h3 className="text-sm font-medium text-error-600">Error loading analytics</h3>
<p className="mt-1 text-sm text-error-500">{error}</p>
</div>
</div>
</div>
@@ -156,7 +156,7 @@ const Analytics: React.FC = () => {
</select>
<button
onClick={loadAnalyticsData}
className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700"
className="bg-primary-600 text-white px-4 py-2 rounded-md text-sm hover:bg-primary-700"
>
Refresh
</button>
@@ -172,7 +172,7 @@ const Analytics: React.FC = () => {
<div className="flex items-center">
<div className={cn(
"w-3 h-3 rounded-full mr-2",
healthStatus.status === 'healthy' ? 'bg-green-500' : 'bg-red-500'
healthStatus.status === 'healthy' ? 'bg-success-500' : 'bg-error-500'
)}></div>
<span className="text-sm font-medium text-gray-900">Overall Status</span>
</div>
@@ -278,6 +278,13 @@ const Analytics: React.FC = () => {
{analyticsData && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Session Statistics</h2>
{(analyticsData.sessionStats ?? []).length === 0 ? (
<EmptyState
icon={<BarChart3 className="h-8 w-8" />}
title="No session data"
description="Session statistics will appear here once documents are processed."
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
@@ -297,8 +304,8 @@ const Analytics: React.FC = () => {
{new Date(stat.date).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.total_sessions}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600">{stat.successful_sessions}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-red-600">{stat.failed_sessions}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-success-600">{stat.successful_sessions}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-error-600">{stat.failed_sessions}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatTime(stat.avg_processing_time)}
</td>
@@ -310,6 +317,7 @@ const Analytics: React.FC = () => {
</tbody>
</table>
</div>
)}
</div>
)}
@@ -317,6 +325,13 @@ const Analytics: React.FC = () => {
{analyticsData && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Agent Performance</h2>
{(analyticsData.agentStats ?? []).length === 0 ? (
<EmptyState
icon={<BarChart3 className="h-8 w-8" />}
title="No agent data"
description="Agent performance metrics will appear here after processing runs."
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(analyticsData.agentStats ?? []).map((agent, index) => (
<div key={index} className="bg-gray-50 rounded-lg p-4">
@@ -330,7 +345,7 @@ const Analytics: React.FC = () => {
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-600">Success Rate:</span>
<span className="font-medium text-green-600">
<span className="font-medium text-success-600">
{parseInt(agent.total_executions) > 0
? ((parseInt(agent.successful_executions) / parseInt(agent.total_executions)) * 100).toFixed(1)
: 0}%
@@ -348,6 +363,7 @@ const Analytics: React.FC = () => {
</div>
))}
</div>
)}
</div>
)}
@@ -355,6 +371,13 @@ const Analytics: React.FC = () => {
{analyticsData && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quality Metrics</h2>
{(analyticsData.qualityStats ?? []).length === 0 ? (
<EmptyState
icon={<BarChart3 className="h-8 w-8" />}
title="No quality data"
description="Quality metrics will appear here after documents are analyzed."
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{(analyticsData.qualityStats ?? []).map((metric, index) => (
<div key={index} className="bg-gray-50 rounded-lg p-4">
@@ -378,6 +401,7 @@ const Analytics: React.FC = () => {
</div>
))}
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { cn } from '../utils/cn';
interface ConfirmModalProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning' | 'default';
onConfirm: () => void;
onCancel: () => void;
}
const variantStyles = {
danger: {
icon: 'text-error-500 bg-error-50',
button: 'bg-error-600 hover:bg-error-700 focus:ring-error-500',
},
warning: {
icon: 'text-warning-500 bg-warning-50',
button: 'bg-warning-600 hover:bg-warning-700 focus:ring-warning-500',
},
default: {
icon: 'text-primary-500 bg-primary-50',
button: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500',
},
};
const ConfirmModal: React.FC<ConfirmModalProps> = ({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
onConfirm,
onCancel,
}) => {
if (!open) return null;
const styles = variantStyles[variant];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 transition-opacity"
onClick={onCancel}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-large max-w-md w-full mx-4 p-6 animate-[fadeInScale_0.2s_ease-out]">
<button
onClick={onCancel}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="flex items-start gap-4">
<div className={cn('rounded-full p-2', styles.icon)}>
<AlertTriangle className="h-5 w-5" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{message}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={cn(
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors',
styles.button
)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};
export default ConfirmModal;

View File

@@ -9,7 +9,8 @@ import {
Clock,
CheckCircle,
AlertCircle,
PlayCircle
PlayCircle,
RefreshCw
} from 'lucide-react';
import { cn } from '../utils/cn';
@@ -85,14 +86,14 @@ const DocumentList: React.FC<DocumentListProps> = ({
const getStatusText = (status: Document['status'], progress?: number, message?: string) => {
switch (status) {
case 'uploaded':
return 'Uploaded';
return 'Uploaded';
case 'processing':
if (progress !== undefined) {
return `Processing... ${progress}%`;
}
return message || 'Processing...';
case 'completed':
return 'Completed';
return 'Completed';
case 'error':
return 'Error';
case 'extracting_text':
@@ -142,11 +143,9 @@ const DocumentList: React.FC<DocumentListProps> = ({
{onRefresh && (
<button
onClick={onRefresh}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<svg className="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<RefreshCw className="h-4 w-4 mr-1" />
Refresh
</button>
)}
@@ -209,13 +208,14 @@ const DocumentList: React.FC<DocumentListProps> = ({
{/* Show a brief status message instead of the full summary */}
{document.status === 'completed' && (
<p className="mt-2 text-sm text-success-600">
Analysis completed - Click "View" to see detailed CIM review
<p className="mt-2 text-sm text-success-600 flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5" />
Analysis completed - Click &quot;View&quot; to see detailed CIM review
</p>
)}
{document.error && (
<p className="mt-2 text-sm text-red-600">
<p className="mt-2 text-sm text-error-600">
Error: {document.error}
</p>
)}
@@ -227,14 +227,14 @@ const DocumentList: React.FC<DocumentListProps> = ({
<>
<button
onClick={() => onViewDocument?.(document.id)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Eye className="h-4 w-4 mr-1" />
View
</button>
<button
onClick={() => onDownloadDocument?.(document.id)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Download className="h-4 w-4 mr-1" />
Download
@@ -245,7 +245,7 @@ const DocumentList: React.FC<DocumentListProps> = ({
{document.status === 'error' && onRetryProcessing && (
<button
onClick={() => onRetryProcessing(document.id)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlayCircle className="h-4 w-4 mr-1" />
Retry
@@ -254,7 +254,7 @@ const DocumentList: React.FC<DocumentListProps> = ({
<button
onClick={() => onDeleteDocument?.(document.id)}
className="inline-flex items-center px-3 py-1.5 border border-red-300 shadow-sm text-xs font-medium rounded text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
className="inline-flex items-center px-3 py-1.5 border border-error-500 shadow-sm text-xs font-medium rounded text-error-600 bg-white hover:bg-error-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error-500"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { cn } from '../utils/cn';
interface EmptyStateProps {
icon: React.ReactNode;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
actionLabel,
onAction,
className,
}) => {
return (
<div className={cn('text-center py-16 px-6', className)}>
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-primary-50 text-primary-400 mb-4">
{icon}
</div>
<h3 className="text-lg font-semibold text-primary-800 mb-2">{title}</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto mb-6">{description}</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-accent-500 hover:bg-accent-600 rounded-md shadow-soft focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 transition-colors duration-200"
>
{actionLabel}
</button>
)}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="bg-white rounded-lg shadow-large max-w-md w-full p-8 text-center">
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-error-50 mb-4">
<AlertTriangle className="h-8 w-8 text-error-500" />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Something went wrong</h2>
<p className="text-sm text-gray-600 mb-6">
An unexpected error occurred. Please try again or refresh the page.
</p>
{this.state.error && (
<details className="mb-6 text-left">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-600">
Technical details
</summary>
<pre className="mt-2 text-xs text-gray-500 bg-gray-50 rounded p-3 overflow-auto max-h-32">
{this.state.error.message}
</pre>
</details>
)}
<button
onClick={this.handleRetry}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md shadow-soft focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { cn } from '../utils/cn';
interface SkeletonLineProps {
className?: string;
width?: string;
}
export const SkeletonLine: React.FC<SkeletonLineProps> = ({ className, width = 'w-full' }) => {
return (
<div
className={cn(
'h-4 rounded bg-gray-200 animate-pulse',
width,
className
)}
/>
);
};
interface SkeletonCardProps {
className?: string;
}
export const SkeletonCard: React.FC<SkeletonCardProps> = ({ className }) => {
return (
<div className={cn('bg-white shadow-soft border border-gray-100 rounded-md p-4', className)}>
<div className="flex items-center space-x-3">
<div className="h-8 w-8 rounded bg-gray-200 animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center space-x-2">
<SkeletonLine width="w-1/3" />
<div className="h-5 w-20 rounded-full bg-gray-200 animate-pulse" />
</div>
<div className="flex items-center space-x-4">
<SkeletonLine width="w-24" className="h-3" />
<SkeletonLine width="w-32" className="h-3" />
<SkeletonLine width="w-16" className="h-3" />
</div>
</div>
<div className="flex space-x-2">
<div className="h-8 w-16 rounded bg-gray-200 animate-pulse" />
<div className="h-8 w-20 rounded bg-gray-200 animate-pulse" />
</div>
</div>
</div>
);
};
interface DocumentSkeletonListProps {
count?: number;
}
export const DocumentSkeletonList: React.FC<DocumentSkeletonListProps> = ({ count = 3 }) => {
return (
<div className="bg-white shadow-soft border border-gray-100 overflow-hidden sm:rounded-md">
<div className="divide-y divide-gray-200">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="px-4 py-4 sm:px-6">
<SkeletonCard className="border-0 shadow-none p-0" />
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { cn } from '../utils/cn';
interface TabButtonProps {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}
const TabButton: React.FC<TabButtonProps> = ({ active, onClick, icon, label }) => {
return (
<button
onClick={onClick}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
active
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<span className="h-4 w-4 mr-2">{icon}</span>
{label}
</button>
);
};
export default TabButton;

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info } from 'lucide-react';
import { cn } from '../utils/cn';
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface ToastData {
id: string;
message: string;
variant: ToastVariant;
}
interface ToastItemProps {
toast: ToastData;
onDismiss: (id: string) => void;
}
const variantStyles: Record<ToastVariant, string> = {
success: 'bg-success-50 border-success-500 text-success-600',
error: 'bg-error-50 border-error-500 text-error-600',
warning: 'bg-warning-50 border-warning-500 text-warning-600',
info: 'bg-primary-50 border-primary-500 text-primary-600',
};
const variantIcons: Record<ToastVariant, React.ReactNode> = {
success: <CheckCircle className="h-5 w-5 text-success-500" />,
error: <AlertCircle className="h-5 w-5 text-error-500" />,
warning: <AlertTriangle className="h-5 w-5 text-warning-500" />,
info: <Info className="h-5 w-5 text-primary-500" />,
};
const ToastItem: React.FC<ToastItemProps> = ({ toast, onDismiss }) => {
const [visible, setVisible] = useState(false);
const [exiting, setExiting] = useState(false);
useEffect(() => {
// Trigger enter animation
const enterTimer = setTimeout(() => setVisible(true), 10);
// Auto-dismiss after 4 seconds
const dismissTimer = setTimeout(() => {
setExiting(true);
setTimeout(() => onDismiss(toast.id), 300);
}, 4000);
return () => {
clearTimeout(enterTimer);
clearTimeout(dismissTimer);
};
}, [toast.id, onDismiss]);
const handleClose = () => {
setExiting(true);
setTimeout(() => onDismiss(toast.id), 300);
};
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg border shadow-medium max-w-sm w-full transition-all duration-300 ease-in-out',
variantStyles[toast.variant],
visible && !exiting ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-8'
)}
>
<div className="flex-shrink-0">{variantIcons[toast.variant]}</div>
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={handleClose}
className="flex-shrink-0 rounded-md p-1 hover:bg-black/5 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
);
};
interface ToastContainerProps {
toasts: ToastData[];
onDismiss: (id: string) => void;
}
const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onDismiss }) => {
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
};
export default ToastContainer;

View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import ToastContainer, { ToastData, ToastVariant } from '../components/Toast';
interface ToastContextValue {
success: (message: string) => void;
error: (message: string) => void;
warning: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
let toastIdCounter = 0;
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastData[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((message: string, variant: ToastVariant) => {
const id = `toast-${++toastIdCounter}`;
setToasts((prev) => [...prev, { id, message, variant }]);
}, []);
const value: ToastContextValue = {
success: useCallback((msg: string) => addToast(msg, 'success'), [addToast]),
error: useCallback((msg: string) => addToast(msg, 'error'), [addToast]),
warning: useCallback((msg: string) => addToast(msg, 'warning'), [addToast]),
info: useCallback((msg: string) => addToast(msg, 'info'), [addToast]),
};
return (
<ToastContext.Provider value={value}>
{children}
<ToastContainer toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
};
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}

View File

@@ -11,3 +11,14 @@
font-family: 'Inter', system-ui, sans-serif;
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}