Readur/frontend/src/components/AdvancedSearchPanel/AdvancedSearchPanel.tsx

583 lines
20 KiB
TypeScript

import React, { useState } from 'react';
import {
Box,
Paper,
Typography,
FormControl,
FormControlLabel,
Switch,
Select,
MenuItem,
InputLabel,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Slider,
TextField,
Button,
Divider,
IconButton,
Tooltip,
Alert,
List,
ListItem,
ListItemIcon,
ListItemText,
ToggleButtonGroup,
ToggleButton,
Card,
CardContent,
Collapse,
Badge,
Grid,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Settings as SettingsIcon,
Speed as SpeedIcon,
Visibility as VisibilityIcon,
TextSnippet as SnippetIcon,
Search as SearchIcon,
Tune as TuneIcon,
Psychology as PsychologyIcon,
FormatQuote as QuoteIcon,
Code as CodeIcon,
BlurOn as BlurIcon,
Help as HelpIcon,
RestoreFromTrash as ResetIcon,
BookmarkBorder as SaveIcon,
Lightbulb as TipIcon,
} from '@mui/icons-material';
type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean';
interface AdvancedSearchSettings {
useEnhancedSearch: boolean;
searchMode: SearchMode;
includeSnippets: boolean;
snippetLength: number;
fuzzyThreshold: number;
resultLimit: number;
includeOcrText: boolean;
includeFileContent: boolean;
includeFilenames: boolean;
boostRecentDocs: boolean;
enableAutoCorrect: boolean;
}
interface AdvancedSearchPanelProps {
settings: AdvancedSearchSettings;
onSettingsChange: (settings: Partial<AdvancedSearchSettings>) => void;
onSavePreset?: (name: string, settings: AdvancedSearchSettings) => void;
onLoadPreset?: (preset: AdvancedSearchSettings) => void;
availablePresets?: { name: string; settings: AdvancedSearchSettings }[];
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
}
const AdvancedSearchPanel: React.FC<AdvancedSearchPanelProps> = ({
settings,
onSettingsChange,
onSavePreset,
onLoadPreset,
availablePresets = [],
expanded = false,
onExpandedChange,
}) => {
const [activeSection, setActiveSection] = useState<string>('search-behavior');
const [showPresetSave, setShowPresetSave] = useState(false);
const [presetName, setPresetName] = useState('');
const sections = [
{
id: 'search-behavior',
label: 'Search Behavior',
icon: <SearchIcon />,
description: 'How search queries are processed and matched',
},
{
id: 'results-display',
label: 'Results Display',
icon: <VisibilityIcon />,
description: 'How search results are shown and formatted',
},
{
id: 'performance',
label: 'Performance',
icon: <SpeedIcon />,
description: 'Speed and resource optimization settings',
},
{
id: 'content-sources',
label: 'Content Sources',
icon: <PsychologyIcon />,
description: 'Which parts of documents to search',
},
];
const handleSettingChange = <K extends keyof AdvancedSearchSettings>(
key: K,
value: AdvancedSearchSettings[K]
) => {
onSettingsChange({ [key]: value });
};
const handleResetToDefaults = () => {
const defaults: AdvancedSearchSettings = {
useEnhancedSearch: true,
searchMode: 'simple',
includeSnippets: true,
snippetLength: 200,
fuzzyThreshold: 0.8,
resultLimit: 100,
includeOcrText: true,
includeFileContent: true,
includeFilenames: true,
boostRecentDocs: false,
enableAutoCorrect: true,
};
onSettingsChange(defaults);
};
const handleSavePreset = () => {
if (presetName.trim() && onSavePreset) {
onSavePreset(presetName.trim(), settings);
setPresetName('');
setShowPresetSave(false);
}
};
const getSearchModeDescription = (mode: SearchMode) => {
switch (mode) {
case 'simple':
return 'Basic keyword matching with stemming';
case 'phrase':
return 'Exact phrase matching in order';
case 'fuzzy':
return 'Flexible matching with typo tolerance';
case 'boolean':
return 'Advanced operators (AND, OR, NOT)';
default:
return '';
}
};
return (
<Paper
elevation={2}
sx={{
mb: 3,
overflow: 'hidden',
border: '1px solid',
borderColor: expanded ? 'primary.main' : 'divider',
transition: 'all 0.3s ease',
}}
>
<Box
sx={{
p: 2,
backgroundColor: expanded ? 'primary.50' : 'background.paper',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: expanded ? 'primary.100' : 'action.hover',
},
}}
onClick={() => onExpandedChange?.(!expanded)}
>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={2}>
<Badge
color="primary"
variant="dot"
invisible={!settings.useEnhancedSearch}
>
<TuneIcon color={expanded ? 'primary' : 'action'} />
</Badge>
<Box>
<Typography variant="h6" color={expanded ? 'primary.main' : 'text.primary'}>
Advanced Search Options
</Typography>
<Typography variant="caption" color="text.secondary">
Customize search behavior and result display
</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Chip
label={settings.searchMode.toUpperCase()}
size="small"
color={settings.useEnhancedSearch ? 'primary' : 'default'}
variant={expanded ? 'filled' : 'outlined'}
/>
<IconButton size="small">
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
</Box>
<Collapse in={expanded}>
<Box sx={{ p: 0 }}>
{/* Section Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
<Box display="flex" gap={1} sx={{ overflowX: 'auto', py: 1 }}>
{sections.map((section) => (
<Button
key={section.id}
variant={activeSection === section.id ? 'contained' : 'text'}
size="small"
startIcon={section.icon}
onClick={() => setActiveSection(section.id)}
sx={{ minWidth: 'fit-content', whiteSpace: 'nowrap' }}
>
{section.label}
</Button>
))}
</Box>
</Box>
<Box sx={{ p: 3 }}>
{/* Search Behavior Section */}
{activeSection === 'search-behavior' && (
<Box>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
These settings control how your search queries are interpreted and matched against documents.
</Typography>
</Alert>
<Box display="flex" flexDirection={{ xs: 'column', md: 'row' }} gap={3} mb={3}>
<Box flex={1}>
<FormControl fullWidth>
<InputLabel>Search Mode</InputLabel>
<Select
value={settings.searchMode}
onChange={(e) => handleSettingChange('searchMode', e.target.value as SearchMode)}
label="Search Mode"
>
<MenuItem value="simple">
<Box>
<Typography variant="body2">Simple Search</Typography>
<Typography variant="caption" color="text.secondary">
Basic keyword matching
</Typography>
</Box>
</MenuItem>
<MenuItem value="phrase">
<Box>
<Typography variant="body2">Phrase Search</Typography>
<Typography variant="caption" color="text.secondary">
Exact phrase matching
</Typography>
</Box>
</MenuItem>
<MenuItem value="fuzzy">
<Box>
<Typography variant="body2">Fuzzy Search</Typography>
<Typography variant="caption" color="text.secondary">
Typo-tolerant matching
</Typography>
</Box>
</MenuItem>
<MenuItem value="boolean">
<Box>
<Typography variant="body2">Boolean Search</Typography>
<Typography variant="caption" color="text.secondary">
AND, OR, NOT operators
</Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{getSearchModeDescription(settings.searchMode)}
</Typography>
</Box>
<Box flex={1}>
<Typography variant="body2" gutterBottom>
Fuzzy Match Threshold: {settings.fuzzyThreshold}
</Typography>
<Slider
value={settings.fuzzyThreshold}
onChange={(_, value) => handleSettingChange('fuzzyThreshold', value as number)}
min={0.1}
max={1.0}
step={0.1}
marks
disabled={settings.searchMode !== 'fuzzy'}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${(value * 100).toFixed(0)}%`}
/>
<Typography variant="caption" color="text.secondary">
Higher values = stricter matching
</Typography>
</Box>
</Box>
<Box>
<Box display="flex" flexWrap="wrap" gap={2}>
<FormControlLabel
control={
<Switch
checked={settings.useEnhancedSearch}
onChange={(e) => handleSettingChange('useEnhancedSearch', e.target.checked)}
/>
}
label="Enhanced Search Engine"
/>
<FormControlLabel
control={
<Switch
checked={settings.enableAutoCorrect}
onChange={(e) => handleSettingChange('enableAutoCorrect', e.target.checked)}
/>
}
label="Auto-correct Spelling"
/>
<FormControlLabel
control={
<Switch
checked={settings.boostRecentDocs}
onChange={(e) => handleSettingChange('boostRecentDocs', e.target.checked)}
/>
}
label="Boost Recent Documents"
/>
</Box>
</Box>
</Box>
)}
{/* Results Display Section */}
{activeSection === 'results-display' && (
<Grid container spacing={3}>
<Grid size={12}>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Control how search results are presented and what information is shown.
</Typography>
</Alert>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControlLabel
control={
<Switch
checked={settings.includeSnippets}
onChange={(e) => handleSettingChange('includeSnippets', e.target.checked)}
/>
}
label="Show Text Snippets"
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!settings.includeSnippets}>
<InputLabel>Snippet Length</InputLabel>
<Select
value={settings.snippetLength}
onChange={(e) => handleSettingChange('snippetLength', e.target.value as number)}
label="Snippet Length"
>
<MenuItem value={100}>Short (100 chars)</MenuItem>
<MenuItem value={200}>Medium (200 chars)</MenuItem>
<MenuItem value={400}>Long (400 chars)</MenuItem>
<MenuItem value={600}>Extra Long (600 chars)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Results Per Page</InputLabel>
<Select
value={settings.resultLimit}
onChange={(e) => handleSettingChange('resultLimit', e.target.value as number)}
label="Results Per Page"
>
<MenuItem value={25}>25 results</MenuItem>
<MenuItem value={50}>50 results</MenuItem>
<MenuItem value={100}>100 results</MenuItem>
<MenuItem value={200}>200 results</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
)}
{/* Content Sources Section */}
{activeSection === 'content-sources' && (
<Grid container spacing={3}>
<Grid size={12}>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Choose which parts of your documents to include in search.
</Typography>
</Alert>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2" gutterBottom>
Search In:
</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<FormControlLabel
control={
<Switch
checked={settings.includeFileContent}
onChange={(e) => handleSettingChange('includeFileContent', e.target.checked)}
/>
}
label="Document Content"
/>
<FormControlLabel
control={
<Switch
checked={settings.includeOcrText}
onChange={(e) => handleSettingChange('includeOcrText', e.target.checked)}
/>
}
label="OCR Extracted Text"
/>
<FormControlLabel
control={
<Switch
checked={settings.includeFilenames}
onChange={(e) => handleSettingChange('includeFilenames', e.target.checked)}
/>
}
label="Filenames"
/>
</Box>
</Grid>
</Grid>
)}
{/* Performance Section */}
{activeSection === 'performance' && (
<Grid container spacing={3}>
<Grid size={12}>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
These settings can affect search speed. Use with caution for large document collections.
</Typography>
</Alert>
</Grid>
<Grid size={12}>
<Typography variant="body2" gutterBottom>
Maximum Results: {settings.resultLimit}
</Typography>
<Slider
value={settings.resultLimit}
onChange={(_, value) => handleSettingChange('resultLimit', value as number)}
min={10}
max={500}
step={10}
marks={[
{ value: 25, label: '25' },
{ value: 100, label: '100' },
{ value: 250, label: '250' },
{ value: 500, label: '500' },
]}
valueLabelDisplay="auto"
/>
<Typography variant="caption" color="text.secondary">
Higher values may slow down search for large collections
</Typography>
</Grid>
</Grid>
)}
{/* Action Buttons */}
<Divider sx={{ my: 3 }} />
<Box display="flex" alignItems="center" justifyContent="between" gap={2}>
<Box display="flex" gap={1}>
<Button
variant="outlined"
size="small"
startIcon={<ResetIcon />}
onClick={handleResetToDefaults}
>
Reset to Defaults
</Button>
<Button
variant="outlined"
size="small"
startIcon={<SaveIcon />}
onClick={() => setShowPresetSave(!showPresetSave)}
>
Save Preset
</Button>
</Box>
{availablePresets.length > 0 && (
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Load Preset</InputLabel>
<Select
label="Load Preset"
onChange={(e) => {
const preset = availablePresets.find(p => p.name === e.target.value);
if (preset && onLoadPreset) {
onLoadPreset(preset.settings);
}
}}
value=""
>
{availablePresets.map((preset) => (
<MenuItem key={preset.name} value={preset.name}>
{preset.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
{/* Save Preset Dialog */}
<Collapse in={showPresetSave}>
<Card variant="outlined" sx={{ mt: 2, p: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Save Current Settings as Preset
</Typography>
<Box display="flex" gap={1} alignItems="end">
<TextField
size="small"
label="Preset Name"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
fullWidth
/>
<Button
variant="contained"
size="small"
onClick={handleSavePreset}
disabled={!presetName.trim()}
>
Save
</Button>
<Button
size="small"
onClick={() => {
setShowPresetSave(false);
setPresetName('');
}}
>
Cancel
</Button>
</Box>
</Card>
</Collapse>
</Box>
</Box>
</Collapse>
</Paper>
);
};
export default AdvancedSearchPanel;