backstory/frontend/src/components/DocumentManager.tsx

497 lines
16 KiB
TypeScript

import React, { useState, useEffect, JSX } from 'react';
import {
Box,
Button,
Grid,
useMediaQuery,
Typography,
Card,
CardContent,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Switch,
FormControlLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
Divider,
Paper,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { CloudUpload, Edit, Delete, Visibility, Close } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types';
import { BackstoryElementProps } from './BackstoryTab';
import { useAppState } from 'hooks/GlobalContext';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
const theme = useTheme();
const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { user, apiClient } = useAuth();
const [documents, setDocuments] = useState<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>('');
const [isViewingContent, setIsViewingContent] = useState(false);
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
const [editingName, setEditingName] = useState('');
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
// Check if user is a candidate
const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null;
// Load documents on component mount
useEffect(() => {
const loadDocuments = async (): Promise<void> => {
try {
const results = await apiClient.getCandidateDocuments();
setDocuments(results.documents);
} catch (error) {
console.error(error);
setSnack('Failed to load documents', 'error');
}
};
if (candidate) {
loadDocuments();
}
}, [candidate, apiClient, setSnack]);
// Handle document upload
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case 'pdf':
docType = 'pdf';
break;
case 'docx':
docType = 'docx';
break;
case 'md':
docType = 'markdown';
break;
case 'txt':
docType = 'txt';
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
// Upload file (replace with actual API call)
const controller = apiClient.uploadCandidateDocument(
file,
{ includeInRag: true, isJobDocument: false },
{
onError: error => {
console.error(error);
setSnack(error.content, 'error');
},
}
);
const result = await controller.promise;
if (result && result.document) {
setDocuments(prev => [...prev, result.document]);
setSnack(`Document uploaded: ${file.name}`, 'success');
}
// Reset file input
e.target.value = '';
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
}
}
};
// Handle document deletion
const handleDeleteDocument = async (document: Types.Document): Promise<void> => {
try {
// Call API to delete document
await apiClient.deleteCandidateDocument(document);
setDocuments(prev => prev.filter(doc => doc.id !== document.id));
setSnack('Document deleted successfully', 'success');
// Close content view if this document was being viewed
if (selectedDocument?.id === document.id) {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}
} catch (error) {
setSnack('Failed to delete document', 'error');
}
};
// Handle RAG flag toggle
const handleRAGToggle = async (
document: Types.Document,
includeInRag: boolean
): Promise<void> => {
try {
document.options = { includeInRag };
// Call API to update RAG flag
await apiClient.updateCandidateDocument(document);
setDocuments(prev =>
prev.map(doc => (doc.id === document.id ? { ...doc, includeInRag } : doc))
);
setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success');
} catch (error) {
setSnack('Failed to update RAG setting', 'error');
}
};
// Handle document rename
const handleRenameDocument = async (document: Types.Document, newName: string): Promise<void> => {
if (!newName.trim()) {
setSnack('Document name cannot be empty', 'error');
return;
}
try {
// Call API to rename document
document.filename = newName;
await apiClient.updateCandidateDocument(document);
setDocuments(prev =>
prev.map(doc => (doc.id === document.id ? { ...doc, filename: newName.trim() } : doc))
);
setSnack('Document renamed successfully', 'success');
setIsRenameDialogOpen(false);
setEditingDocument(null);
setEditingName('');
} catch (error) {
setSnack('Failed to rename document', 'error');
}
};
// Handle document content viewing
const handleViewDocument = async (document: Types.Document): Promise<void> => {
try {
setSelectedDocument(document);
setIsViewingContent(true);
// Call API to get document content
const result = await apiClient.getCandidateDocumentText(document);
setDocumentContent(result.content);
} catch (error) {
setSnack('Failed to load document content', 'error');
setIsViewingContent(false);
}
};
// Start rename process
const startRename = (document: Types.Document, currentName: string): void => {
setEditingDocument(document);
setEditingName(currentName);
setIsRenameDialogOpen(true);
};
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Get file type color
const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => {
switch (type) {
case 'pdf':
return 'primary';
case 'docx':
return 'secondary';
case 'txt':
return 'success';
case 'md':
return 'warning';
default:
return 'primary';
}
};
if (!candidate) {
return <Box>You must be logged in as a candidate to view this content.</Box>;
}
return (
<>
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
width: '100%',
verticalAlign: 'center',
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography>
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? 'small' : 'medium'}
>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box>
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3,
}}
>
No additional documents uploaded
</Typography>
) : (
<List sx={{ width: '100%' }}>
{documents.map((doc, index) => (
<React.Fragment key={doc.id}>
{index > 0 && <Divider />}
<ListItem sx={{ px: 0 }}>
<ListItemText
primary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<Typography
variant="body1"
sx={{
wordBreak: 'break-word',
fontSize: { xs: '0.9rem', sm: '1rem' },
}}
>
{doc.filename}
</Typography>
<Chip
label={doc.type.toUpperCase()}
size="small"
color={getFileTypeColor(doc.type)}
/>
{doc.options?.includeInRag && (
<Chip label="RAG" size="small" color="success" variant="outlined" />
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.options?.includeInRag}
onChange={(e): void => {
handleRAGToggle(doc, e.target.checked);
}}
size="small"
/>
}
label={<Typography variant="caption">Include in RAG</Typography>}
/>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={(): void => {
handleViewDocument(doc);
}}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={(): void => {
startRename(doc, doc.filename);
}}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={(): void => {
handleDeleteDocument(doc);
}}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Grid>
{/* Document Content Viewer */}
{isViewingContent && (
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
<IconButton
size="small"
onClick={(): void => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}}
>
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50',
}}
>
<pre
style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{documentContent || 'Loading content...'}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
{/* Rename Dialog */}
<Dialog
open={isRenameDialogOpen}
onClose={(): void => {
setIsRenameDialogOpen(false);
}}
maxWidth="sm"
fullWidth
>
<DialogTitle>Rename Document</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e): void => {
setEditingName(e.target.value);
}}
onKeyUp={(e): void => {
if (e.key === 'Enter' && editingDocument) {
handleRenameDocument(editingDocument, editingName);
}
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={(): void => {
setIsRenameDialogOpen(false);
}}
>
Cancel
</Button>
<Button
onClick={(): void => {
editingDocument && handleRenameDocument(editingDocument, editingName);
}}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</>
);
};
export { DocumentManager };