444 lines
14 KiB
JavaScript
444 lines
14 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Grid,
|
|
Card,
|
|
CardContent,
|
|
CardActions,
|
|
Button,
|
|
Chip,
|
|
IconButton,
|
|
ToggleButton,
|
|
ToggleButtonGroup,
|
|
TextField,
|
|
InputAdornment,
|
|
Stack,
|
|
Menu,
|
|
MenuItem,
|
|
ListItemIcon,
|
|
ListItemText,
|
|
Divider,
|
|
CircularProgress,
|
|
Alert,
|
|
} from '@mui/material';
|
|
import {
|
|
GridView as GridViewIcon,
|
|
ViewList as ListViewIcon,
|
|
Search as SearchIcon,
|
|
FilterList as FilterIcon,
|
|
Sort as SortIcon,
|
|
Download as DownloadIcon,
|
|
PictureAsPdf as PdfIcon,
|
|
Image as ImageIcon,
|
|
Description as DocIcon,
|
|
TextSnippet as TextIcon,
|
|
MoreVert as MoreIcon,
|
|
CalendarToday as DateIcon,
|
|
Storage as SizeIcon,
|
|
Visibility as ViewIcon,
|
|
} from '@mui/icons-material';
|
|
import { documentService } from '../services/api';
|
|
|
|
const DocumentsPage = () => {
|
|
const navigate = useNavigate();
|
|
const [documents, setDocuments] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [viewMode, setViewMode] = useState('grid');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sortBy, setSortBy] = useState('created_at');
|
|
const [sortOrder, setSortOrder] = useState('desc');
|
|
|
|
// Menu states
|
|
const [sortMenuAnchor, setSortMenuAnchor] = useState(null);
|
|
const [docMenuAnchor, setDocMenuAnchor] = useState(null);
|
|
const [selectedDoc, setSelectedDoc] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchDocuments();
|
|
}, []);
|
|
|
|
const fetchDocuments = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await documentService.list(100, 0);
|
|
setDocuments(response.data);
|
|
} catch (err) {
|
|
setError('Failed to load documents');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (doc) => {
|
|
try {
|
|
const response = await documentService.download(doc.id);
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', doc.original_filename);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
}
|
|
};
|
|
|
|
const getFileIcon = (mimeType) => {
|
|
if (mimeType.includes('pdf')) return <PdfIcon color="error" />;
|
|
if (mimeType.includes('image')) return <ImageIcon color="primary" />;
|
|
if (mimeType.includes('text')) return <TextIcon color="info" />;
|
|
return <DocIcon color="secondary" />;
|
|
};
|
|
|
|
const formatFileSize = (bytes) => {
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
if (bytes === 0) return '0 Bytes';
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
const filteredDocuments = documents.filter(doc =>
|
|
doc.original_filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
);
|
|
|
|
const sortedDocuments = [...filteredDocuments].sort((a, b) => {
|
|
let aValue = a[sortBy];
|
|
let bValue = b[sortBy];
|
|
|
|
if (sortBy === 'created_at') {
|
|
aValue = new Date(aValue);
|
|
bValue = new Date(bValue);
|
|
}
|
|
|
|
if (sortOrder === 'asc') {
|
|
return aValue > bValue ? 1 : -1;
|
|
} else {
|
|
return aValue < bValue ? 1 : -1;
|
|
}
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Alert severity="error" sx={{ m: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ p: 3 }}>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 4 }}>
|
|
<Typography
|
|
variant="h4"
|
|
sx={{
|
|
fontWeight: 800,
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
backgroundClip: 'text',
|
|
WebkitBackgroundClip: 'text',
|
|
color: 'transparent',
|
|
mb: 1,
|
|
}}
|
|
>
|
|
Documents
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary">
|
|
Manage and explore your document library
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Toolbar */}
|
|
<Box sx={{
|
|
mb: 3,
|
|
display: 'flex',
|
|
gap: 2,
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
}}>
|
|
{/* Search */}
|
|
<TextField
|
|
placeholder="Search documents..."
|
|
variant="outlined"
|
|
size="small"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon color="action" />
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
sx={{ minWidth: 300, flexGrow: 1 }}
|
|
/>
|
|
|
|
{/* View Toggle */}
|
|
<ToggleButtonGroup
|
|
value={viewMode}
|
|
exclusive
|
|
onChange={(e, newView) => newView && setViewMode(newView)}
|
|
size="small"
|
|
>
|
|
<ToggleButton value="grid">
|
|
<GridViewIcon />
|
|
</ToggleButton>
|
|
<ToggleButton value="list">
|
|
<ListViewIcon />
|
|
</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
{/* Sort Button */}
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<SortIcon />}
|
|
onClick={(e) => setSortMenuAnchor(e.currentTarget)}
|
|
size="small"
|
|
>
|
|
Sort
|
|
</Button>
|
|
</Box>
|
|
|
|
{/* Sort Menu */}
|
|
<Menu
|
|
anchorEl={sortMenuAnchor}
|
|
open={Boolean(sortMenuAnchor)}
|
|
onClose={() => setSortMenuAnchor(null)}
|
|
>
|
|
<MenuItem onClick={() => { setSortBy('created_at'); setSortOrder('desc'); setSortMenuAnchor(null); }}>
|
|
<ListItemIcon><DateIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Newest First</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => { setSortBy('created_at'); setSortOrder('asc'); setSortMenuAnchor(null); }}>
|
|
<ListItemIcon><DateIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Oldest First</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => { setSortBy('original_filename'); setSortOrder('asc'); setSortMenuAnchor(null); }}>
|
|
<ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Name A-Z</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => { setSortBy('original_filename'); setSortOrder('desc'); setSortMenuAnchor(null); }}>
|
|
<ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Name Z-A</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => { setSortBy('file_size'); setSortOrder('desc'); setSortMenuAnchor(null); }}>
|
|
<ListItemIcon><SizeIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Largest First</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => { setSortBy('file_size'); setSortOrder('asc'); setSortMenuAnchor(null); }}>
|
|
<ListItemIcon><SizeIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Smallest First</ListItemText>
|
|
</MenuItem>
|
|
</Menu>
|
|
|
|
{/* Document Menu */}
|
|
<Menu
|
|
anchorEl={docMenuAnchor}
|
|
open={Boolean(docMenuAnchor)}
|
|
onClose={() => setDocMenuAnchor(null)}
|
|
>
|
|
<MenuItem onClick={() => { handleDownload(selectedDoc); setDocMenuAnchor(null); }}>
|
|
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>Download</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => {
|
|
navigate(`/documents/${selectedDoc.id}`);
|
|
setDocMenuAnchor(null);
|
|
}}>
|
|
<ListItemIcon><ViewIcon fontSize="small" /></ListItemIcon>
|
|
<ListItemText>View Details</ListItemText>
|
|
</MenuItem>
|
|
</Menu>
|
|
|
|
{/* Documents Grid/List */}
|
|
{sortedDocuments.length === 0 ? (
|
|
<Box
|
|
sx={{
|
|
textAlign: 'center',
|
|
py: 8,
|
|
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
|
|
borderRadius: 2,
|
|
border: '1px dashed',
|
|
borderColor: 'primary.main',
|
|
}}
|
|
>
|
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
|
No documents found
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{searchQuery ? 'Try adjusting your search terms' : 'Upload your first document to get started'}
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<Grid container spacing={viewMode === 'grid' ? 3 : 1}>
|
|
{sortedDocuments.map((doc) => (
|
|
<Grid
|
|
item
|
|
xs={12}
|
|
sm={viewMode === 'grid' ? 6 : 12}
|
|
md={viewMode === 'grid' ? 4 : 12}
|
|
lg={viewMode === 'grid' ? 3 : 12}
|
|
key={doc.id}
|
|
>
|
|
<Card
|
|
sx={{
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: viewMode === 'list' ? 'row' : 'column',
|
|
transition: 'all 0.2s ease-in-out',
|
|
'&:hover': {
|
|
transform: 'translateY(-4px)',
|
|
boxShadow: (theme) => theme.shadows[4],
|
|
},
|
|
}}
|
|
>
|
|
{viewMode === 'grid' && (
|
|
<Box
|
|
sx={{
|
|
height: 120,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
|
|
}}
|
|
>
|
|
<Box sx={{ fontSize: '3rem' }}>
|
|
{getFileIcon(doc.mime_type)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
<CardContent sx={{ flexGrow: 1, pb: 1 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
|
{viewMode === 'list' && (
|
|
<Box sx={{ mr: 1, mt: 0.5 }}>
|
|
{getFileIcon(doc.mime_type)}
|
|
</Box>
|
|
)}
|
|
|
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
|
<Typography
|
|
variant="h6"
|
|
sx={{
|
|
fontSize: '1rem',
|
|
fontWeight: 600,
|
|
mb: 1,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
title={doc.original_filename}
|
|
>
|
|
{doc.original_filename}
|
|
</Typography>
|
|
|
|
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 0.5 }}>
|
|
<Chip
|
|
label={formatFileSize(doc.file_size)}
|
|
size="small"
|
|
variant="outlined"
|
|
/>
|
|
{doc.has_ocr_text && (
|
|
<Chip
|
|
label="OCR"
|
|
size="small"
|
|
color="success"
|
|
variant="outlined"
|
|
/>
|
|
)}
|
|
</Stack>
|
|
|
|
{doc.tags.length > 0 && (
|
|
<Stack direction="row" spacing={0.5} sx={{ mb: 1, flexWrap: 'wrap' }}>
|
|
{doc.tags.slice(0, 3).map((tag, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={tag}
|
|
size="small"
|
|
color="primary"
|
|
variant="outlined"
|
|
sx={{ fontSize: '0.7rem', height: '20px' }}
|
|
/>
|
|
))}
|
|
{doc.tags.length > 3 && (
|
|
<Chip
|
|
label={`+${doc.tags.length - 3}`}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{ fontSize: '0.7rem', height: '20px' }}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
|
|
<Typography variant="caption" color="text.secondary">
|
|
{formatDate(doc.created_at)}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
setSelectedDoc(doc);
|
|
setDocMenuAnchor(e.currentTarget);
|
|
}}
|
|
>
|
|
<MoreIcon />
|
|
</IconButton>
|
|
</Box>
|
|
</CardContent>
|
|
|
|
{viewMode === 'grid' && (
|
|
<CardActions sx={{ pt: 0 }}>
|
|
<Button
|
|
size="small"
|
|
startIcon={<DownloadIcon />}
|
|
onClick={() => handleDownload(doc)}
|
|
fullWidth
|
|
>
|
|
Download
|
|
</Button>
|
|
</CardActions>
|
|
)}
|
|
</Card>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Results count */}
|
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Showing {sortedDocuments.length} of {documents.length} documents
|
|
{searchQuery && ` matching "${searchQuery}"`}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default DocumentsPage; |