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:
@@ -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,19 +11,26 @@ 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';
|
||||
|
||||
import {
|
||||
Home,
|
||||
Upload,
|
||||
FileText,
|
||||
BarChart3,
|
||||
import {
|
||||
Home,
|
||||
Upload,
|
||||
FileText,
|
||||
BarChart3,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
91
frontend/src/components/ConfirmModal.tsx
Normal file
91
frontend/src/components/ConfirmModal.tsx
Normal 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;
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Eye,
|
||||
Download,
|
||||
Trash2,
|
||||
Calendar,
|
||||
User,
|
||||
import {
|
||||
FileText,
|
||||
Eye,
|
||||
Download,
|
||||
Trash2,
|
||||
Calendar,
|
||||
User,
|
||||
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 "View" 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
|
||||
|
||||
40
frontend/src/components/EmptyState.tsx
Normal file
40
frontend/src/components/EmptyState.tsx
Normal 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;
|
||||
69
frontend/src/components/ErrorBoundary.tsx
Normal file
69
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
66
frontend/src/components/Skeleton.tsx
Normal file
66
frontend/src/components/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
frontend/src/components/TabButton.tsx
Normal file
28
frontend/src/components/TabButton.tsx
Normal 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;
|
||||
90
frontend/src/components/Toast.tsx
Normal file
90
frontend/src/components/Toast.tsx
Normal 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;
|
||||
48
frontend/src/contexts/ToastContext.tsx
Normal file
48
frontend/src/contexts/ToastContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -6,8 +6,19 @@
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user