696 lines
22 KiB
TypeScript
696 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
Button,
|
|
Chip,
|
|
IconButton,
|
|
TextField,
|
|
InputAdornment,
|
|
Stack,
|
|
CircularProgress,
|
|
Alert,
|
|
Pagination,
|
|
FormControl,
|
|
InputLabel,
|
|
Select,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Checkbox,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Paper,
|
|
Tooltip,
|
|
MenuItem,
|
|
Breadcrumbs,
|
|
Link,
|
|
} from '@mui/material';
|
|
import {
|
|
Search as SearchIcon,
|
|
FilterList as FilterIcon,
|
|
Delete as DeleteIcon,
|
|
Block as BlockIcon,
|
|
Refresh as RefreshIcon,
|
|
Folder as FolderIcon,
|
|
Cloud as CloudIcon,
|
|
Computer as ComputerIcon,
|
|
Storage as StorageIcon,
|
|
CalendarToday as DateIcon,
|
|
ArrowBack as ArrowBackIcon,
|
|
RestoreFromTrash as RestoreFromTrashIcon,
|
|
} from '@mui/icons-material';
|
|
import { format, formatDistanceToNow } from 'date-fns';
|
|
import { useNotifications } from '../contexts/NotificationContext';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
|
|
interface IgnoredFile {
|
|
id: string;
|
|
file_hash: string;
|
|
filename: string;
|
|
original_filename: string;
|
|
file_path: string;
|
|
file_size: number;
|
|
mime_type: string;
|
|
source_type?: string;
|
|
source_path?: string;
|
|
source_identifier?: string;
|
|
ignored_at: string;
|
|
ignored_by: string;
|
|
ignored_by_username?: string;
|
|
reason?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface IgnoredFilesStats {
|
|
total_ignored_files: number;
|
|
by_source_type: Array<{
|
|
source_type?: string;
|
|
count: number;
|
|
total_size_bytes: number;
|
|
}>;
|
|
total_size_bytes: number;
|
|
most_recent_ignored_at?: string;
|
|
}
|
|
|
|
const IgnoredFilesPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const [ignoredFiles, setIgnoredFiles] = useState<IgnoredFile[]>([]);
|
|
const [stats, setStats] = useState<IgnoredFilesStats | null>(null);
|
|
const [sources, setSources] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [sourceTypeFilter, setSourceTypeFilter] = useState('');
|
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
const [bulkDeleteDialog, setBulkDeleteDialog] = useState(false);
|
|
const [deletingFiles, setDeletingFiles] = useState(false);
|
|
const { addNotification } = useNotifications();
|
|
|
|
// URL parameters for filtering
|
|
const sourceTypeParam = searchParams.get('sourceType');
|
|
const sourceNameParam = searchParams.get('sourceName');
|
|
const sourceIdParam = searchParams.get('sourceId');
|
|
|
|
const pageSize = 25;
|
|
|
|
const fetchIgnoredFiles = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
throw new Error('No authentication token found');
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
limit: pageSize.toString(),
|
|
offset: ((page - 1) * pageSize).toString(),
|
|
});
|
|
|
|
if (searchTerm) {
|
|
params.append('filename', searchTerm);
|
|
}
|
|
|
|
if (sourceTypeFilter) {
|
|
params.append('source_type', sourceTypeFilter);
|
|
}
|
|
|
|
if (sourceIdParam) {
|
|
params.append('source_identifier', sourceIdParam);
|
|
}
|
|
|
|
const response = await fetch(`/api/ignored-files?${params}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch ignored files: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setIgnoredFiles(data.ignored_files);
|
|
setTotalPages(Math.ceil(data.total / pageSize));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load ignored files');
|
|
console.error('Error fetching ignored files:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return;
|
|
|
|
const response = await fetch('/api/ignored-files/stats', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setStats(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching stats:', err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Set initial filters from URL params
|
|
if (sourceTypeParam) {
|
|
setSourceTypeFilter(sourceTypeParam);
|
|
}
|
|
fetchSources();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchIgnoredFiles();
|
|
fetchStats();
|
|
}, [page, searchTerm, sourceTypeFilter]);
|
|
|
|
const fetchSources = async () => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return;
|
|
|
|
const response = await fetch('/api/sources', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setSources(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching sources:', err);
|
|
}
|
|
};
|
|
|
|
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSearchTerm(event.target.value);
|
|
setPage(1);
|
|
};
|
|
|
|
const handleSourceTypeFilter = (event: any) => {
|
|
setSourceTypeFilter(event.target.value);
|
|
setPage(1);
|
|
};
|
|
|
|
const handleSelectFile = (fileId: string) => {
|
|
const newSelected = new Set(selectedFiles);
|
|
if (newSelected.has(fileId)) {
|
|
newSelected.delete(fileId);
|
|
} else {
|
|
newSelected.add(fileId);
|
|
}
|
|
setSelectedFiles(newSelected);
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectedFiles.size === ignoredFiles.length) {
|
|
setSelectedFiles(new Set());
|
|
} else {
|
|
setSelectedFiles(new Set(ignoredFiles.map(file => file.id)));
|
|
}
|
|
};
|
|
|
|
const handleDeleteSelected = async () => {
|
|
if (selectedFiles.size === 0) return;
|
|
|
|
setDeletingFiles(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
throw new Error('No authentication token found');
|
|
}
|
|
|
|
const response = await fetch('/api/ignored-files/bulk-delete', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
ignored_file_ids: Array.from(selectedFiles)
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to delete ignored files: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
addNotification({
|
|
type: 'success',
|
|
title: 'Files Deleted',
|
|
message: data.message
|
|
});
|
|
setSelectedFiles(new Set());
|
|
setBulkDeleteDialog(false);
|
|
fetchIgnoredFiles();
|
|
fetchStats();
|
|
} catch (err) {
|
|
addNotification({
|
|
type: 'error',
|
|
title: 'Delete Failed',
|
|
message: err instanceof Error ? err.message : 'Failed to delete ignored files'
|
|
});
|
|
} finally {
|
|
setDeletingFiles(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteSingle = async (fileId: string) => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) {
|
|
throw new Error('No authentication token found');
|
|
}
|
|
|
|
const response = await fetch(`/api/ignored-files/${fileId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to delete ignored file: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
addNotification({
|
|
type: 'success',
|
|
title: 'Files Deleted',
|
|
message: data.message
|
|
});
|
|
fetchIgnoredFiles();
|
|
fetchStats();
|
|
} catch (err) {
|
|
addNotification({
|
|
type: 'error',
|
|
title: 'Delete Failed',
|
|
message: err instanceof Error ? err.message : 'Failed to delete ignored file'
|
|
});
|
|
}
|
|
};
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getSourceIcon = (sourceType?: string) => {
|
|
switch (sourceType) {
|
|
case 'webdav':
|
|
return <CloudIcon fontSize="small" />;
|
|
case 'local_folder':
|
|
return <ComputerIcon fontSize="small" />;
|
|
case 's3':
|
|
return <StorageIcon fontSize="small" />;
|
|
default:
|
|
return <FolderIcon fontSize="small" />;
|
|
}
|
|
};
|
|
|
|
const getSourceTypeDisplay = (sourceType?: string) => {
|
|
switch (sourceType) {
|
|
case 'webdav':
|
|
return 'WebDAV';
|
|
case 'local_folder':
|
|
return 'Local Folder';
|
|
case 's3':
|
|
return 'S3';
|
|
default:
|
|
return sourceType || 'Unknown';
|
|
}
|
|
};
|
|
|
|
const getSourceNameFromIdentifier = (sourceIdentifier?: string, sourceType?: string) => {
|
|
// Try to find the source name from the sources list
|
|
const source = sources.find(s => s.id === sourceIdentifier || s.name.toLowerCase().includes(sourceIdentifier?.toLowerCase() || ''));
|
|
return source?.name || sourceIdentifier || 'Unknown Source';
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSourceTypeFilter('');
|
|
setSearchTerm('');
|
|
setPage(1);
|
|
navigate('/ignored-files', { replace: true });
|
|
};
|
|
|
|
const uniqueSourceTypes = Array.from(
|
|
new Set(ignoredFiles.map(file => file.source_type).filter(Boolean))
|
|
);
|
|
|
|
return (
|
|
<Box sx={{ p: 3 }}>
|
|
{/* Breadcrumbs and Navigation */}
|
|
<Box sx={{ mb: 3 }}>
|
|
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 2 }}>
|
|
<Link
|
|
color="inherit"
|
|
href="#"
|
|
onClick={() => navigate('/sources')}
|
|
sx={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}
|
|
>
|
|
<StorageIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
|
Sources
|
|
</Link>
|
|
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<BlockIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
|
Ignored Files
|
|
{(sourceTypeParam || sourceNameParam) && (
|
|
<Chip
|
|
label={sourceNameParam ? `${sourceNameParam}` : `${getSourceTypeDisplay(sourceTypeParam)} Sources`}
|
|
size="small"
|
|
onDelete={clearFilters}
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
)}
|
|
</Typography>
|
|
</Breadcrumbs>
|
|
</Box>
|
|
|
|
<Typography variant="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
|
|
<BlockIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
|
Ignored Files
|
|
{(sourceTypeParam || sourceNameParam || sourceIdParam) && (
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
startIcon={<ArrowBackIcon />}
|
|
onClick={clearFilters}
|
|
sx={{ ml: 2, textTransform: 'none' }}
|
|
>
|
|
View All
|
|
</Button>
|
|
)}
|
|
</Typography>
|
|
|
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
{sourceTypeParam || sourceNameParam || sourceIdParam
|
|
? `Files from ${sourceNameParam || getSourceTypeDisplay(sourceTypeParam)} sources that have been deleted and will be ignored during future syncs.`
|
|
: 'Files that have been deleted and will be ignored during future syncs from their sources.'
|
|
}
|
|
</Typography>
|
|
|
|
{/* Statistics Cards */}
|
|
{stats && (
|
|
<Box sx={{ mb: 3 }}>
|
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
|
<Card variant="outlined">
|
|
<CardContent>
|
|
<Typography variant="h6" color="primary">
|
|
{stats.total_ignored_files}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Total Ignored Files
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
<Card variant="outlined">
|
|
<CardContent>
|
|
<Typography variant="h6" color="primary">
|
|
{formatFileSize(stats.total_size_bytes)}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Total Size
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
{stats.most_recent_ignored_at && (
|
|
<Card variant="outlined">
|
|
<CardContent>
|
|
<Typography variant="h6" color="primary">
|
|
{formatDistanceToNow(new Date(stats.most_recent_ignored_at), { addSuffix: true })}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Most Recent
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Filters and Search */}
|
|
<Card variant="outlined" sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="center">
|
|
<TextField
|
|
placeholder="Search filenames..."
|
|
variant="outlined"
|
|
size="small"
|
|
value={searchTerm}
|
|
onChange={handleSearch}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon />
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
sx={{ flexGrow: 1 }}
|
|
/>
|
|
|
|
<FormControl size="small" sx={{ minWidth: 150 }}>
|
|
<InputLabel>Source Type</InputLabel>
|
|
<Select
|
|
value={sourceTypeFilter}
|
|
label="Source Type"
|
|
onChange={handleSourceTypeFilter}
|
|
>
|
|
<MenuItem value="">All Sources</MenuItem>
|
|
{uniqueSourceTypes.map(sourceType => (
|
|
<MenuItem key={sourceType} value={sourceType}>
|
|
{getSourceTypeDisplay(sourceType)}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<RefreshIcon />}
|
|
onClick={() => {
|
|
fetchIgnoredFiles();
|
|
fetchStats();
|
|
}}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Bulk Actions */}
|
|
{selectedFiles.size > 0 && (
|
|
<Card variant="outlined" sx={{ mb: 2, bgcolor: 'action.selected' }}>
|
|
<CardContent>
|
|
<Stack direction="row" spacing={2} alignItems="center">
|
|
<Typography variant="body2">
|
|
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
color="success"
|
|
startIcon={<RestoreFromTrashIcon />}
|
|
onClick={() => setBulkDeleteDialog(true)}
|
|
size="small"
|
|
>
|
|
Remove from Ignored List
|
|
</Button>
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Files Table */}
|
|
<Card variant="outlined">
|
|
<TableContainer>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
indeterminate={selectedFiles.size > 0 && selectedFiles.size < ignoredFiles.length}
|
|
checked={ignoredFiles.length > 0 && selectedFiles.size === ignoredFiles.length}
|
|
onChange={handleSelectAll}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>Filename</TableCell>
|
|
<TableCell>Source</TableCell>
|
|
<TableCell>Size</TableCell>
|
|
<TableCell>Ignored Date</TableCell>
|
|
<TableCell>Reason</TableCell>
|
|
<TableCell>Actions</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} align="center">
|
|
<CircularProgress />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : error ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7}>
|
|
<Alert severity="error">{error}</Alert>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : ignoredFiles.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} align="center">
|
|
<Typography variant="body2" color="text.secondary">
|
|
No ignored files found
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
ignoredFiles.map((file) => (
|
|
<TableRow key={file.id} hover>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
checked={selectedFiles.has(file.id)}
|
|
onChange={() => handleSelectFile(file.id)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Box>
|
|
<Typography variant="body2" fontWeight="medium">
|
|
{file.filename}
|
|
</Typography>
|
|
{file.filename !== file.original_filename && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
Original: {file.original_filename}
|
|
</Typography>
|
|
)}
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
{file.mime_type}
|
|
</Typography>
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
{getSourceIcon(file.source_type)}
|
|
<Box>
|
|
<Typography variant="body2">
|
|
{getSourceTypeDisplay(file.source_type)}
|
|
</Typography>
|
|
{file.source_path && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{file.source_path}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2">
|
|
{formatFileSize(file.file_size)}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2">
|
|
{format(new Date(file.ignored_at), 'MMM dd, yyyy')}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{formatDistanceToNow(new Date(file.ignored_at), { addSuffix: true })}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2">
|
|
{file.reason || 'No reason provided'}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Stack direction="row" spacing={1}>
|
|
<Tooltip title="Remove from ignored list (allow re-syncing)">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleDeleteSingle(file.id)}
|
|
color="success"
|
|
>
|
|
<RestoreFromTrashIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center' }}>
|
|
<Pagination
|
|
count={totalPages}
|
|
page={page}
|
|
onChange={(_, newPage) => setPage(newPage)}
|
|
color="primary"
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Bulk Delete Confirmation Dialog */}
|
|
<Dialog open={bulkDeleteDialog} onClose={() => setBulkDeleteDialog(false)}>
|
|
<DialogTitle>Confirm Bulk Delete</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>
|
|
Are you sure you want to remove {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} from the ignored list?
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
|
These files will be eligible for syncing again if encountered from their sources. This action allows them to be re-imported during future syncs.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setBulkDeleteDialog(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleDeleteSelected}
|
|
color="success"
|
|
variant="contained"
|
|
disabled={deletingFiles}
|
|
startIcon={deletingFiles ? <CircularProgress size={16} /> : <RestoreFromTrashIcon />}
|
|
>
|
|
Remove from Ignored List
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default IgnoredFilesPage; |