Readur/frontend/src/pages/SettingsPage.tsx

1181 lines
47 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Container,
Typography,
Paper,
Tabs,
Tab,
FormControl,
FormControlLabel,
InputLabel,
Select,
MenuItem,
Button,
Snackbar,
Alert,
TextField,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Card,
CardContent,
Divider,
Switch,
SelectChangeEvent,
Chip,
LinearProgress,
CircularProgress,
} from '@mui/material';
import Grid from '@mui/material/GridLegacy';
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
CloudSync as CloudSyncIcon, Folder as FolderIcon,
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon,
Pause as PauseIcon, Stop as StopIcon } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import api, { queueService } from '../services/api';
interface User {
id: string;
username: string;
email: string;
created_at: string;
}
interface Settings {
ocrLanguage: string;
concurrentOcrJobs: number;
ocrTimeoutSeconds: number;
maxFileSizeMb: number;
allowedFileTypes: string[];
autoRotateImages: boolean;
enableImagePreprocessing: boolean;
searchResultsPerPage: number;
searchSnippetLength: number;
fuzzySearchThreshold: number;
retentionDays: number | null;
enableAutoCleanup: boolean;
enableCompression: boolean;
memoryLimitMb: number;
cpuPriority: string;
enableBackgroundOcr: boolean;
ocrPageSegmentationMode: number;
ocrEngineMode: number;
ocrMinConfidence: number;
ocrDpi: number;
ocrEnhanceContrast: boolean;
ocrRemoveNoise: boolean;
ocrDetectOrientation: boolean;
ocrWhitelistChars: string;
ocrBlacklistChars: string;
ocrBrightnessBoost: number;
ocrContrastMultiplier: number;
ocrNoiseReductionLevel: number;
ocrSharpeningStrength: number;
ocrMorphologicalOperations: boolean;
ocrAdaptiveThresholdWindowSize: number;
ocrHistogramEqualization: boolean;
ocrUpscaleFactor: number;
ocrMaxImageWidth: number;
ocrMaxImageHeight: number;
saveProcessedImages: boolean;
ocrQualityThresholdBrightness: number;
ocrQualityThresholdContrast: number;
ocrQualityThresholdNoise: number;
ocrQualityThresholdSharpness: number;
ocrSkipEnhancement: boolean;
}
interface SnackbarState {
open: boolean;
message: string;
severity: 'success' | 'error' | 'warning' | 'info';
}
interface UserDialogState {
open: boolean;
mode: 'create' | 'edit';
user: User | null;
}
interface UserFormData {
username: string;
email: string;
password: string;
}
interface OcrLanguage {
code: string;
name: string;
}
interface WebDAVFolderInfo {
path: string;
total_files: number;
supported_files: number;
estimated_time_hours: number;
total_size_mb: number;
}
interface WebDAVCrawlEstimate {
folders: WebDAVFolderInfo[];
total_files: number;
total_supported_files: number;
total_estimated_time_hours: number;
total_size_mb: number;
}
interface WebDAVConnectionResult {
success: boolean;
message: string;
server_version?: string;
server_type?: string;
}
// Debounce utility function
function useDebounce<T extends (...args: any[]) => any>(func: T, delay: number): T {
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const debouncedFunc = useCallback((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => func(...args), delay);
}, [func, delay]) as T;
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedFunc;
}
const SettingsPage: React.FC = () => {
const { user: currentUser } = useAuth();
const [tabValue, setTabValue] = useState<number>(0);
const [settings, setSettings] = useState<Settings>({
ocrLanguage: 'eng',
concurrentOcrJobs: 4,
ocrTimeoutSeconds: 300,
maxFileSizeMb: 50,
allowedFileTypes: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
autoRotateImages: true,
enableImagePreprocessing: false,
searchResultsPerPage: 25,
searchSnippetLength: 200,
fuzzySearchThreshold: 0.8,
retentionDays: null,
enableAutoCleanup: false,
enableCompression: false,
memoryLimitMb: 512,
cpuPriority: 'normal',
enableBackgroundOcr: true,
ocrPageSegmentationMode: 3,
ocrEngineMode: 3,
ocrMinConfidence: 30.0,
ocrDpi: 300,
ocrEnhanceContrast: true,
ocrRemoveNoise: true,
ocrDetectOrientation: true,
ocrWhitelistChars: '',
ocrBlacklistChars: '',
ocrBrightnessBoost: 0.0,
ocrContrastMultiplier: 1.0,
ocrNoiseReductionLevel: 1,
ocrSharpeningStrength: 0.0,
ocrMorphologicalOperations: true,
ocrAdaptiveThresholdWindowSize: 15,
ocrHistogramEqualization: false,
ocrUpscaleFactor: 1.0,
ocrMaxImageWidth: 10000,
ocrMaxImageHeight: 10000,
saveProcessedImages: false,
ocrQualityThresholdBrightness: 40.0,
ocrQualityThresholdContrast: 0.15,
ocrQualityThresholdNoise: 0.3,
ocrQualityThresholdSharpness: 0.15,
ocrSkipEnhancement: false,
});
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: '',
severity: 'success'
});
const [userDialog, setUserDialog] = useState<UserDialogState>({
open: false,
mode: 'create',
user: null
});
const [userForm, setUserForm] = useState<UserFormData>({
username: '',
email: '',
password: ''
});
// OCR Admin Controls State
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: 'paused' | 'running' } | null>(null);
const [ocrActionLoading, setOcrActionLoading] = useState(false);
const ocrLanguages: OcrLanguage[] = [
{ code: 'eng', name: 'English' },
{ code: 'spa', name: 'Spanish' },
{ code: 'fra', name: 'French' },
{ code: 'deu', name: 'German' },
{ code: 'ita', name: 'Italian' },
{ code: 'por', name: 'Portuguese' },
{ code: 'rus', name: 'Russian' },
{ code: 'jpn', name: 'Japanese' },
{ code: 'chi_sim', name: 'Chinese (Simplified)' },
{ code: 'chi_tra', name: 'Chinese (Traditional)' },
{ code: 'kor', name: 'Korean' },
{ code: 'ara', name: 'Arabic' },
{ code: 'hin', name: 'Hindi' },
{ code: 'nld', name: 'Dutch' },
{ code: 'pol', name: 'Polish' },
];
useEffect(() => {
fetchSettings();
fetchUsers();
fetchOcrStatus();
}, []);
const fetchSettings = async (): Promise<void> => {
try {
const response = await api.get('/settings');
setSettings({
ocrLanguage: response.data.ocr_language || 'eng',
concurrentOcrJobs: response.data.concurrent_ocr_jobs || 4,
ocrTimeoutSeconds: response.data.ocr_timeout_seconds || 300,
maxFileSizeMb: response.data.max_file_size_mb || 50,
allowedFileTypes: response.data.allowed_file_types || ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
autoRotateImages: response.data.auto_rotate_images !== undefined ? response.data.auto_rotate_images : true,
enableImagePreprocessing: response.data.enable_image_preprocessing !== undefined ? response.data.enable_image_preprocessing : false,
searchResultsPerPage: response.data.search_results_per_page || 25,
searchSnippetLength: response.data.search_snippet_length || 200,
fuzzySearchThreshold: response.data.fuzzy_search_threshold || 0.8,
retentionDays: response.data.retention_days,
enableAutoCleanup: response.data.enable_auto_cleanup || false,
enableCompression: response.data.enable_compression || false,
memoryLimitMb: response.data.memory_limit_mb || 512,
cpuPriority: response.data.cpu_priority || 'normal',
enableBackgroundOcr: response.data.enable_background_ocr !== undefined ? response.data.enable_background_ocr : true,
ocrPageSegmentationMode: response.data.ocr_page_segmentation_mode || 3,
ocrEngineMode: response.data.ocr_engine_mode || 3,
ocrMinConfidence: response.data.ocr_min_confidence || 30.0,
ocrDpi: response.data.ocr_dpi || 300,
ocrEnhanceContrast: response.data.ocr_enhance_contrast !== undefined ? response.data.ocr_enhance_contrast : true,
ocrRemoveNoise: response.data.ocr_remove_noise !== undefined ? response.data.ocr_remove_noise : true,
ocrDetectOrientation: response.data.ocr_detect_orientation !== undefined ? response.data.ocr_detect_orientation : true,
ocrWhitelistChars: response.data.ocr_whitelist_chars || '',
ocrBlacklistChars: response.data.ocr_blacklist_chars || '',
ocrBrightnessBoost: response.data.ocr_brightness_boost || 0.0,
ocrContrastMultiplier: response.data.ocr_contrast_multiplier || 1.0,
ocrNoiseReductionLevel: response.data.ocr_noise_reduction_level || 1,
ocrSharpeningStrength: response.data.ocr_sharpening_strength || 0.0,
ocrMorphologicalOperations: response.data.ocr_morphological_operations !== undefined ? response.data.ocr_morphological_operations : true,
ocrAdaptiveThresholdWindowSize: response.data.ocr_adaptive_threshold_window_size || 15,
ocrHistogramEqualization: response.data.ocr_histogram_equalization || false,
ocrUpscaleFactor: response.data.ocr_upscale_factor || 1.0,
ocrMaxImageWidth: response.data.ocr_max_image_width || 10000,
ocrMaxImageHeight: response.data.ocr_max_image_height || 10000,
saveProcessedImages: response.data.save_processed_images || false,
ocrQualityThresholdBrightness: response.data.ocr_quality_threshold_brightness || 40.0,
ocrQualityThresholdContrast: response.data.ocr_quality_threshold_contrast || 0.15,
ocrQualityThresholdNoise: response.data.ocr_quality_threshold_noise || 0.3,
ocrQualityThresholdSharpness: response.data.ocr_quality_threshold_sharpness || 0.15,
ocrSkipEnhancement: response.data.ocr_skip_enhancement || false,
});
} catch (error: any) {
console.error('Error fetching settings:', error);
if (error.response?.status !== 404) {
showSnackbar('Failed to load settings', 'error');
}
}
};
const fetchUsers = async (): Promise<void> => {
try {
const response = await api.get<User[]>('/users');
setUsers(response.data);
} catch (error: any) {
console.error('Error fetching users:', error);
if (error.response?.status !== 404) {
showSnackbar('Failed to load users', 'error');
}
}
};
const handleSettingsChange = async (key: keyof Settings, value: any): Promise<void> => {
try {
// Convert camelCase to snake_case for API
const snakeCase = (str: string): string => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
const apiKey = snakeCase(key);
// Build the update payload with only the changed field
const updatePayload = { [apiKey]: value };
await api.put('/settings', updatePayload);
// Only update state after successful API call
setSettings(prevSettings => ({ ...prevSettings, [key]: value }));
// Only show success message for non-text inputs to reduce noise
if (typeof value !== 'string') {
showSnackbar('Settings updated successfully', 'success');
}
} catch (error) {
console.error('Error updating settings:', error);
showSnackbar('Failed to update settings', 'error');
}
};
const handleUserSubmit = async (): Promise<void> => {
setLoading(true);
try {
if (userDialog.mode === 'create') {
await api.post('/users', userForm);
showSnackbar('User created successfully', 'success');
} else {
const { password, ...updateData } = userForm;
const payload: any = updateData;
if (password) {
payload.password = password;
}
await api.put(`/users/${userDialog.user?.id}`, payload);
showSnackbar('User updated successfully', 'success');
}
fetchUsers();
handleCloseUserDialog();
} catch (error: any) {
console.error('Error saving user:', error);
showSnackbar(error.response?.data?.message || 'Failed to save user', 'error');
} finally {
setLoading(false);
}
};
const handleDeleteUser = async (userId: string): Promise<void> => {
if (userId === currentUser?.id) {
showSnackbar('You cannot delete your own account', 'error');
return;
}
if (window.confirm('Are you sure you want to delete this user?')) {
setLoading(true);
try {
await api.delete(`/users/${userId}`);
showSnackbar('User deleted successfully', 'success');
fetchUsers();
} catch (error) {
console.error('Error deleting user:', error);
showSnackbar('Failed to delete user', 'error');
} finally {
setLoading(false);
}
}
};
const handleOpenUserDialog = (mode: 'create' | 'edit', user: User | null = null): void => {
setUserDialog({ open: true, mode, user });
if (mode === 'edit' && user) {
setUserForm({ username: user.username, email: user.email, password: '' });
} else {
setUserForm({ username: '', email: '', password: '' });
}
};
const handleCloseUserDialog = (): void => {
setUserDialog({ open: false, mode: 'create', user: null });
setUserForm({ username: '', email: '', password: '' });
};
const showSnackbar = (message: string, severity: SnackbarState['severity']): void => {
setSnackbar({ open: true, message, severity });
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number): void => {
setTabValue(newValue);
};
const handleOcrLanguageChange = (event: SelectChangeEvent<string>): void => {
handleSettingsChange('ocrLanguage', event.target.value);
};
const handleCpuPriorityChange = (event: SelectChangeEvent<string>): void => {
handleSettingsChange('cpuPriority', event.target.value);
};
const handleResultsPerPageChange = (event: SelectChangeEvent<number>): void => {
handleSettingsChange('searchResultsPerPage', event.target.value);
};
const fetchOcrStatus = async (): Promise<void> => {
try {
const response = await queueService.getOcrStatus();
setOcrStatus(response.data);
} catch (error: any) {
console.error('Error fetching OCR status:', error);
// Don't show error for OCR status since it might not be available for non-admin users
}
};
const handlePauseOcr = async (): Promise<void> => {
setOcrActionLoading(true);
try {
await queueService.pauseOcr();
showSnackbar('OCR processing paused successfully', 'success');
fetchOcrStatus(); // Refresh status
} catch (error: any) {
console.error('Error pausing OCR:', error);
if (error.response?.status === 403) {
showSnackbar('Admin access required to pause OCR processing', 'error');
} else {
showSnackbar('Failed to pause OCR processing', 'error');
}
} finally {
setOcrActionLoading(false);
}
};
const handleResumeOcr = async (): Promise<void> => {
setOcrActionLoading(true);
try {
await queueService.resumeOcr();
showSnackbar('OCR processing resumed successfully', 'success');
fetchOcrStatus(); // Refresh status
} catch (error: any) {
console.error('Error resuming OCR:', error);
if (error.response?.status === 403) {
showSnackbar('Admin access required to resume OCR processing', 'error');
} else {
showSnackbar('Failed to resume OCR processing', 'error');
}
} finally {
setOcrActionLoading(false);
}
};
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" sx={{ mb: 4 }}>
Settings
</Typography>
<Paper sx={{ width: '100%' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
<Tab label="General" />
<Tab label="OCR Settings" />
<Tab label="User Management" />
</Tabs>
<Box sx={{ p: 3 }}>
{tabValue === 0 && (
<Box>
<Typography variant="h6" sx={{ mb: 3 }}>
General Settings
</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
OCR Configuration
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>OCR Language</InputLabel>
<Select
value={settings.ocrLanguage}
label="OCR Language"
onChange={handleOcrLanguageChange}
disabled={loading}
>
{ocrLanguages.map((lang) => (
<MenuItem key={lang.code} value={lang.code}>
{lang.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Concurrent OCR Jobs"
value={settings.concurrentOcrJobs}
onChange={(e) => handleSettingsChange('concurrentOcrJobs', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 1, max: 16 }}
helperText="Number of OCR jobs that can run simultaneously"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="OCR Timeout (seconds)"
value={settings.ocrTimeoutSeconds}
onChange={(e) => handleSettingsChange('ocrTimeoutSeconds', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 30, max: 3600 }}
helperText="Maximum time for OCR processing per file"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>CPU Priority</InputLabel>
<Select
value={settings.cpuPriority}
label="CPU Priority"
onChange={handleCpuPriorityChange}
disabled={loading}
>
<MenuItem value="low">Low</MenuItem>
<MenuItem value="normal">Normal</MenuItem>
<MenuItem value="high">High</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Admin OCR Controls */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
<StopIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
OCR Processing Controls (Admin Only)
</Typography>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
Control OCR processing to manage CPU usage and allow users to use the application without performance impact.
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant={ocrStatus?.is_paused ? "outlined" : "contained"}
color={ocrStatus?.is_paused ? "success" : "warning"}
startIcon={ocrActionLoading ? <CircularProgress size={16} /> :
(ocrStatus?.is_paused ? <PlayArrowIcon /> : <PauseIcon />)}
onClick={ocrStatus?.is_paused ? handleResumeOcr : handlePauseOcr}
disabled={ocrActionLoading || loading}
size="large"
>
{ocrActionLoading ? 'Processing...' :
ocrStatus?.is_paused ? 'Resume OCR Processing' : 'Pause OCR Processing'}
</Button>
</Box>
</Grid>
<Grid item xs={12} md={6}>
{ocrStatus && (
<Box>
<Chip
label={`OCR Status: ${ocrStatus.status.toUpperCase()}`}
color={ocrStatus.is_paused ? "warning" : "success"}
variant="outlined"
icon={ocrStatus.is_paused ? <PauseIcon /> : <PlayArrowIcon />}
size="medium"
/>
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'text.secondary' }}>
{ocrStatus.is_paused
? 'OCR processing is paused. No new jobs will be processed.'
: 'OCR processing is active. Documents will be processed automatically.'}
</Typography>
</Box>
)}
</Grid>
</Grid>
{ocrStatus?.is_paused && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
<strong>OCR Processing Paused</strong><br />
New documents will not be processed for OCR text extraction until processing is resumed.
Users can still upload and view documents, but search functionality may be limited.
</Typography>
</Alert>
)}
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
File Processing
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Max File Size (MB)"
value={settings.maxFileSizeMb}
onChange={(e) => handleSettingsChange('maxFileSizeMb', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 1, max: 500 }}
helperText="Maximum allowed file size for uploads"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Memory Limit (MB)"
value={settings.memoryLimitMb}
onChange={(e) => handleSettingsChange('memoryLimitMb', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 128, max: 4096 }}
helperText="Memory limit per OCR job"
/>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.autoRotateImages}
onChange={(e) => handleSettingsChange('autoRotateImages', e.target.checked)}
disabled={loading}
/>
}
label="Auto-rotate Images"
/>
<Typography variant="body2" color="text.secondary">
Automatically detect and correct image orientation
</Typography>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableImagePreprocessing}
onChange={(e) => handleSettingsChange('enableImagePreprocessing', e.target.checked)}
disabled={loading}
/>
}
label="Enable Image Preprocessing"
/>
<Typography variant="body2" color="text.secondary">
Enhance images for better OCR accuracy (deskew, denoise, contrast)
</Typography>
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
Warning: Enabling preprocessing can significantly alter OCR text results and may reduce accuracy for some documents
</Typography>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableBackgroundOcr}
onChange={(e) => handleSettingsChange('enableBackgroundOcr', e.target.checked)}
disabled={loading}
/>
}
label="Enable Background OCR"
/>
<Typography variant="body2" color="text.secondary">
Process OCR in the background after file upload
</Typography>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Search Configuration
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Results Per Page</InputLabel>
<Select
value={settings.searchResultsPerPage}
label="Results Per Page"
onChange={handleResultsPerPageChange}
disabled={loading}
>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={25}>25</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={100}>100</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Snippet Length"
value={settings.searchSnippetLength}
onChange={(e) => handleSettingsChange('searchSnippetLength', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 50, max: 500 }}
helperText="Characters to show in search result previews"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Fuzzy Search Threshold"
value={settings.fuzzySearchThreshold}
onChange={(e) => handleSettingsChange('fuzzySearchThreshold', parseFloat(e.target.value))}
disabled={loading}
inputProps={{ min: 0, max: 1, step: 0.1 }}
helperText="Tolerance for spelling mistakes (0.0-1.0)"
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Storage Management
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Retention Days"
value={settings.retentionDays || ''}
onChange={(e) => handleSettingsChange('retentionDays', e.target.value ? parseInt(e.target.value) : null)}
disabled={loading}
inputProps={{ min: 1 }}
helperText="Auto-delete documents after X days (leave empty to disable)"
/>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableAutoCleanup}
onChange={(e) => handleSettingsChange('enableAutoCleanup', e.target.checked)}
disabled={loading}
/>
}
label="Enable Auto Cleanup"
/>
<Typography variant="body2" color="text.secondary">
Automatically remove orphaned files and clean up storage
</Typography>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableCompression}
onChange={(e) => handleSettingsChange('enableCompression', e.target.checked)}
disabled={loading}
/>
}
label="Enable Compression"
/>
<Typography variant="body2" color="text.secondary">
Compress stored documents to save disk space
</Typography>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
</Box>
)}
{tabValue === 1 && (
<Box>
<Typography variant="h6" sx={{ mb: 3 }}>
OCR Image Processing Settings
</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Enhancement Controls
</Typography>
<Divider sx={{ mb: 2 }} />
<FormControlLabel
control={
<Switch
checked={settings.ocrSkipEnhancement}
onChange={(e) => handleSettingsChange('ocrSkipEnhancement', e.target.checked)}
/>
}
label="Skip All Image Enhancement (Use Original Images Only)"
sx={{ mb: 2 }}
/>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Brightness Boost"
type="number"
value={settings.ocrBrightnessBoost}
onChange={(e) => handleSettingsChange('ocrBrightnessBoost', parseFloat(e.target.value) || 0)}
helperText="Manual brightness adjustment (0 = auto, >0 = boost amount)"
inputProps={{ step: 0.1, min: 0, max: 100 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Contrast Multiplier"
type="number"
value={settings.ocrContrastMultiplier}
onChange={(e) => handleSettingsChange('ocrContrastMultiplier', parseFloat(e.target.value) || 1)}
helperText="Manual contrast adjustment (1.0 = auto, >1.0 = increase)"
inputProps={{ step: 0.1, min: 0.1, max: 5 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Noise Reduction Level</InputLabel>
<Select
value={settings.ocrNoiseReductionLevel}
label="Noise Reduction Level"
onChange={(e) => handleSettingsChange('ocrNoiseReductionLevel', e.target.value as number)}
>
<MenuItem value={0}>None</MenuItem>
<MenuItem value={1}>Light</MenuItem>
<MenuItem value={2}>Moderate</MenuItem>
<MenuItem value={3}>Heavy</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Sharpening Strength"
type="number"
value={settings.ocrSharpeningStrength}
onChange={(e) => handleSettingsChange('ocrSharpeningStrength', parseFloat(e.target.value) || 0)}
helperText="Image sharpening amount (0 = auto, >0 = manual)"
inputProps={{ step: 0.1, min: 0, max: 2 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Quality Thresholds (when to apply enhancements)
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Brightness Threshold"
type="number"
value={settings.ocrQualityThresholdBrightness}
onChange={(e) => handleSettingsChange('ocrQualityThresholdBrightness', parseFloat(e.target.value) || 40)}
helperText="Enhance if brightness below this value (0-255)"
inputProps={{ step: 1, min: 0, max: 255 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Contrast Threshold"
type="number"
value={settings.ocrQualityThresholdContrast}
onChange={(e) => handleSettingsChange('ocrQualityThresholdContrast', parseFloat(e.target.value) || 0.15)}
helperText="Enhance if contrast below this value (0-1)"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Noise Threshold"
type="number"
value={settings.ocrQualityThresholdNoise}
onChange={(e) => handleSettingsChange('ocrQualityThresholdNoise', parseFloat(e.target.value) || 0.3)}
helperText="Enhance if noise above this value (0-1)"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Sharpness Threshold"
type="number"
value={settings.ocrQualityThresholdSharpness}
onChange={(e) => handleSettingsChange('ocrQualityThresholdSharpness', parseFloat(e.target.value) || 0.15)}
helperText="Enhance if sharpness below this value (0-1)"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Advanced Processing Options
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={settings.ocrMorphologicalOperations}
onChange={(e) => handleSettingsChange('ocrMorphologicalOperations', e.target.checked)}
/>
}
label="Morphological Operations (text cleanup)"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={settings.ocrHistogramEqualization}
onChange={(e) => handleSettingsChange('ocrHistogramEqualization', e.target.checked)}
/>
}
label="Histogram Equalization"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={settings.saveProcessedImages}
onChange={(e) => handleSettingsChange('saveProcessedImages', e.target.checked)}
/>
}
label="Save Processed Images for Review"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Adaptive Threshold Window Size"
type="number"
value={settings.ocrAdaptiveThresholdWindowSize}
onChange={(e) => handleSettingsChange('ocrAdaptiveThresholdWindowSize', parseInt(e.target.value) || 15)}
helperText="Window size for contrast enhancement (odd number)"
inputProps={{ step: 2, min: 3, max: 101 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Image Size and Scaling
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Max Image Width"
type="number"
value={settings.ocrMaxImageWidth}
onChange={(e) => handleSettingsChange('ocrMaxImageWidth', parseInt(e.target.value) || 10000)}
helperText="Maximum image width in pixels"
inputProps={{ step: 100, min: 100, max: 50000 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Max Image Height"
type="number"
value={settings.ocrMaxImageHeight}
onChange={(e) => handleSettingsChange('ocrMaxImageHeight', parseInt(e.target.value) || 10000)}
helperText="Maximum image height in pixels"
inputProps={{ step: 100, min: 100, max: 50000 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Upscale Factor"
type="number"
value={settings.ocrUpscaleFactor}
onChange={(e) => handleSettingsChange('ocrUpscaleFactor', parseFloat(e.target.value) || 1.0)}
helperText="Image scaling factor (1.0 = no scaling)"
inputProps={{ step: 0.1, min: 0.1, max: 5 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
</Box>
)}
{tabValue === 2 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">
User Management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenUserDialog('create')}
disabled={loading}
>
Add User
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Created At</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{new Date(user.created_at).toLocaleDateString()}</TableCell>
<TableCell align="right">
<IconButton
onClick={() => handleOpenUserDialog('edit', user)}
disabled={loading}
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => handleDeleteUser(user.id)}
disabled={loading || user.id === currentUser?.id}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Box>
</Paper>
<Dialog open={userDialog.open} onClose={handleCloseUserDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{userDialog.mode === 'create' ? 'Create New User' : 'Edit User'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12}>
<TextField
fullWidth
label="Username"
value={userForm.username}
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Email"
type="email"
value={userForm.email}
onChange={(e) => setUserForm({ ...userForm, email: e.target.value })}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={userDialog.mode === 'create' ? 'Password' : 'New Password (leave empty to keep current)'}
type="password"
value={userForm.password}
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
required={userDialog.mode === 'create'}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseUserDialog} disabled={loading}>
Cancel
</Button>
<Button onClick={handleUserSubmit} variant="contained" disabled={loading}>
{userDialog.mode === 'create' ? 'Create' : 'Update'}
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
);
};
export default SettingsPage;