2079 lines
76 KiB
TypeScript
2079 lines
76 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
Container,
|
|
Typography,
|
|
Paper,
|
|
Button,
|
|
Grid,
|
|
Card,
|
|
CardContent,
|
|
Chip,
|
|
IconButton,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
TextField,
|
|
FormControl,
|
|
InputLabel,
|
|
Select,
|
|
MenuItem,
|
|
Alert,
|
|
LinearProgress,
|
|
Snackbar,
|
|
Divider,
|
|
FormControlLabel,
|
|
Switch,
|
|
Tooltip,
|
|
CircularProgress,
|
|
Fade,
|
|
Stack,
|
|
Avatar,
|
|
Badge,
|
|
useTheme,
|
|
alpha,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
} from '@mui/material';
|
|
import {
|
|
Add as AddIcon,
|
|
CloudSync as CloudSyncIcon,
|
|
Error as ErrorIcon,
|
|
CheckCircle as CheckCircleIcon,
|
|
Edit as EditIcon,
|
|
Delete as DeleteIcon,
|
|
PlayArrow as PlayArrowIcon,
|
|
Stop as StopIcon,
|
|
Storage as StorageIcon,
|
|
Cloud as CloudIcon,
|
|
Speed as SpeedIcon,
|
|
Timeline as TimelineIcon,
|
|
TrendingUp as TrendingUpIcon,
|
|
Security as SecurityIcon,
|
|
AutoFixHigh as AutoFixHighIcon,
|
|
Sync as SyncIcon,
|
|
MoreVert as MoreVertIcon,
|
|
Menu as MenuIcon,
|
|
Folder as FolderIcon,
|
|
Assessment as AssessmentIcon,
|
|
Extension as ExtensionIcon,
|
|
Storage as ServerIcon,
|
|
Pause as PauseIcon,
|
|
PlayArrow as ResumeIcon,
|
|
} from '@mui/icons-material';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import api, { queueService } from '../services/api';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
interface Source {
|
|
id: string;
|
|
name: string;
|
|
source_type: 'webdav' | 'local_folder' | 's3';
|
|
enabled: boolean;
|
|
config: any;
|
|
status: 'idle' | 'syncing' | 'error';
|
|
last_sync_at: string | null;
|
|
last_error: string | null;
|
|
last_error_at: string | null;
|
|
total_files_synced: number;
|
|
total_files_pending: number;
|
|
total_size_bytes: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface SnackbarState {
|
|
open: boolean;
|
|
message: string;
|
|
severity: 'success' | 'error' | 'warning' | 'info';
|
|
}
|
|
|
|
const SourcesPage: React.FC = () => {
|
|
const theme = useTheme();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const [sources, setSources] = useState<Source[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: string }>({ is_paused: false, status: 'running' });
|
|
const [ocrLoading, setOcrLoading] = useState(false);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingSource, setEditingSource] = useState<Source | null>(null);
|
|
const [snackbar, setSnackbar] = useState<SnackbarState>({
|
|
open: false,
|
|
message: '',
|
|
severity: 'info',
|
|
});
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
source_type: 'webdav' as 'webdav' | 'local_folder' | 's3',
|
|
enabled: true,
|
|
// WebDAV fields
|
|
server_url: '',
|
|
username: '',
|
|
password: '',
|
|
server_type: 'generic' as 'nextcloud' | 'owncloud' | 'generic',
|
|
// Local Folder fields
|
|
recursive: true,
|
|
follow_symlinks: false,
|
|
// S3 fields
|
|
bucket_name: '',
|
|
region: 'us-east-1',
|
|
access_key_id: '',
|
|
secret_access_key: '',
|
|
endpoint_url: '',
|
|
prefix: '',
|
|
// Common fields
|
|
watch_folders: ['/Documents'],
|
|
file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
|
|
auto_sync: false,
|
|
sync_interval_minutes: 60,
|
|
});
|
|
|
|
// Additional state for enhanced features
|
|
const [newFolder, setNewFolder] = useState('');
|
|
const [newExtension, setNewExtension] = useState('');
|
|
const [crawlEstimate, setCrawlEstimate] = useState<any>(null);
|
|
const [estimatingCrawl, setEstimatingCrawl] = useState(false);
|
|
|
|
const [testingConnection, setTestingConnection] = useState(false);
|
|
const [syncingSource, setSyncingSource] = useState<string | null>(null);
|
|
const [stoppingSync, setStoppingSync] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadSources();
|
|
if (user?.role === 'Admin') {
|
|
loadOcrStatus();
|
|
}
|
|
}, [user]);
|
|
|
|
// Update default folders when source type changes
|
|
useEffect(() => {
|
|
if (!editingSource) { // Only for new sources
|
|
let defaultFolders;
|
|
switch (formData.source_type) {
|
|
case 'local_folder':
|
|
defaultFolders = ['/home/user/Documents'];
|
|
break;
|
|
case 's3':
|
|
defaultFolders = ['documents/'];
|
|
break;
|
|
case 'webdav':
|
|
default:
|
|
defaultFolders = ['/Documents'];
|
|
break;
|
|
}
|
|
setFormData(prev => ({ ...prev, watch_folders: defaultFolders }));
|
|
}
|
|
}, [formData.source_type, editingSource]);
|
|
|
|
const loadSources = async () => {
|
|
try {
|
|
const response = await api.get('/sources');
|
|
setSources(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to load sources:', error);
|
|
showSnackbar('Failed to load sources', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const showSnackbar = (message: string, severity: SnackbarState['severity']) => {
|
|
setSnackbar({ open: true, message, severity });
|
|
};
|
|
|
|
// OCR Control Functions (Admin only)
|
|
const loadOcrStatus = async () => {
|
|
if (user?.role !== 'Admin') return;
|
|
try {
|
|
const response = await queueService.getOcrStatus();
|
|
setOcrStatus(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to load OCR status:', error);
|
|
}
|
|
};
|
|
|
|
const handlePauseOcr = async () => {
|
|
if (user?.role !== 'Admin') return;
|
|
setOcrLoading(true);
|
|
try {
|
|
await queueService.pauseOcr();
|
|
await loadOcrStatus();
|
|
showSnackbar('OCR processing paused successfully', 'success');
|
|
} catch (error) {
|
|
console.error('Failed to pause OCR:', error);
|
|
showSnackbar('Failed to pause OCR processing', 'error');
|
|
} finally {
|
|
setOcrLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResumeOcr = async () => {
|
|
if (user?.role !== 'Admin') return;
|
|
setOcrLoading(true);
|
|
try {
|
|
await queueService.resumeOcr();
|
|
await loadOcrStatus();
|
|
showSnackbar('OCR processing resumed successfully', 'success');
|
|
} catch (error) {
|
|
console.error('Failed to resume OCR:', error);
|
|
showSnackbar('Failed to resume OCR processing', 'error');
|
|
} finally {
|
|
setOcrLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateSource = () => {
|
|
setEditingSource(null);
|
|
setFormData({
|
|
name: '',
|
|
source_type: 'webdav',
|
|
enabled: true,
|
|
// WebDAV fields
|
|
server_url: '',
|
|
username: '',
|
|
password: '',
|
|
server_type: 'generic',
|
|
// Local Folder fields
|
|
recursive: true,
|
|
follow_symlinks: false,
|
|
// S3 fields
|
|
bucket_name: '',
|
|
region: 'us-east-1',
|
|
access_key_id: '',
|
|
secret_access_key: '',
|
|
endpoint_url: '',
|
|
prefix: '',
|
|
// Common fields
|
|
watch_folders: ['/Documents'],
|
|
file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
|
|
auto_sync: false,
|
|
sync_interval_minutes: 60,
|
|
});
|
|
setCrawlEstimate(null);
|
|
setNewFolder('');
|
|
setNewExtension('');
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleEditSource = (source: Source) => {
|
|
setEditingSource(source);
|
|
const config = source.config;
|
|
setFormData({
|
|
name: source.name,
|
|
source_type: source.source_type,
|
|
enabled: source.enabled,
|
|
// WebDAV fields
|
|
server_url: config.server_url || '',
|
|
username: config.username || '',
|
|
password: config.password || '',
|
|
server_type: config.server_type || 'generic',
|
|
// Local Folder fields
|
|
recursive: config.recursive !== undefined ? config.recursive : true,
|
|
follow_symlinks: config.follow_symlinks || false,
|
|
// S3 fields
|
|
bucket_name: config.bucket_name || '',
|
|
region: config.region || 'us-east-1',
|
|
access_key_id: config.access_key_id || '',
|
|
secret_access_key: config.secret_access_key || '',
|
|
endpoint_url: config.endpoint_url || '',
|
|
prefix: config.prefix || '',
|
|
// Common fields
|
|
watch_folders: config.watch_folders || ['/Documents'],
|
|
file_extensions: config.file_extensions || ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
|
|
auto_sync: config.auto_sync || false,
|
|
sync_interval_minutes: config.sync_interval_minutes || 60,
|
|
});
|
|
setCrawlEstimate(null);
|
|
setNewFolder('');
|
|
setNewExtension('');
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleSaveSource = async () => {
|
|
try {
|
|
let config = {};
|
|
|
|
// Build config based on source type
|
|
if (formData.source_type === 'webdav') {
|
|
config = {
|
|
server_url: formData.server_url,
|
|
username: formData.username,
|
|
password: formData.password,
|
|
watch_folders: formData.watch_folders,
|
|
file_extensions: formData.file_extensions,
|
|
auto_sync: formData.auto_sync,
|
|
sync_interval_minutes: formData.sync_interval_minutes,
|
|
server_type: formData.server_type,
|
|
};
|
|
} else if (formData.source_type === 'local_folder') {
|
|
config = {
|
|
watch_folders: formData.watch_folders,
|
|
file_extensions: formData.file_extensions,
|
|
auto_sync: formData.auto_sync,
|
|
sync_interval_minutes: formData.sync_interval_minutes,
|
|
recursive: formData.recursive,
|
|
follow_symlinks: formData.follow_symlinks,
|
|
};
|
|
} else if (formData.source_type === 's3') {
|
|
config = {
|
|
bucket_name: formData.bucket_name,
|
|
region: formData.region,
|
|
access_key_id: formData.access_key_id,
|
|
secret_access_key: formData.secret_access_key,
|
|
endpoint_url: formData.endpoint_url,
|
|
prefix: formData.prefix,
|
|
watch_folders: formData.watch_folders,
|
|
file_extensions: formData.file_extensions,
|
|
auto_sync: formData.auto_sync,
|
|
sync_interval_minutes: formData.sync_interval_minutes,
|
|
};
|
|
}
|
|
|
|
if (editingSource) {
|
|
await api.put(`/sources/${editingSource.id}`, {
|
|
name: formData.name,
|
|
enabled: formData.enabled,
|
|
config,
|
|
});
|
|
showSnackbar('Source updated successfully', 'success');
|
|
} else {
|
|
await api.post('/sources', {
|
|
name: formData.name,
|
|
source_type: formData.source_type,
|
|
enabled: formData.enabled,
|
|
config,
|
|
});
|
|
showSnackbar('Source created successfully', 'success');
|
|
}
|
|
|
|
setDialogOpen(false);
|
|
loadSources();
|
|
} catch (error) {
|
|
console.error('Failed to save source:', error);
|
|
showSnackbar('Failed to save source', 'error');
|
|
}
|
|
};
|
|
|
|
const handleDeleteSource = async (source: Source) => {
|
|
if (!confirm(`Are you sure you want to delete "${source.name}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.delete(`/sources/${source.id}`);
|
|
showSnackbar('Source deleted successfully', 'success');
|
|
loadSources();
|
|
} catch (error) {
|
|
console.error('Failed to delete source:', error);
|
|
showSnackbar('Failed to delete source', 'error');
|
|
}
|
|
};
|
|
|
|
const handleTestConnection = async () => {
|
|
setTestingConnection(true);
|
|
try {
|
|
let response;
|
|
if (formData.source_type === 'webdav') {
|
|
response = await api.post('/webdav/test-connection', {
|
|
server_url: formData.server_url,
|
|
username: formData.username,
|
|
password: formData.password,
|
|
server_type: formData.server_type,
|
|
});
|
|
} else if (formData.source_type === 'local_folder') {
|
|
response = await api.post('/sources/test-connection', {
|
|
source_type: 'local_folder',
|
|
config: {
|
|
watch_folders: formData.watch_folders,
|
|
file_extensions: formData.file_extensions,
|
|
recursive: formData.recursive,
|
|
follow_symlinks: formData.follow_symlinks,
|
|
}
|
|
});
|
|
} else if (formData.source_type === 's3') {
|
|
response = await api.post('/sources/test-connection', {
|
|
source_type: 's3',
|
|
config: {
|
|
bucket_name: formData.bucket_name,
|
|
region: formData.region,
|
|
access_key_id: formData.access_key_id,
|
|
secret_access_key: formData.secret_access_key,
|
|
endpoint_url: formData.endpoint_url,
|
|
prefix: formData.prefix,
|
|
}
|
|
});
|
|
}
|
|
|
|
if (response && response.data.success) {
|
|
showSnackbar(response.data.message || 'Connection successful!', 'success');
|
|
} else {
|
|
showSnackbar(response?.data.message || 'Connection failed', 'error');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to test connection:', error);
|
|
const errorMessage = error.response?.data?.message || error.message || 'Failed to test connection';
|
|
showSnackbar(errorMessage, 'error');
|
|
} finally {
|
|
setTestingConnection(false);
|
|
}
|
|
};
|
|
|
|
const handleTriggerSync = async (sourceId: string) => {
|
|
setSyncingSource(sourceId);
|
|
try {
|
|
await api.post(`/sources/${sourceId}/sync`);
|
|
showSnackbar('Sync started successfully', 'success');
|
|
setTimeout(loadSources, 1000);
|
|
} catch (error: any) {
|
|
console.error('Failed to trigger sync:', error);
|
|
if (error.response?.status === 409) {
|
|
showSnackbar('Source is already syncing', 'warning');
|
|
} else {
|
|
showSnackbar('Failed to start sync', 'error');
|
|
}
|
|
} finally {
|
|
setSyncingSource(null);
|
|
}
|
|
};
|
|
|
|
const handleStopSync = async (sourceId: string) => {
|
|
setStoppingSync(sourceId);
|
|
try {
|
|
await api.post(`/sources/${sourceId}/sync/stop`);
|
|
showSnackbar('Sync stopped successfully', 'success');
|
|
setTimeout(loadSources, 1000);
|
|
} catch (error: any) {
|
|
console.error('Failed to stop sync:', error);
|
|
if (error.response?.status === 409) {
|
|
showSnackbar('Source is not currently syncing', 'warning');
|
|
} else {
|
|
showSnackbar('Failed to stop sync', 'error');
|
|
}
|
|
} finally {
|
|
setStoppingSync(null);
|
|
}
|
|
};
|
|
|
|
// Utility functions for folder management
|
|
const addFolder = () => {
|
|
if (newFolder && !formData.watch_folders.includes(newFolder)) {
|
|
setFormData({
|
|
...formData,
|
|
watch_folders: [...formData.watch_folders, newFolder]
|
|
});
|
|
setNewFolder('');
|
|
}
|
|
};
|
|
|
|
const removeFolder = (folderToRemove: string) => {
|
|
setFormData({
|
|
...formData,
|
|
watch_folders: formData.watch_folders.filter(folder => folder !== folderToRemove)
|
|
});
|
|
};
|
|
|
|
// Utility functions for file extension management
|
|
const addFileExtension = () => {
|
|
if (newExtension && !formData.file_extensions.includes(newExtension)) {
|
|
setFormData({
|
|
...formData,
|
|
file_extensions: [...formData.file_extensions, newExtension]
|
|
});
|
|
setNewExtension('');
|
|
}
|
|
};
|
|
|
|
const removeFileExtension = (extensionToRemove: string) => {
|
|
setFormData({
|
|
...formData,
|
|
file_extensions: formData.file_extensions.filter(ext => ext !== extensionToRemove)
|
|
});
|
|
};
|
|
|
|
// Crawl estimation function
|
|
const estimateCrawl = async () => {
|
|
setEstimatingCrawl(true);
|
|
try {
|
|
let response;
|
|
if (editingSource) {
|
|
// Use the source-specific endpoint for existing sources
|
|
response = await api.post(`/sources/${editingSource.id}/estimate`);
|
|
} else {
|
|
// Use the general endpoint with provided config for new sources
|
|
response = await api.post('/sources/estimate', {
|
|
server_url: formData.server_url,
|
|
username: formData.username,
|
|
password: formData.password,
|
|
watch_folders: formData.watch_folders,
|
|
file_extensions: formData.file_extensions,
|
|
auto_sync: formData.auto_sync,
|
|
sync_interval_minutes: formData.sync_interval_minutes,
|
|
server_type: formData.server_type,
|
|
});
|
|
}
|
|
setCrawlEstimate(response.data);
|
|
showSnackbar('Crawl estimation completed', 'success');
|
|
} catch (error) {
|
|
console.error('Failed to estimate crawl:', error);
|
|
showSnackbar('Failed to estimate crawl', 'error');
|
|
} finally {
|
|
setEstimatingCrawl(false);
|
|
}
|
|
};
|
|
|
|
const getSourceIcon = (sourceType: string) => {
|
|
switch (sourceType) {
|
|
case 'webdav':
|
|
return <CloudIcon />;
|
|
case 's3':
|
|
return <CloudIcon />;
|
|
case 'local_folder':
|
|
return <FolderIcon />;
|
|
default:
|
|
return <StorageIcon />;
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (source: Source) => {
|
|
if (source.status === 'syncing') {
|
|
return <SyncIcon sx={{ animation: 'spin 2s linear infinite' }} />;
|
|
} else if (source.status === 'error') {
|
|
return <ErrorIcon />;
|
|
} else {
|
|
return <CheckCircleIcon />;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'syncing':
|
|
return theme.palette.info.main;
|
|
case 'error':
|
|
return theme.palette.error.main;
|
|
default:
|
|
return theme.palette.success.main;
|
|
}
|
|
};
|
|
|
|
const formatBytes = (bytes: number) => {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const StatCard = ({ icon, label, value, color = 'primary' }: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string | number;
|
|
color?: 'primary' | 'success' | 'warning' | 'error'
|
|
}) => (
|
|
<Box
|
|
sx={{
|
|
p: 3,
|
|
borderRadius: 3,
|
|
background: `linear-gradient(135deg, ${alpha(theme.palette[color].main, 0.1)} 0%, ${alpha(theme.palette[color].main, 0.05)} 100%)`,
|
|
border: `1px solid ${alpha(theme.palette[color].main, 0.2)}`,
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
'&::before': {
|
|
content: '""',
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '3px',
|
|
background: `linear-gradient(90deg, ${theme.palette[color].main}, ${theme.palette[color].light})`,
|
|
}
|
|
}}
|
|
>
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette[color].main, 0.15),
|
|
color: theme.palette[color].main,
|
|
width: 48,
|
|
height: 48,
|
|
}}
|
|
>
|
|
{icon}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h4" fontWeight="bold" color={theme.palette[color].main}>
|
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{label}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
|
|
const renderSourceCard = (source: Source) => (
|
|
<Fade in={true} key={source.id}>
|
|
<Card
|
|
sx={{
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
borderRadius: 4,
|
|
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
'&:hover': {
|
|
transform: 'translateY(-4px)',
|
|
boxShadow: theme.shadows[8],
|
|
'& .action-buttons': {
|
|
opacity: 1,
|
|
transform: 'translateY(0)',
|
|
}
|
|
},
|
|
'&::before': {
|
|
content: '""',
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '4px',
|
|
background: source.enabled
|
|
? `linear-gradient(90deg, ${getStatusColor(source.status)}, ${alpha(getStatusColor(source.status), 0.7)})`
|
|
: theme.palette.grey[300],
|
|
}
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: 4 }}>
|
|
{/* Header */}
|
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" mb={3}>
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
|
color: theme.palette.primary.main,
|
|
width: 56,
|
|
height: 56,
|
|
}}
|
|
>
|
|
{getSourceIcon(source.source_type)}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
|
{source.name}
|
|
</Typography>
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
<Chip
|
|
label={source.source_type.toUpperCase()}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{
|
|
borderRadius: 2,
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
}}
|
|
/>
|
|
<Chip
|
|
icon={getStatusIcon(source)}
|
|
label={source.status.charAt(0).toUpperCase() + source.status.slice(1)}
|
|
size="small"
|
|
sx={{
|
|
borderRadius: 2,
|
|
bgcolor: alpha(getStatusColor(source.status), 0.1),
|
|
color: getStatusColor(source.status),
|
|
border: `1px solid ${alpha(getStatusColor(source.status), 0.3)}`,
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
}}
|
|
/>
|
|
{!source.enabled && (
|
|
<Chip
|
|
label="Disabled"
|
|
size="small"
|
|
color="default"
|
|
sx={{ borderRadius: 2, fontSize: '0.75rem' }}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
|
|
{/* Action Buttons */}
|
|
<Stack
|
|
direction="row"
|
|
spacing={1}
|
|
className="action-buttons"
|
|
sx={{
|
|
opacity: 0,
|
|
transform: 'translateY(-8px)',
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
{source.status === 'syncing' ? (
|
|
<Tooltip title="Stop Sync">
|
|
<span>
|
|
<IconButton
|
|
onClick={() => handleStopSync(source.id)}
|
|
disabled={stoppingSync === source.id}
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
'&:hover': { bgcolor: alpha(theme.palette.warning.main, 0.2) },
|
|
color: theme.palette.warning.main,
|
|
}}
|
|
>
|
|
{stoppingSync === source.id ? (
|
|
<CircularProgress size={20} />
|
|
) : (
|
|
<StopIcon />
|
|
)}
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip title="Trigger Sync">
|
|
<span>
|
|
<IconButton
|
|
onClick={() => handleTriggerSync(source.id)}
|
|
disabled={syncingSource === source.id || !source.enabled}
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
|
'&:hover': { bgcolor: alpha(theme.palette.primary.main, 0.2) },
|
|
}}
|
|
>
|
|
{syncingSource === source.id ? (
|
|
<CircularProgress size={20} />
|
|
) : (
|
|
<PlayArrowIcon />
|
|
)}
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip title="Edit Source">
|
|
<IconButton
|
|
onClick={() => handleEditSource(source)}
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.grey[500], 0.1),
|
|
'&:hover': { bgcolor: alpha(theme.palette.grey[500], 0.2) },
|
|
}}
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Delete Source">
|
|
<IconButton
|
|
onClick={() => handleDeleteSource(source)}
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.error.main, 0.1),
|
|
'&:hover': { bgcolor: alpha(theme.palette.error.main, 0.2) },
|
|
color: theme.palette.error.main,
|
|
}}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Stats Grid */}
|
|
<Grid container spacing={3} mb={3}>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<TrendingUpIcon />}
|
|
label="Files Synced"
|
|
value={source.total_files_synced}
|
|
color="success"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<SpeedIcon />}
|
|
label="Files Pending"
|
|
value={source.total_files_pending}
|
|
color="warning"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<StorageIcon />}
|
|
label="Total Size"
|
|
value={formatBytes(source.total_size_bytes)}
|
|
color="primary"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<TimelineIcon />}
|
|
label="Last Sync"
|
|
value={source.last_sync_at
|
|
? formatDistanceToNow(new Date(source.last_sync_at), { addSuffix: true })
|
|
: 'Never'}
|
|
color="primary"
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* Error Alert */}
|
|
{source.last_error && (
|
|
<Alert
|
|
severity="error"
|
|
sx={{
|
|
borderRadius: 3,
|
|
'& .MuiAlert-icon': {
|
|
fontSize: '1.2rem',
|
|
}
|
|
}}
|
|
>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
{source.last_error}
|
|
</Typography>
|
|
{source.last_error_at && (
|
|
<Typography variant="caption" display="block" sx={{ mt: 0.5, opacity: 0.8 }}>
|
|
{formatDistanceToNow(new Date(source.last_error_at), { addSuffix: true })}
|
|
</Typography>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</Fade>
|
|
);
|
|
|
|
return (
|
|
<Container maxWidth="xl" sx={{ py: 6 }}>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 6 }}>
|
|
<Typography
|
|
variant="h3"
|
|
component="h1"
|
|
fontWeight="bold"
|
|
sx={{
|
|
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
|
|
backgroundClip: 'text',
|
|
WebkitBackgroundClip: 'text',
|
|
color: 'transparent',
|
|
mb: 2,
|
|
}}
|
|
>
|
|
Document Sources
|
|
</Typography>
|
|
<Typography variant="h6" color="text.secondary" sx={{ mb: 4 }}>
|
|
Connect and manage your document sources with intelligent syncing
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={2} alignItems="center">
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
startIcon={<AddIcon />}
|
|
onClick={handleCreateSource}
|
|
sx={{
|
|
borderRadius: 3,
|
|
px: 4,
|
|
py: 1.5,
|
|
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
|
boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.3)}`,
|
|
'&:hover': {
|
|
transform: 'translateY(-2px)',
|
|
boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.4)}`,
|
|
},
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
Add Source
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
size="large"
|
|
startIcon={<AutoFixHighIcon />}
|
|
onClick={loadSources}
|
|
sx={{
|
|
borderRadius: 3,
|
|
px: 4,
|
|
py: 1.5,
|
|
borderWidth: 2,
|
|
'&:hover': {
|
|
borderWidth: 2,
|
|
transform: 'translateY(-1px)',
|
|
},
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
|
|
{/* OCR Controls for Admin Users */}
|
|
{user?.role === 'Admin' && (
|
|
<>
|
|
{ocrLoading ? (
|
|
<CircularProgress size={24} />
|
|
) : ocrStatus.is_paused ? (
|
|
<Button
|
|
variant="outlined"
|
|
size="large"
|
|
startIcon={<ResumeIcon />}
|
|
onClick={handleResumeOcr}
|
|
sx={{
|
|
borderRadius: 3,
|
|
px: 4,
|
|
py: 1.5,
|
|
borderWidth: 2,
|
|
borderColor: 'success.main',
|
|
color: 'success.main',
|
|
'&:hover': {
|
|
borderWidth: 2,
|
|
borderColor: 'success.dark',
|
|
backgroundColor: alpha(theme.palette.success.main, 0.1),
|
|
transform: 'translateY(-1px)',
|
|
},
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
Resume OCR
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="outlined"
|
|
size="large"
|
|
startIcon={<PauseIcon />}
|
|
onClick={handlePauseOcr}
|
|
sx={{
|
|
borderRadius: 3,
|
|
px: 4,
|
|
py: 1.5,
|
|
borderWidth: 2,
|
|
borderColor: 'warning.main',
|
|
color: 'warning.main',
|
|
'&:hover': {
|
|
borderWidth: 2,
|
|
borderColor: 'warning.dark',
|
|
backgroundColor: alpha(theme.palette.warning.main, 0.1),
|
|
transform: 'translateY(-1px)',
|
|
},
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
Pause OCR
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
<CircularProgress size={48} thickness={3} />
|
|
</Box>
|
|
) : sources.length === 0 ? (
|
|
<Paper
|
|
sx={{
|
|
p: 8,
|
|
textAlign: 'center',
|
|
borderRadius: 4,
|
|
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.secondary.main, 0.05)} 100%)`,
|
|
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
|
}}
|
|
>
|
|
<Avatar
|
|
sx={{
|
|
width: 80,
|
|
height: 80,
|
|
mx: 'auto',
|
|
mb: 3,
|
|
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
|
color: theme.palette.primary.main,
|
|
}}
|
|
>
|
|
<StorageIcon sx={{ fontSize: 40 }} />
|
|
</Avatar>
|
|
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
|
No Sources Configured
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 400, mx: 'auto' }}>
|
|
Connect your first document source to start automatically syncing and processing your files with AI-powered OCR.
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
startIcon={<AddIcon />}
|
|
onClick={handleCreateSource}
|
|
sx={{
|
|
borderRadius: 3,
|
|
px: 6,
|
|
py: 2,
|
|
fontSize: '1.1rem',
|
|
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
|
boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.3)}`,
|
|
'&:hover': {
|
|
transform: 'translateY(-2px)',
|
|
boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.4)}`,
|
|
},
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
Add Your First Source
|
|
</Button>
|
|
</Paper>
|
|
) : (
|
|
<Grid container spacing={4}>
|
|
{sources.map(renderSourceCard)}
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Create/Edit Dialog - Enhanced */}
|
|
<Dialog
|
|
open={dialogOpen}
|
|
onClose={() => setDialogOpen(false)}
|
|
maxWidth="md"
|
|
fullWidth
|
|
PaperProps={{
|
|
sx: {
|
|
borderRadius: 4,
|
|
background: theme.palette.background.paper,
|
|
}
|
|
}}
|
|
>
|
|
<DialogTitle sx={{ p: 4, pb: 2 }}>
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
|
color: theme.palette.primary.main,
|
|
}}
|
|
>
|
|
{editingSource ? <EditIcon /> : <AddIcon />}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h6" fontWeight="bold">
|
|
{editingSource ? 'Edit Source' : 'Create New Source'}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{editingSource ? 'Update your source configuration' : 'Connect a new document source'}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</DialogTitle>
|
|
|
|
<DialogContent sx={{ p: 4, pt: 2 }}>
|
|
<Stack spacing={3}>
|
|
<TextField
|
|
fullWidth
|
|
label="Source Name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="My Document Server"
|
|
sx={{
|
|
'& .MuiOutlinedInput-root': {
|
|
borderRadius: 2,
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{!editingSource && (
|
|
<FormControl fullWidth>
|
|
<InputLabel>Source Type</InputLabel>
|
|
<Select
|
|
value={formData.source_type}
|
|
onChange={(e) => setFormData({ ...formData, source_type: e.target.value as any })}
|
|
label="Source Type"
|
|
sx={{
|
|
borderRadius: 2,
|
|
}}
|
|
>
|
|
<MenuItem value="webdav">
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<CloudIcon />
|
|
<Box>
|
|
<Typography variant="body1">WebDAV</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Nextcloud, ownCloud, and other WebDAV servers
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
<MenuItem value="local_folder">
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<FolderIcon />
|
|
<Box>
|
|
<Typography variant="body1">Local Folder</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Monitor local filesystem directories
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
<MenuItem value="s3">
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<CloudIcon />
|
|
<Box>
|
|
<Typography variant="body1">S3 Compatible</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
AWS S3, MinIO, and other S3-compatible storage
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
)}
|
|
|
|
{formData.source_type === 'webdav' && (
|
|
<Paper
|
|
sx={{
|
|
p: 3,
|
|
borderRadius: 3,
|
|
bgcolor: alpha(theme.palette.primary.main, 0.03),
|
|
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
|
}}
|
|
>
|
|
<Stack spacing={3}>
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
|
color: theme.palette.primary.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<CloudIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
WebDAV Configuration
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Server URL"
|
|
value={formData.server_url}
|
|
onChange={(e) => setFormData({ ...formData, server_url: e.target.value })}
|
|
placeholder={
|
|
formData.server_type === 'nextcloud'
|
|
? "https://nextcloud.example.com/"
|
|
: formData.server_type === 'owncloud'
|
|
? "https://owncloud.example.com/remote.php/dav/files/username/"
|
|
: "https://webdav.example.com/dav/"
|
|
}
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
fullWidth
|
|
label="Username"
|
|
value={formData.username}
|
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
fullWidth
|
|
label="Password"
|
|
type="password"
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<FormControl fullWidth>
|
|
<InputLabel>Server Type</InputLabel>
|
|
<Select
|
|
value={formData.server_type}
|
|
onChange={(e) => setFormData({ ...formData, server_type: e.target.value as any })}
|
|
label="Server Type"
|
|
sx={{ borderRadius: 2 }}
|
|
>
|
|
<MenuItem value="nextcloud">
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<ServerIcon />
|
|
<Box>
|
|
<Typography variant="body1">Nextcloud</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Optimized for Nextcloud servers
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
<MenuItem value="owncloud">
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<ServerIcon />
|
|
<Box>
|
|
<Typography variant="body1">ownCloud</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Optimized for ownCloud servers
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
<MenuItem value="generic">
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<CloudIcon />
|
|
<Box>
|
|
<Typography variant="body1">Generic WebDAV</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Standard WebDAV protocol
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={formData.auto_sync}
|
|
onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
Enable Automatic Sync
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Automatically sync files on a schedule
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
{formData.auto_sync && (
|
|
<TextField
|
|
fullWidth
|
|
type="number"
|
|
label="Sync Interval (minutes)"
|
|
value={formData.sync_interval_minutes}
|
|
onChange={(e) => setFormData({ ...formData, sync_interval_minutes: parseInt(e.target.value) || 60 })}
|
|
inputProps={{ min: 15, max: 1440 }}
|
|
helperText="How often to check for new files (15 min - 24 hours)"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
)}
|
|
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
{/* Folder Management */}
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
|
color: theme.palette.secondary.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<FolderIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
Folders to Monitor
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Specify which folders to scan for files. Use absolute paths starting with "/".
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} mb={2}>
|
|
<TextField
|
|
label="Add Folder Path"
|
|
value={newFolder}
|
|
onChange={(e) => setNewFolder(e.target.value)}
|
|
placeholder="/Documents"
|
|
sx={{
|
|
flexGrow: 1,
|
|
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={addFolder}
|
|
disabled={!newFolder}
|
|
sx={{ borderRadius: 2, px: 3 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ mb: 3 }}>
|
|
{formData.watch_folders.map((folder, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={folder}
|
|
onDelete={() => removeFolder(folder)}
|
|
sx={{
|
|
mr: 1,
|
|
mb: 1,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
|
color: theme.palette.secondary.main,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{/* File Extensions */}
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<ExtensionIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
File Extensions
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
File types to sync and process with OCR.
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} mb={2}>
|
|
<TextField
|
|
label="Add Extension"
|
|
value={newExtension}
|
|
onChange={(e) => setNewExtension(e.target.value)}
|
|
placeholder="docx"
|
|
sx={{
|
|
flexGrow: 1,
|
|
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={addFileExtension}
|
|
disabled={!newExtension}
|
|
sx={{ borderRadius: 2, px: 3 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ mb: 3 }}>
|
|
{formData.file_extensions.map((extension, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={extension}
|
|
onDelete={() => removeFileExtension(extension)}
|
|
sx={{
|
|
mr: 1,
|
|
mb: 1,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{/* Crawl Estimation */}
|
|
{editingSource && formData.server_url && formData.username && formData.watch_folders.length > 0 && (
|
|
<>
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.info.main, 0.1),
|
|
color: theme.palette.info.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<AssessmentIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
Crawl Estimation
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Estimate how many files will be processed and how long it will take.
|
|
</Typography>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
onClick={estimateCrawl}
|
|
disabled={estimatingCrawl}
|
|
startIcon={estimatingCrawl ? <CircularProgress size={20} /> : <AssessmentIcon />}
|
|
sx={{ mb: 2, borderRadius: 2 }}
|
|
>
|
|
{estimatingCrawl ? 'Estimating...' : 'Estimate Crawl'}
|
|
</Button>
|
|
|
|
{estimatingCrawl && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<LinearProgress sx={{ borderRadius: 1 }} />
|
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
|
Analyzing folders and counting files...
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{crawlEstimate && (
|
|
<Paper
|
|
sx={{
|
|
p: 3,
|
|
borderRadius: 3,
|
|
bgcolor: alpha(theme.palette.info.main, 0.03),
|
|
border: `1px solid ${alpha(theme.palette.info.main, 0.1)}`,
|
|
mb: 2
|
|
}}
|
|
>
|
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
|
Estimation Results
|
|
</Typography>
|
|
<Grid container spacing={2} sx={{ mb: 2 }}>
|
|
<Grid item xs={6} sm={3}>
|
|
<Box sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="primary">
|
|
{crawlEstimate.total_files?.toLocaleString() || '0'}
|
|
</Typography>
|
|
<Typography variant="body2">Total Files</Typography>
|
|
</Box>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<Box sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="success.main">
|
|
{crawlEstimate.total_supported_files?.toLocaleString() || '0'}
|
|
</Typography>
|
|
<Typography variant="body2">Supported Files</Typography>
|
|
</Box>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<Box sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="warning.main">
|
|
{crawlEstimate.total_estimated_time_hours?.toFixed(1) || '0'}h
|
|
</Typography>
|
|
<Typography variant="body2">Estimated Time</Typography>
|
|
</Box>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<Box sx={{ textAlign: 'center' }}>
|
|
<Typography variant="h4" color="info.main">
|
|
{crawlEstimate.total_size_mb ? (crawlEstimate.total_size_mb / 1024).toFixed(1) : '0'}GB
|
|
</Typography>
|
|
<Typography variant="body2">Total Size</Typography>
|
|
</Box>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{crawlEstimate.folders && crawlEstimate.folders.length > 0 && (
|
|
<TableContainer component={Paper} sx={{ borderRadius: 2 }}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Folder</TableCell>
|
|
<TableCell align="right">Total Files</TableCell>
|
|
<TableCell align="right">Supported</TableCell>
|
|
<TableCell align="right">Est. Time</TableCell>
|
|
<TableCell align="right">Size (MB)</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{crawlEstimate.folders.map((folder: any) => (
|
|
<TableRow key={folder.path}>
|
|
<TableCell>{folder.path}</TableCell>
|
|
<TableCell align="right">{folder.total_files?.toLocaleString() || '0'}</TableCell>
|
|
<TableCell align="right">{folder.supported_files?.toLocaleString() || '0'}</TableCell>
|
|
<TableCell align="right">{folder.estimated_time_hours?.toFixed(1) || '0'}h</TableCell>
|
|
<TableCell align="right">{folder.total_size_mb?.toFixed(1) || '0'}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</Paper>
|
|
)}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
|
|
{formData.source_type === 'local_folder' && (
|
|
<Paper
|
|
sx={{
|
|
p: 3,
|
|
borderRadius: 3,
|
|
bgcolor: alpha(theme.palette.warning.main, 0.03),
|
|
border: `1px solid ${alpha(theme.palette.warning.main, 0.1)}`,
|
|
}}
|
|
>
|
|
<Stack spacing={3}>
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<FolderIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
Local Folder Configuration
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
<Typography variant="body2">
|
|
Monitor local filesystem directories for new documents.
|
|
Ensure the application has read access to the specified paths.
|
|
</Typography>
|
|
</Alert>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={formData.recursive}
|
|
onChange={(e) => setFormData({ ...formData, recursive: e.target.checked })}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
Recursive Scanning
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Scan subdirectories recursively
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={formData.follow_symlinks}
|
|
onChange={(e) => setFormData({ ...formData, follow_symlinks: e.target.checked })}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
Follow Symbolic Links
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Follow symlinks when scanning directories
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={formData.auto_sync}
|
|
onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
Enable Automatic Sync
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Automatically scan for new files on a schedule
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
{formData.auto_sync && (
|
|
<TextField
|
|
fullWidth
|
|
type="number"
|
|
label="Sync Interval (minutes)"
|
|
value={formData.sync_interval_minutes}
|
|
onChange={(e) => setFormData({ ...formData, sync_interval_minutes: parseInt(e.target.value) || 60 })}
|
|
inputProps={{ min: 15, max: 1440 }}
|
|
helperText="How often to scan for new files (15 min - 24 hours)"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
)}
|
|
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
{/* Folder Management */}
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
|
color: theme.palette.secondary.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<FolderIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
Directories to Monitor
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Specify which local directories to scan for files. Use absolute paths.
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} mb={2}>
|
|
<TextField
|
|
label="Add Directory Path"
|
|
value={newFolder}
|
|
onChange={(e) => setNewFolder(e.target.value)}
|
|
placeholder="/home/user/Documents"
|
|
sx={{
|
|
flexGrow: 1,
|
|
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={addFolder}
|
|
disabled={!newFolder}
|
|
sx={{ borderRadius: 2, px: 3 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ mb: 3 }}>
|
|
{formData.watch_folders.map((folder, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={folder}
|
|
onDelete={() => removeFolder(folder)}
|
|
sx={{
|
|
mr: 1,
|
|
mb: 1,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
|
color: theme.palette.secondary.main,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{/* File Extensions */}
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<ExtensionIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
File Extensions
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
File types to monitor and process with OCR.
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} mb={2}>
|
|
<TextField
|
|
label="Add Extension"
|
|
value={newExtension}
|
|
onChange={(e) => setNewExtension(e.target.value)}
|
|
placeholder="docx"
|
|
sx={{
|
|
flexGrow: 1,
|
|
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={addFileExtension}
|
|
disabled={!newExtension}
|
|
sx={{ borderRadius: 2, px: 3 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ mb: 3 }}>
|
|
{formData.file_extensions.map((extension, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={extension}
|
|
onDelete={() => removeFileExtension(extension)}
|
|
sx={{
|
|
mr: 1,
|
|
mb: 1,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
|
|
{formData.source_type === 's3' && (
|
|
<Paper
|
|
sx={{
|
|
p: 3,
|
|
borderRadius: 3,
|
|
bgcolor: alpha(theme.palette.success.main, 0.03),
|
|
border: `1px solid ${alpha(theme.palette.success.main, 0.1)}`,
|
|
}}
|
|
>
|
|
<Stack spacing={3}>
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.success.main, 0.1),
|
|
color: theme.palette.success.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<CloudIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
S3 Compatible Storage Configuration
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
<Typography variant="body2">
|
|
Connect to AWS S3, MinIO, or any S3-compatible storage service.
|
|
For MinIO, provide the endpoint URL of your server.
|
|
</Typography>
|
|
</Alert>
|
|
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
fullWidth
|
|
label="Bucket Name"
|
|
value={formData.bucket_name}
|
|
onChange={(e) => setFormData({ ...formData, bucket_name: e.target.value })}
|
|
placeholder="my-documents-bucket"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
fullWidth
|
|
label="Region"
|
|
value={formData.region}
|
|
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
|
placeholder="us-east-1"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
fullWidth
|
|
label="Access Key ID"
|
|
value={formData.access_key_id}
|
|
onChange={(e) => setFormData({ ...formData, access_key_id: e.target.value })}
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<TextField
|
|
fullWidth
|
|
label="Secret Access Key"
|
|
type="password"
|
|
value={formData.secret_access_key}
|
|
onChange={(e) => setFormData({ ...formData, secret_access_key: e.target.value })}
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Endpoint URL (Optional)"
|
|
value={formData.endpoint_url}
|
|
onChange={(e) => setFormData({ ...formData, endpoint_url: e.target.value })}
|
|
placeholder="https://minio.example.com (for MinIO/S3-compatible services)"
|
|
helperText="Leave empty for AWS S3, or provide custom endpoint for MinIO/other S3-compatible storage"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Object Key Prefix (Optional)"
|
|
value={formData.prefix}
|
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
|
placeholder="documents/"
|
|
helperText="Optional prefix to limit scanning to specific object keys"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={formData.auto_sync}
|
|
onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
Enable Automatic Sync
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Automatically check for new objects on a schedule
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
{formData.auto_sync && (
|
|
<TextField
|
|
fullWidth
|
|
type="number"
|
|
label="Sync Interval (minutes)"
|
|
value={formData.sync_interval_minutes}
|
|
onChange={(e) => setFormData({ ...formData, sync_interval_minutes: parseInt(e.target.value) || 60 })}
|
|
inputProps={{ min: 15, max: 1440 }}
|
|
helperText="How often to check for new objects (15 min - 24 hours)"
|
|
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
|
/>
|
|
)}
|
|
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
{/* Folder Management (prefixes for S3) */}
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
|
color: theme.palette.secondary.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<FolderIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
Object Prefixes to Monitor
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Specify which object prefixes (like folders) to scan for files.
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} mb={2}>
|
|
<TextField
|
|
label="Add Object Prefix"
|
|
value={newFolder}
|
|
onChange={(e) => setNewFolder(e.target.value)}
|
|
placeholder="documents/"
|
|
sx={{
|
|
flexGrow: 1,
|
|
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={addFolder}
|
|
disabled={!newFolder}
|
|
sx={{ borderRadius: 2, px: 3 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ mb: 3 }}>
|
|
{formData.watch_folders.map((folder, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={folder}
|
|
onDelete={() => removeFolder(folder)}
|
|
sx={{
|
|
mr: 1,
|
|
mb: 1,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
|
color: theme.palette.secondary.main,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{/* File Extensions */}
|
|
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
width: 32,
|
|
height: 32,
|
|
}}
|
|
>
|
|
<ExtensionIcon />
|
|
</Avatar>
|
|
<Typography variant="h6" fontWeight="medium">
|
|
File Extensions
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
File types to sync and process with OCR.
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} mb={2}>
|
|
<TextField
|
|
label="Add Extension"
|
|
value={newExtension}
|
|
onChange={(e) => setNewExtension(e.target.value)}
|
|
placeholder="docx"
|
|
sx={{
|
|
flexGrow: 1,
|
|
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={addFileExtension}
|
|
disabled={!newExtension}
|
|
sx={{ borderRadius: 2, px: 3 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ mb: 3 }}>
|
|
{formData.file_extensions.map((extension, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={extension}
|
|
onDelete={() => removeFileExtension(extension)}
|
|
sx={{
|
|
mr: 1,
|
|
mb: 1,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
|
color: theme.palette.warning.main,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={formData.enabled}
|
|
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
Source Enabled
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Enable this source for syncing
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
</Stack>
|
|
</DialogContent>
|
|
|
|
<DialogActions sx={{ p: 4, pt: 2 }}>
|
|
<Button
|
|
onClick={() => setDialogOpen(false)}
|
|
sx={{ borderRadius: 2 }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{(formData.source_type === 'webdav' || formData.source_type === 'local_folder' || formData.source_type === 's3') && (
|
|
<Button
|
|
onClick={handleTestConnection}
|
|
disabled={testingConnection ||
|
|
(formData.source_type === 'webdav' && (!formData.server_url || !formData.username)) ||
|
|
(formData.source_type === 'local_folder' && formData.watch_folders.length === 0) ||
|
|
(formData.source_type === 's3' && (!formData.bucket_name || !formData.access_key_id || !formData.secret_access_key))
|
|
}
|
|
startIcon={testingConnection ? <CircularProgress size={20} /> : <SecurityIcon />}
|
|
sx={{ borderRadius: 2 }}
|
|
>
|
|
Test Connection
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={handleSaveSource}
|
|
variant="contained"
|
|
sx={{
|
|
borderRadius: 2,
|
|
px: 4,
|
|
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
|
}}
|
|
>
|
|
{editingSource ? 'Save Changes' : 'Create Source'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Snackbar */}
|
|
<Snackbar
|
|
open={snackbar.open}
|
|
autoHideDuration={6000}
|
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
>
|
|
<Alert
|
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
severity={snackbar.severity}
|
|
sx={{
|
|
width: '100%',
|
|
borderRadius: 3,
|
|
}}
|
|
>
|
|
{snackbar.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
|
|
{/* Custom CSS for animations */}
|
|
<style>{`
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
`}</style>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default SourcesPage; |