feat(client/server): implement notifications

This commit is contained in:
perf3ct 2025-06-14 00:06:23 +00:00
parent f7874f4541
commit d128079ce0
11 changed files with 607 additions and 19 deletions

View File

@ -15,12 +15,15 @@
"@mui/lab": "^7.0.0-beta.13",
"@mui/material": "^7.1.1",
"@mui/x-date-pickers": "^8.5.1",
"@types/uuid": "^10.0.0",
"axios": "^1.3.0",
"date-fns": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.0",
"react-router-dom": "^6.8.0"
"react-router-dom": "^6.8.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
@ -1882,6 +1885,12 @@
"@types/jest": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@ -2647,6 +2656,16 @@
"node": ">=18"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -6010,6 +6029,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vite": {
"version": "4.5.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",

View File

@ -18,12 +18,15 @@
"@mui/lab": "^7.0.0-beta.13",
"@mui/material": "^7.1.1",
"@mui/x-date-pickers": "^8.5.1",
"@types/uuid": "^10.0.0",
"axios": "^1.3.0",
"date-fns": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.0",
"react-router-dom": "^6.8.0"
"react-router-dom": "^6.8.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",

View File

@ -3,6 +3,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { CssBaseline } from '@mui/material';
import { useAuth } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { NotificationProvider } from './contexts/NotificationContext';
import Login from './components/Auth/Login';
import AppLayout from './components/Layout/AppLayout';
import Dashboard from './components/Dashboard/Dashboard';
@ -55,19 +56,21 @@ function App(): JSX.Element {
path="/*"
element={
user ? (
<AppLayout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/:id" element={<DocumentDetailsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/watch" element={<WatchFolderPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<div>Profile Page - Coming Soon</div>} />
</Routes>
</AppLayout>
<NotificationProvider>
<AppLayout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/:id" element={<DocumentDetailsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/watch" element={<WatchFolderPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<div>Profile Page - Coming Soon</div>} />
</Routes>
</AppLayout>
</NotificationProvider>
) : (
<Navigate to="/login" />
)

View File

@ -39,6 +39,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import api from '../../services/api';
import TestNotification from '../TestNotification';
interface Document {
id: string;
@ -563,6 +564,9 @@ const Dashboard: React.FC = () => {
</Typography>
</Box>
{/* Test Notifications */}
<TestNotification />
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} lg={3}>

View File

@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { DocumentArrowUpIcon } from '@heroicons/react/24/outline'
import { Document, documentService } from '../services/api'
import { useNotifications } from '../contexts/NotificationContext'
interface FileUploadProps {
onUploadSuccess: (document: Document) => void
@ -10,6 +11,7 @@ interface FileUploadProps {
function FileUpload({ onUploadSuccess }: FileUploadProps) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { addBatchNotification } = useNotifications()
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0]
@ -21,12 +23,18 @@ function FileUpload({ onUploadSuccess }: FileUploadProps) {
try {
const response = await documentService.upload(file)
onUploadSuccess(response.data)
// Trigger success notification
addBatchNotification('success', 'upload', [{ name: file.name, success: true }])
} catch (err: any) {
setError(err.response?.data?.message || 'Upload failed')
// Trigger error notification
addBatchNotification('error', 'upload', [{ name: file.name, success: false }])
} finally {
setUploading(false)
}
}, [onUploadSuccess])
}, [onUploadSuccess, addBatchNotification])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,

View File

@ -34,8 +34,10 @@ import {
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
import GlobalSearchBar from '../GlobalSearchBar';
import ThemeToggle from '../ThemeToggle/ThemeToggle';
import NotificationPanel from '../Notifications/NotificationPanel';
const drawerWidth = 280;
@ -67,9 +69,11 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [notificationAnchorEl, setNotificationAnchorEl] = useState<null | HTMLElement>(null);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const { unreadCount } = useNotifications();
const handleDrawerToggle = (): void => {
setMobileOpen(!mobileOpen);
@ -89,6 +93,14 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
navigate('/login');
};
const handleNotificationClick = (event: React.MouseEvent<HTMLElement>): void => {
setNotificationAnchorEl(event.currentTarget);
};
const handleNotificationClose = (): void => {
setNotificationAnchorEl(null);
};
const drawer = (
<Box sx={{
height: '100%',
@ -368,6 +380,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
{/* Notifications */}
<IconButton
onClick={handleNotificationClick}
sx={{
mr: 2,
color: 'text.secondary',
@ -390,7 +403,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
}}
>
<Badge
badgeContent={3}
badgeContent={unreadCount}
sx={{
'& .MuiBadge-badge': {
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
@ -545,6 +558,12 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
{children}
</Box>
</Box>
{/* Notification Panel */}
<NotificationPanel
anchorEl={notificationAnchorEl}
onClose={handleNotificationClose}
/>
</Box>
);
};

View File

@ -0,0 +1,210 @@
import React from 'react';
import {
Box,
Paper,
Typography,
IconButton,
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
Button,
Chip,
Stack,
useTheme,
} from '@mui/material';
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon,
Warning as WarningIcon,
Close as CloseIcon,
Delete as DeleteIcon,
DoneAll as DoneAllIcon,
} from '@mui/icons-material';
import { useNotifications } from '../../contexts/NotificationContext';
import { NotificationType } from '../../types/notification';
import { formatDistanceToNow } from 'date-fns';
interface NotificationPanelProps {
anchorEl: HTMLElement | null;
onClose: () => void;
}
const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose }) => {
const theme = useTheme();
const { notifications, unreadCount, markAsRead, markAllAsRead, clearNotification, clearAll } = useNotifications();
const getIcon = (type: NotificationType) => {
switch (type) {
case 'success':
return <SuccessIcon sx={{ color: theme.palette.success.main }} />;
case 'error':
return <ErrorIcon sx={{ color: theme.palette.error.main }} />;
case 'warning':
return <WarningIcon sx={{ color: theme.palette.warning.main }} />;
case 'info':
default:
return <InfoIcon sx={{ color: theme.palette.info.main }} />;
}
};
if (!anchorEl) return null;
const rect = anchorEl.getBoundingClientRect();
return (
<Paper
elevation={8}
sx={{
position: 'fixed',
top: rect.bottom + 8,
right: 16,
width: 400,
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column',
borderRadius: 3,
overflow: 'hidden',
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)'
: 'linear-gradient(135deg, rgba(30,30,30,0.95) 0%, rgba(20,20,20,0.95) 100%)',
backdropFilter: 'blur(20px)',
border: theme.palette.mode === 'light'
? '1px solid rgba(0,0,0,0.05)'
: '1px solid rgba(255,255,255,0.05)',
}}
>
{/* Header */}
<Box
sx={{
p: 2,
borderBottom: `1px solid ${theme.palette.divider}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="h6" fontWeight={600}>
Notifications
</Typography>
{unreadCount > 0 && (
<Chip
label={unreadCount}
size="small"
sx={{
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
color: 'white',
fontWeight: 600,
}}
/>
)}
</Stack>
<Stack direction="row" spacing={0.5}>
{notifications.length > 0 && (
<>
<IconButton
size="small"
onClick={markAllAsRead}
title="Mark all as read"
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
>
<DoneAllIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={clearAll}
title="Clear all"
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</>
)}
<IconButton size="small" onClick={onClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
</Box>
{/* Notifications List */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
{notifications.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: 'center',
color: 'text.secondary',
}}
>
<Typography variant="body2">No notifications</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
{notifications.map((notification, index) => (
<React.Fragment key={notification.id}>
<ListItem
sx={{
py: 1.5,
px: 2,
background: !notification.read
? theme.palette.mode === 'light'
? 'rgba(99,102,241,0.05)'
: 'rgba(99,102,241,0.1)'
: 'transparent',
'&:hover': {
background: theme.palette.mode === 'light'
? 'rgba(0,0,0,0.02)'
: 'rgba(255,255,255,0.02)',
},
cursor: 'pointer',
}}
onClick={() => markAsRead(notification.id)}
secondaryAction={
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
clearNotification(notification.id);
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<CloseIcon fontSize="small" />
</IconButton>
}
>
<ListItemIcon sx={{ minWidth: 40 }}>
{getIcon(notification.type)}
</ListItemIcon>
<ListItemText
primary={
<Typography variant="body2" fontWeight={!notification.read ? 600 : 400}>
{notification.title}
</Typography>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
{notification.message}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatDistanceToNow(notification.timestamp, { addSuffix: true })}
</Typography>
</Box>
}
/>
</ListItem>
{index < notifications.length - 1 && <Divider component="li" />}
</React.Fragment>
))}
</List>
)}
</Box>
</Paper>
);
};
export default NotificationPanel;

View File

@ -0,0 +1,58 @@
import React from 'react';
import { Button, Stack } from '@mui/material';
import { useNotifications } from '../contexts/NotificationContext';
const TestNotification: React.FC = () => {
const { addNotification, addBatchNotification } = useNotifications();
const handleTestSingle = () => {
addNotification({
type: 'success',
title: 'Test Success',
message: 'This is a test notification!',
});
};
const handleTestError = () => {
addNotification({
type: 'error',
title: 'Test Error',
message: 'This is a test error notification!',
});
};
const handleTestBatch = () => {
addBatchNotification('success', 'upload', [
{ name: 'document1.pdf', success: true },
{ name: 'document2.pdf', success: true },
{ name: 'document3.pdf', success: true },
]);
};
const handleTestMixedBatch = () => {
addBatchNotification('warning', 'upload', [
{ name: 'document1.pdf', success: true },
{ name: 'document2.pdf', success: false },
{ name: 'document3.pdf', success: true },
]);
};
return (
<Stack direction="row" spacing={2} sx={{ m: 2 }}>
<Button variant="outlined" onClick={handleTestSingle}>
Test Single Success
</Button>
<Button variant="outlined" color="error" onClick={handleTestError}>
Test Error
</Button>
<Button variant="outlined" color="success" onClick={handleTestBatch}>
Test Batch Success
</Button>
<Button variant="outlined" color="warning" onClick={handleTestMixedBatch}>
Test Mixed Batch
</Button>
</Stack>
);
};
export default TestNotification;

View File

@ -28,6 +28,7 @@ import {
} from '@mui/icons-material';
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
import api from '../../services/api';
import { useNotifications } from '../../contexts/NotificationContext';
interface UploadedDocument {
id: string;
@ -54,6 +55,7 @@ type FileStatus = 'pending' | 'uploading' | 'success' | 'error';
const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
const theme = useTheme();
const { addBatchNotification } = useNotifications();
const [files, setFiles] = useState<FileItem[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
@ -155,11 +157,31 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
setError('');
const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error');
const results: { name: string; success: boolean }[] = [];
try {
await Promise.all(pendingFiles.map(uploadFile));
await Promise.allSettled(pendingFiles.map(async (file) => {
try {
await uploadFile(file);
results.push({ name: file.file.name, success: true });
} catch (error) {
results.push({ name: file.file.name, success: false });
}
}));
// Trigger notification based on results
const hasFailures = results.some(r => !r.success);
const hasSuccesses = results.some(r => r.success);
if (!hasFailures) {
addBatchNotification('success', 'upload', results);
} else if (!hasSuccesses) {
addBatchNotification('error', 'upload', results);
} else {
addBatchNotification('warning', 'upload', results);
}
} catch (error) {
setError('Some uploads failed. Please try again.');
setError('Upload failed. Please try again.');
} finally {
setUploading(false);
}

View File

@ -0,0 +1,202 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { Notification, NotificationType, NotificationBatch } from '../types/notification';
import { v4 as uuidv4 } from 'uuid';
interface NotificationContextType {
notifications: Notification[];
unreadCount: number;
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
clearNotification: (id: string) => void;
clearAll: () => void;
addBatchNotification: (
type: NotificationType,
operation: 'upload' | 'ocr' | 'watch',
files: Array<{ name: string; success: boolean }>
) => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
const BATCH_WINDOW_MS = 2000; // 2 seconds to batch notifications
const MAX_NOTIFICATIONS = 50;
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const batchesRef = useRef<Map<string, NotificationBatch>>(new Map());
const batchTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const unreadCount = notifications.filter(n => !n.read).length;
const addNotification = useCallback((notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
const newNotification: Notification = {
...notification,
id: uuidv4(),
timestamp: new Date(),
read: false,
};
setNotifications(prev => {
const updated = [newNotification, ...prev];
// Keep only the most recent notifications
return updated.slice(0, MAX_NOTIFICATIONS);
});
}, []);
const addBatchNotification = useCallback((
type: NotificationType,
operation: 'upload' | 'ocr' | 'watch',
files: Array<{ name: string; success: boolean }>
) => {
const batchKey = `${operation}-${type}`;
const existingBatch = batchesRef.current.get(batchKey);
if (existingBatch) {
// Update existing batch
existingBatch.count += files.length;
existingBatch.successCount += files.filter(f => f.success).length;
existingBatch.failureCount += files.filter(f => !f.success).length;
} else {
// Create new batch
const batch: NotificationBatch = {
batchId: uuidv4(),
type,
operation,
count: files.length,
successCount: files.filter(f => f.success).length,
failureCount: files.filter(f => !f.success).length,
startTime: new Date(),
};
batchesRef.current.set(batchKey, batch);
}
// Clear existing timer
const existingTimer = batchTimersRef.current.get(batchKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set new timer to finalize batch
const timer = setTimeout(() => {
const batch = batchesRef.current.get(batchKey);
if (batch) {
batch.endTime = new Date();
// Create notification based on batch
let title = '';
let message = '';
if (batch.count === 1) {
// Single file - show specific notification
const fileName = files[0]?.name || 'file';
if (operation === 'upload') {
title = batch.successCount > 0 ? 'File Uploaded' : 'Upload Failed';
message = batch.successCount > 0
? `${fileName} uploaded successfully`
: `Failed to upload ${fileName}`;
} else if (operation === 'ocr') {
title = batch.successCount > 0 ? 'OCR Complete' : 'OCR Failed';
message = batch.successCount > 0
? `Text extracted from ${fileName}`
: `Failed to extract text from ${fileName}`;
} else if (operation === 'watch') {
title = 'File Detected';
message = `${fileName} added from watch folder`;
}
} else {
// Multiple files - show batch notification
if (operation === 'upload') {
title = 'Batch Upload Complete';
if (batch.failureCount === 0) {
message = `${batch.successCount} files uploaded successfully`;
} else if (batch.successCount === 0) {
message = `Failed to upload ${batch.failureCount} files`;
} else {
message = `${batch.successCount} files uploaded, ${batch.failureCount} failed`;
}
} else if (operation === 'ocr') {
title = 'Batch OCR Complete';
if (batch.failureCount === 0) {
message = `Text extracted from ${batch.successCount} documents`;
} else if (batch.successCount === 0) {
message = `Failed to process ${batch.failureCount} documents`;
} else {
message = `${batch.successCount} documents processed, ${batch.failureCount} failed`;
}
} else if (operation === 'watch') {
title = 'Files Detected';
message = `${batch.count} files added from watch folder`;
}
}
addNotification({
type: batch.failureCount > 0 && batch.successCount === 0 ? 'error' :
batch.failureCount > 0 ? 'warning' : 'success',
title,
message,
metadata: {
batchId: batch.batchId,
fileCount: batch.count,
},
});
// Clean up
batchesRef.current.delete(batchKey);
batchTimersRef.current.delete(batchKey);
}
}, BATCH_WINDOW_MS);
batchTimersRef.current.set(batchKey, timer);
}, [addNotification]);
const markAsRead = useCallback((id: string) => {
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
}, []);
const clearNotification = useCallback((id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const clearAll = useCallback(() => {
setNotifications([]);
}, []);
// Cleanup timers on unmount
useEffect(() => {
return () => {
batchTimersRef.current.forEach(timer => clearTimeout(timer));
};
}, []);
return (
<NotificationContext.Provider
value={{
notifications,
unreadCount,
addNotification,
markAsRead,
markAllAsRead,
clearNotification,
clearAll,
addBatchNotification,
}}
>
{children}
</NotificationContext.Provider>
);
};
export const useNotifications = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotifications must be used within NotificationProvider');
}
return context;
};

View File

@ -0,0 +1,27 @@
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
export interface Notification {
id: string;
type: NotificationType;
title: string;
message: string;
timestamp: Date;
read: boolean;
actionUrl?: string;
metadata?: {
documentId?: number;
batchId?: string;
fileCount?: number;
};
}
export interface NotificationBatch {
batchId: string;
type: NotificationType;
operation: 'upload' | 'ocr' | 'watch';
count: number;
successCount: number;
failureCount: number;
startTime: Date;
endTime?: Date;
}