Readur/frontend/src/pages/SearchPage.tsx

1510 lines
53 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Box,
Typography,
Card,
CardContent,
TextField,
InputAdornment,
Button,
Chip,
Stack,
FormControl,
InputLabel,
Select,
MenuItem,
OutlinedInput,
Checkbox,
ListItemText,
Accordion,
AccordionSummary,
AccordionDetails,
Slider,
ToggleButton,
ToggleButtonGroup,
CircularProgress,
Alert,
Divider,
IconButton,
Tooltip,
Autocomplete,
LinearProgress,
FormControlLabel,
Switch,
Paper,
Skeleton,
SelectChangeEvent,
Menu,
RadioGroup,
Radio,
Pagination,
} from '@mui/material';
import Grid from '@mui/material/GridLegacy';
import {
Search as SearchIcon,
FilterList as FilterIcon,
Clear as ClearIcon,
ExpandMore as ExpandMoreIcon,
Download as DownloadIcon,
PictureAsPdf as PdfIcon,
Image as ImageIcon,
Description as DocIcon,
TextSnippet as TextIcon,
CalendarToday as DateIcon,
Storage as SizeIcon,
Tag as TagIcon,
Visibility as ViewIcon,
Settings as SettingsIcon,
Speed as SpeedIcon,
AccessTime as TimeIcon,
TrendingUp as TrendingIcon,
TextFormat as TextFormatIcon,
} from '@mui/icons-material';
import { documentService, SearchRequest } from '../services/api';
import SearchGuidance from '../components/SearchGuidance';
import EnhancedSearchGuide from '../components/EnhancedSearchGuide';
import MimeTypeFacetFilter from '../components/MimeTypeFacetFilter';
import EnhancedSnippetViewer from '../components/EnhancedSnippetViewer';
import AdvancedSearchPanel from '../components/AdvancedSearchPanel';
interface Document {
id: string;
original_filename: string;
filename?: string;
file_size: number;
mime_type: string;
created_at: string;
has_ocr_text?: boolean;
tags: string[];
snippets?: Snippet[];
search_rank?: number;
}
interface Snippet {
text: string;
highlight_ranges?: HighlightRange[];
}
interface HighlightRange {
start: number;
end: number;
}
interface SearchResponse {
documents: Document[];
total: number;
query_time_ms: number;
suggestions?: string[];
}
interface MimeTypeOption {
value: string;
label: string;
}
interface SearchFilters {
tags?: string[];
mimeTypes?: string[];
dateRange?: number[];
fileSizeRange?: number[];
hasOcr?: string;
}
type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean';
type OcrStatus = 'all' | 'yes' | 'no';
interface AdvancedSearchSettings {
useEnhancedSearch: boolean;
searchMode: SearchMode;
includeSnippets: boolean;
snippetLength: number;
fuzzyThreshold: number;
resultLimit: number;
includeOcrText: boolean;
includeFileContent: boolean;
includeFilenames: boolean;
boostRecentDocs: boolean;
enableAutoCorrect: boolean;
}
type SnippetViewMode = 'compact' | 'detailed' | 'context';
type SnippetHighlightStyle = 'background' | 'underline' | 'bold';
interface SnippetSettings {
viewMode: SnippetViewMode;
highlightStyle: SnippetHighlightStyle;
fontSize: number;
contextLength: number;
maxSnippetsToShow: number;
}
const SearchPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState<string>(searchParams.get('q') || '');
const [searchResults, setSearchResults] = useState<Document[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [queryTime, setQueryTime] = useState<number>(0);
const [totalResults, setTotalResults] = useState<number>(0);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isTyping, setIsTyping] = useState<boolean>(false);
const [searchProgress, setSearchProgress] = useState<number>(0);
const [quickSuggestions, setQuickSuggestions] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState<boolean>(false);
const [searchTips] = useState<string[]>([
'Use quotes for exact phrases: "project plan"',
'Search by tags: tag:important or tag:invoice',
'Combine terms: contract AND payment',
'Use wildcards: proj* for project, projects, etc.'
]);
// Search settings - consolidated into advanced settings
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
const [advancedSettings, setAdvancedSettings] = useState<AdvancedSearchSettings>({
useEnhancedSearch: true,
searchMode: 'simple',
includeSnippets: true,
snippetLength: 200,
fuzzyThreshold: 0.8,
resultLimit: 100,
includeOcrText: true,
includeFileContent: true,
includeFilenames: true,
boostRecentDocs: false,
enableAutoCorrect: true,
});
// Global snippet settings
const [snippetSettings, setSnippetSettings] = useState<SnippetSettings>({
viewMode: 'detailed',
highlightStyle: 'background',
fontSize: 15,
contextLength: 50,
maxSnippetsToShow: 3,
});
const [snippetSettingsAnchor, setSnippetSettingsAnchor] = useState<null | HTMLElement>(null);
// Pagination states
const [currentPage, setCurrentPage] = useState<number>(1);
const [resultsPerPage] = useState<number>(20);
// Filter states
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedMimeTypes, setSelectedMimeTypes] = useState<string[]>([]);
const [dateRange, setDateRange] = useState<number[]>([0, 365]); // days
const [fileSizeRange, setFileSizeRange] = useState<number[]>([0, 100]); // MB
const [hasOcr, setHasOcr] = useState<OcrStatus>('all');
// Available options (would typically come from API)
const [availableTags, setAvailableTags] = useState<string[]>([]);
const mimeTypeOptions: MimeTypeOption[] = [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/', label: 'Images' },
{ value: 'text/', label: 'Text Files' },
{ value: 'application/msword', label: 'Word Documents' },
{ value: 'application/vnd.openxmlformats-officedocument', label: 'Office Documents' },
];
// Enhanced debounced search with typing indicators
const debounce = useCallback((func: (...args: any[]) => void, delay: number) => {
let timeoutId: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timeoutId);
setIsTyping(true);
timeoutId = setTimeout(() => {
setIsTyping(false);
func.apply(null, args);
}, delay);
};
}, []);
// Quick suggestions generator
const generateQuickSuggestions = useCallback((query: string): void => {
if (!query || query.length < 2) {
setQuickSuggestions([]);
return;
}
const suggestions: string[] = [];
// Add exact phrase suggestion
if (!query.includes('"')) {
suggestions.push(`"${query}"`);
}
// Add tag suggestions
if (!query.startsWith('tag:')) {
suggestions.push(`tag:${query}`);
}
// Add wildcard suggestion
if (!query.includes('*')) {
suggestions.push(`${query}*`);
}
setQuickSuggestions(suggestions.slice(0, 3));
}, []);
const performSearch = useCallback(async (query: string, filters: SearchFilters = {}, page: number = 1): Promise<void> => {
if (!query.trim()) {
setSearchResults([]);
setTotalResults(0);
setQueryTime(0);
setSuggestions([]);
setQuickSuggestions([]);
return;
}
try {
setLoading(true);
setError(null);
setSearchProgress(0);
// Simulate progressive loading for better UX
const progressInterval = setInterval(() => {
setSearchProgress(prev => Math.min(prev + 20, 90));
}, 100);
const searchRequest: SearchRequest = {
query: query.trim(),
tags: filters.tags?.length ? filters.tags : undefined,
mime_types: filters.mimeTypes?.length ? filters.mimeTypes : undefined,
limit: resultsPerPage,
offset: (page - 1) * resultsPerPage,
include_snippets: advancedSettings.includeSnippets,
snippet_length: advancedSettings.snippetLength,
search_mode: advancedSettings.searchMode,
};
const response = advancedSettings.useEnhancedSearch
? await documentService.enhancedSearch(searchRequest)
: await documentService.search(searchRequest);
// Apply additional client-side filters
let results = response.data.documents || [];
// Filter by date range
if (filters.dateRange) {
const now = new Date();
const [minDays, maxDays] = filters.dateRange;
results = results.filter(doc => {
const docDate = new Date(doc.created_at);
const daysDiff = Math.ceil((now.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24));
return daysDiff >= minDays && daysDiff <= maxDays;
});
}
// Filter by file size
if (filters.fileSizeRange) {
const [minMB, maxMB] = filters.fileSizeRange;
results = results.filter(doc => {
const sizeMB = doc.file_size / (1024 * 1024);
return sizeMB >= minMB && sizeMB <= maxMB;
});
}
// Filter by OCR status
if (filters.hasOcr && filters.hasOcr !== 'all') {
results = results.filter(doc => {
return filters.hasOcr === 'yes' ? doc.has_ocr_text : !doc.has_ocr_text;
});
}
clearInterval(progressInterval);
setSearchProgress(100);
setSearchResults(results);
setTotalResults(response.data.total || results.length);
setQueryTime(response.data.query_time_ms || 0);
setSuggestions(response.data.suggestions || []);
// Extract unique tags for filter options
const tags = [...new Set(results.flatMap(doc => doc.tags || []))].filter(tag => typeof tag === 'string');
setAvailableTags(tags);
// Clear progress after a brief delay
setTimeout(() => setSearchProgress(0), 500);
} catch (err) {
setSearchProgress(0);
setError('Search failed. Please try again.');
console.error(err);
} finally {
setLoading(false);
}
}, [advancedSettings]);
const debouncedSearch = useCallback(
debounce((query: string, filters: SearchFilters, page: number = 1, resetPage: boolean = false) => {
if (resetPage) {
setCurrentPage(1);
performSearch(query, filters, 1);
} else {
setCurrentPage(page);
performSearch(query, filters, page);
}
}, 300),
[performSearch]
);
const quickSuggestionsDebounced = useCallback(
debounce((query: string) => generateQuickSuggestions(query), 150),
[generateQuickSuggestions]
);
// Handle URL search params
useEffect(() => {
const queryFromUrl = searchParams.get('q');
if (queryFromUrl && queryFromUrl !== searchQuery) {
setSearchQuery(queryFromUrl);
}
}, [searchParams]);
useEffect(() => {
const filters: SearchFilters = {
tags: selectedTags,
mimeTypes: selectedMimeTypes,
dateRange: dateRange,
fileSizeRange: fileSizeRange,
hasOcr: hasOcr,
};
// Reset to page 1 when search query or filters change
const shouldResetPage = searchQuery !== searchParams.get('q') ||
JSON.stringify(filters) !== JSON.stringify({
tags: selectedTags,
mimeTypes: selectedMimeTypes,
dateRange: dateRange,
fileSizeRange: fileSizeRange,
hasOcr: hasOcr,
});
debouncedSearch(searchQuery, filters, 1, shouldResetPage);
quickSuggestionsDebounced(searchQuery);
if (shouldResetPage) {
setCurrentPage(1);
}
// Update URL params when search query changes
if (searchQuery) {
setSearchParams({ q: searchQuery });
} else {
setSearchParams({});
}
}, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, quickSuggestionsDebounced, setSearchParams]);
const handleClearFilters = (): void => {
setSelectedTags([]);
setSelectedMimeTypes([]);
setDateRange([0, 365]);
setFileSizeRange([0, 100]);
setHasOcr('all');
setCurrentPage(1);
};
const getFileIcon = (mimeType: string): React.ReactElement => {
if (mimeType.includes('pdf')) return <PdfIcon color="error" sx={{ fontSize: '1.2rem' }} />;
if (mimeType.includes('image')) return <ImageIcon color="primary" sx={{ fontSize: '1.2rem' }} />;
if (mimeType.includes('text')) return <TextIcon color="info" sx={{ fontSize: '1.2rem' }} />;
return <DocIcon color="secondary" sx={{ fontSize: '1.2rem' }} />;
};
const formatFileSize = (bytes: number): string => {
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: string): string => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const handleDownload = async (doc: Document): Promise<void> => {
try {
const response = await documentService.download(doc.id);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = window.document.createElement('a');
link.href = url;
link.setAttribute('download', doc.original_filename);
window.document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
};
const renderHighlightedText = (text: string, highlightRanges?: HighlightRange[]): React.ReactNode => {
if (!highlightRanges || highlightRanges.length === 0) {
return text;
}
const parts: React.ReactNode[] = [];
let lastIndex = 0;
highlightRanges.forEach((range, index) => {
// Add text before highlight
if (range.start > lastIndex) {
parts.push(
<span key={`text-${index}`}>
{text.substring(lastIndex, range.start)}
</span>
);
}
// Add highlighted text
parts.push(
<Box
key={`highlight-${index}`}
component="mark"
sx={{
backgroundColor: 'primary.light',
color: 'primary.contrastText',
padding: '0 2px',
borderRadius: '2px',
fontWeight: 600,
}}
>
{text.substring(range.start, range.end)}
</Box>
);
lastIndex = range.end;
});
// Add remaining text
if (lastIndex < text.length) {
parts.push(
<span key="final-text">
{text.substring(lastIndex)}
</span>
);
}
return parts;
};
const handleSuggestionClick = (suggestion: string): void => {
setSearchQuery(suggestion);
};
const handleSearchModeChange = (event: React.MouseEvent<HTMLElement>, newMode: SearchMode | null): void => {
if (newMode) {
setAdvancedSettings(prev => ({ ...prev, searchMode: newMode }));
}
};
const handleTagsChange = (event: SelectChangeEvent<string[]>): void => {
const value = event.target.value;
setSelectedTags(typeof value === 'string' ? value.split(',') : value);
};
const handleMimeTypesChange = (event: SelectChangeEvent<string[]>): void => {
const value = event.target.value;
setSelectedMimeTypes(typeof value === 'string' ? value.split(',') : value);
};
const handleOcrChange = (event: SelectChangeEvent<OcrStatus>): void => {
setHasOcr(event.target.value as OcrStatus);
};
const handlePageChange = (event: React.ChangeEvent<unknown>, page: number): void => {
setCurrentPage(page);
const filters: SearchFilters = {
tags: selectedTags,
mimeTypes: selectedMimeTypes,
dateRange: dateRange,
fileSizeRange: fileSizeRange,
hasOcr: hasOcr,
};
performSearch(searchQuery, filters, page);
// Scroll to top of results
const resultsElement = document.querySelector('.search-results-container');
if (resultsElement) {
resultsElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Box sx={{ p: 3 }}>
{/* Header with Prominent Search */}
<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: 2,
}}
>
Search Documents
</Typography>
{/* Enhanced Search Bar */}
<Paper
elevation={3}
className="search-input-responsive"
sx={{
p: 2,
mb: 3,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
border: '1px solid',
borderColor: 'primary.light',
}}
>
<Box sx={{ position: 'relative' }}>
<TextField
fullWidth
placeholder="Search documents by content, filename, or tags... Try 'invoice', 'contract', or tag:important"
variant="outlined"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="primary" sx={{ fontSize: '1.5rem' }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<Stack direction="row" spacing={1}>
{(loading || isTyping) && (
<CircularProgress
size={20}
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
value={searchProgress}
/>
)}
{searchQuery && (
<IconButton
size="small"
onClick={() => setSearchQuery('')}
>
<ClearIcon />
</IconButton>
)}
<Tooltip title="Search Settings">
<IconButton
size="small"
onClick={() => setShowAdvanced(!showAdvanced)}
color={showAdvanced ? 'primary' : 'default'}
>
<SettingsIcon />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => setShowFilters(!showFilters)}
color={showFilters ? 'primary' : 'default'}
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
>
<FilterIcon />
</IconButton>
</Stack>
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderWidth: 2,
},
'&:hover fieldset': {
borderColor: 'primary.main',
},
'&.Mui-focused fieldset': {
borderColor: 'primary.main',
},
},
'& .MuiInputBase-input': {
fontSize: '1.1rem',
py: 2,
},
}}
/>
{/* Enhanced Loading Progress Bar */}
{(loading || isTyping || searchProgress > 0) && (
<LinearProgress
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
value={searchProgress}
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderRadius: '0 0 4px 4px',
opacity: isTyping ? 0.5 : 1,
transition: 'opacity 0.2s ease-in-out',
}}
/>
)}
</Box>
{/* Quick Stats */}
{(searchQuery && !loading) && (
<Box sx={{
mt: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 2,
}}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
<Chip
icon={<TrendingIcon />}
label={`${totalResults} results`}
size="small"
color="primary"
variant="outlined"
sx={{ flexShrink: 0 }}
/>
<Chip
icon={<TimeIcon />}
label={`${queryTime}ms`}
size="small"
variant="outlined"
sx={{ flexShrink: 0 }}
/>
{advancedSettings.useEnhancedSearch && (
<Chip
icon={<SpeedIcon />}
label="Enhanced"
size="small"
color="success"
variant="outlined"
sx={{ flexShrink: 0 }}
/>
)}
</Box>
{/* Simplified Search Mode Selector */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<ToggleButtonGroup
value={advancedSettings.searchMode}
exclusive
onChange={handleSearchModeChange}
size="small"
>
<ToggleButton value="simple">Smart</ToggleButton>
<ToggleButton value="phrase">Exact phrase</ToggleButton>
<ToggleButton value="fuzzy">Similar words</ToggleButton>
<ToggleButton value="boolean">Advanced</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
)}
{/* Quick Suggestions */}
{quickSuggestions.length > 0 && searchQuery && !loading && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Quick suggestions:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{quickSuggestions.map((suggestion, index) => (
<Chip
key={index}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
clickable
variant="outlined"
color="primary"
sx={{
flexShrink: 0,
'&:hover': {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}
}}
/>
))}
</Box>
</Box>
)}
{/* Server Suggestions */}
{suggestions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Related searches:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{suggestions.map((suggestion, index) => (
<Chip
key={index}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
clickable
variant="outlined"
sx={{
flexShrink: 0,
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
}
}}
/>
))}
</Box>
</Box>
)}
{/* Enhanced Search Guide when not in advanced mode */}
{!showAdvanced && (
<Box sx={{ mt: 2 }}>
<EnhancedSearchGuide
compact
onExampleClick={setSearchQuery}
/>
</Box>
)}
</Paper>
</Box>
{/* Advanced Search Panel */}
<AdvancedSearchPanel
settings={advancedSettings}
onSettingsChange={(newSettings) =>
setAdvancedSettings(prev => ({ ...prev, ...newSettings }))
}
expanded={showAdvanced}
onExpandedChange={setShowAdvanced}
/>
<Grid container spacing={3}>
{/* Mobile Filters Drawer */}
{showFilters && (
<Grid item xs={12} sx={{ display: { xs: 'block', md: 'none' } }}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterIcon />
Filters
</Typography>
<Button size="small" onClick={handleClearFilters} startIcon={<ClearIcon />}>
Clear
</Button>
</Box>
{/* Mobile filter content would go here - simplified */}
<Typography variant="body2" color="text.secondary">
Mobile filters coming soon...
</Typography>
</CardContent>
</Card>
</Grid>
)}
{/* Desktop Filters Sidebar */}
<Grid item xs={12} md={3} sx={{ display: { xs: 'none', md: 'block' } }}>
<Card sx={{ position: 'sticky', top: 20 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterIcon />
Filters
</Typography>
<Button size="small" onClick={handleClearFilters} startIcon={<ClearIcon />}>
Clear
</Button>
</Box>
<Stack spacing={3}>
{/* Tags Filter */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Tags</Typography>
</AccordionSummary>
<AccordionDetails>
<FormControl fullWidth size="small">
<InputLabel>Select Tags</InputLabel>
<Select<string[]>
multiple
value={selectedTags}
onChange={handleTagsChange}
input={<OutlinedInput label="Select Tags" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, overflow: 'hidden' }}>
{selected.map((value) => (
<Chip
key={value}
label={value}
size="small"
sx={{
flexShrink: 0,
maxWidth: '100px',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
}}
/>
))}
</Box>
)}
>
{availableTags.map((tag) => (
<MenuItem key={tag} value={tag}>
<Checkbox checked={selectedTags.indexOf(tag) > -1} />
<ListItemText primary={tag} />
</MenuItem>
))}
</Select>
</FormControl>
</AccordionDetails>
</Accordion>
{/* File Type Filter with Facets */}
<Box sx={{ mb: 2 }}>
<MimeTypeFacetFilter
selectedMimeTypes={selectedMimeTypes}
onMimeTypeChange={setSelectedMimeTypes}
maxItemsToShow={8}
/>
</Box>
{/* OCR Filter */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">OCR Status</Typography>
</AccordionSummary>
<AccordionDetails>
<FormControl fullWidth size="small">
<InputLabel>OCR Text</InputLabel>
<Select
value={hasOcr}
onChange={handleOcrChange}
label="OCR Text"
>
<MenuItem value="all">All Documents</MenuItem>
<MenuItem value="yes">Has OCR Text</MenuItem>
<MenuItem value="no">No OCR Text</MenuItem>
</Select>
</FormControl>
</AccordionDetails>
</Accordion>
{/* Date Range Filter */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Date Range</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" gutterBottom>
Days ago: {dateRange[0]} - {dateRange[1]}
</Typography>
<Slider
value={dateRange}
onChange={(e, newValue) => setDateRange(newValue as number[])}
valueLabelDisplay="auto"
min={0}
max={365}
marks={[
{ value: 0, label: 'Today' },
{ value: 30, label: '30d' },
{ value: 90, label: '90d' },
{ value: 365, label: '1y' },
]}
/>
</AccordionDetails>
</Accordion>
{/* File Size Filter */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">File Size</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" gutterBottom>
Size: {fileSizeRange[0]}MB - {fileSizeRange[1]}MB
</Typography>
<Slider
value={fileSizeRange}
onChange={(e, newValue) => setFileSizeRange(newValue as number[])}
valueLabelDisplay="auto"
min={0}
max={100}
marks={[
{ value: 0, label: '0MB' },
{ value: 10, label: '10MB' },
{ value: 50, label: '50MB' },
{ value: 100, label: '100MB' },
]}
/>
</AccordionDetails>
</Accordion>
</Stack>
</CardContent>
</Card>
</Grid>
{/* Search Results */}
<Grid item xs={12} md={9} className="search-results-container">
{/* Results Header */}
{searchQuery && (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 1 }}>
<Typography variant="body2" color="text.secondary">
{loading ? 'Searching...' : `${searchResults.length} results found`}
</Typography>
{/* Snippet Settings Button */}
<Button
variant="outlined"
size="small"
startIcon={<TextFormatIcon />}
onClick={(e) => setSnippetSettingsAnchor(e.currentTarget)}
sx={{
flexShrink: 0,
position: 'relative',
}}
>
Display Settings
{/* Show indicator if settings are customized */}
{(snippetSettings.viewMode !== 'detailed' ||
snippetSettings.highlightStyle !== 'background' ||
snippetSettings.fontSize !== 15 ||
snippetSettings.maxSnippetsToShow !== 3) && (
<Box
sx={{
position: 'absolute',
top: -4,
right: -4,
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: 'primary.main',
}}
/>
)}
</Button>
</Box>
{/* Current Settings Preview */}
{!loading && searchResults.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
Showing:
</Typography>
<Chip
label={`${snippetSettings.maxSnippetsToShow} snippets`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
<Chip
label={`${snippetSettings.fontSize}px font`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
<Chip
label={snippetSettings.viewMode}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', textTransform: 'capitalize' }}
/>
</Box>
)}
</Box>
)}
{/* Results */}
{loading && (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{!loading && !error && searchQuery && searchResults.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 results found for "{searchQuery}"
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Try adjusting your search terms or filters
</Typography>
{/* Helpful suggestions for no results */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.primary" gutterBottom>
Suggestions:
</Typography>
<Stack spacing={1} alignItems="center">
<Typography variant="body2" color="text.secondary"> Try simpler or more general terms</Typography>
<Typography variant="body2" color="text.secondary"> Check spelling and try different keywords</Typography>
<Typography variant="body2" color="text.secondary"> Remove some filters to broaden your search</Typography>
<Typography variant="body2" color="text.secondary"> Use quotes for exact phrases</Typography>
</Stack>
</Box>
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
<Button
size="small"
variant="outlined"
onClick={handleClearFilters}
startIcon={<ClearIcon />}
>
Clear Filters
</Button>
<Button
size="small"
variant="outlined"
onClick={() => setSearchQuery('')}
>
New Search
</Button>
</Stack>
</Box>
)}
{!loading && !error && !searchQuery && (
<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,
}}
>
<SearchIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
Start searching your documents
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Use the enhanced search bar above to find documents by content, filename, or tags
</Typography>
{/* Search Tips */}
<Box sx={{ mb: 3, maxWidth: 600, mx: 'auto' }}>
<Typography variant="subtitle2" color="text.primary" gutterBottom>
Search Tips:
</Typography>
<Stack spacing={1} alignItems="center">
{searchTips.map((tip, index) => (
<Typography key={index} variant="body2" color="text.secondary" sx={{ fontSize: '0.85rem' }}>
{tip}
</Typography>
))}
</Stack>
</Box>
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
<Chip
label="Try: invoice"
size="small"
variant="outlined"
clickable
onClick={() => {
setSearchQuery('invoice');
setCurrentPage(1);
}}
/>
<Chip
label="Try: contract"
size="small"
variant="outlined"
clickable
onClick={() => {
setSearchQuery('contract');
setCurrentPage(1);
}}
/>
<Chip
label="Try: tag:important"
size="small"
variant="outlined"
clickable
onClick={() => {
setSearchQuery('tag:important');
setCurrentPage(1);
}}
/>
</Stack>
</Box>
)}
{!loading && !error && searchResults.length > 0 && (
<>
<Grid container spacing={1}>
{searchResults.map((doc) => (
<Grid
item
xs={12}
key={doc.id}
>
<Card
className="search-result-card search-loading-fade"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'row',
}}
>
<CardContent
className="search-card"
sx={{
flexGrow: 1,
overflow: 'hidden',
py: 1.5,
px: 2,
'&:last-child': {
pb: 1.5
}
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
width: '100%'
}}>
<Box sx={{ mr: 1.5, mt: 0.5, flexShrink: 0 }}>
{getFileIcon(doc.mime_type)}
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0, overflow: 'hidden' }}>
<Typography
variant="h6"
sx={{
fontSize: '1.05rem',
fontWeight: 600,
mb: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
width: '100%',
color: 'text.primary',
}}
title={doc.original_filename}
>
{doc.original_filename}
</Typography>
<Box sx={{
mb: 0.5,
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
overflow: 'hidden',
alignItems: 'center',
}}>
<Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
{formatFileSize(doc.file_size)} {formatDate(doc.created_at)}
{doc.has_ocr_text && ' • OCR'}
</Typography>
</Box>
{doc.tags.length > 0 && (
<Box sx={{
mb: 1,
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
overflow: 'hidden',
alignItems: 'center',
}}>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>
Tags:
</Typography>
{doc.tags.slice(0, 3).map((tag, index) => (
<Chip
key={index}
className="search-chip"
label={tag}
size="small"
color="primary"
variant="outlined"
sx={{
fontSize: '0.7rem',
height: '18px',
flexShrink: 0,
maxWidth: '120px',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
}}
/>
))}
{doc.tags.length > 3 && (
<Typography variant="caption" color="text.secondary">
+{doc.tags.length - 3} more
</Typography>
)}
</Box>
)}
{/* Enhanced Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && (
<Box sx={{
mt: 0.5,
mb: 1
}}>
<EnhancedSnippetViewer
snippets={doc.snippets}
searchQuery={searchQuery}
maxSnippetsToShow={snippetSettings.maxSnippetsToShow}
viewMode={snippetSettings.viewMode}
highlightStyle={snippetSettings.highlightStyle}
fontSize={snippetSettings.fontSize}
contextLength={snippetSettings.contextLength}
showSettings={false}
onSnippetClick={(snippet, index) => {
console.log('Snippet clicked:', snippet, index);
}}
/>
</Box>
)}
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
ml: 2,
gap: 0.5,
alignItems: 'center',
justifyContent: 'flex-start',
pt: 0.5,
}}>
<Tooltip title="View Details">
<IconButton
className="search-filter-button search-focusable"
size="small"
sx={{
p: 0.75,
minWidth: 32,
minHeight: 32,
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
bgcolor: 'primary.dark',
}
}}
onClick={() => navigate(`/documents/${doc.id}`)}
>
<ViewIcon sx={{ fontSize: '1.1rem' }} />
</IconButton>
</Tooltip>
<Tooltip title="Download">
<IconButton
className="search-filter-button search-focusable"
size="small"
sx={{
p: 0.75,
minWidth: 32,
minHeight: 32,
bgcolor: 'action.hover',
'&:hover': {
bgcolor: 'action.selected',
}
}}
onClick={() => handleDownload(doc)}
>
<DownloadIcon sx={{ fontSize: '1.1rem' }} />
</IconButton>
</Tooltip>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Pagination */}
{totalResults > resultsPerPage && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4, mb: 2 }}>
<Pagination
count={Math.ceil(totalResults / resultsPerPage)}
page={currentPage}
onChange={handlePageChange}
color="primary"
size="large"
showFirstButton
showLastButton
siblingCount={1}
boundaryCount={1}
sx={{
'& .MuiPagination-ul': {
flexWrap: 'wrap',
justifyContent: 'center',
},
}}
/>
</Box>
)}
{/* Results Summary */}
<Box sx={{ textAlign: 'center', mt: 2, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
Showing {((currentPage - 1) * resultsPerPage) + 1}-{Math.min(currentPage * resultsPerPage, totalResults)} of {totalResults} results
</Typography>
</Box>
</>
)}
</Grid>
</Grid>
{/* Global Snippet Settings Menu */}
<Menu
anchorEl={snippetSettingsAnchor}
open={Boolean(snippetSettingsAnchor)}
onClose={() => setSnippetSettingsAnchor(null)}
PaperProps={{ sx: { width: 320, p: 2 } }}
>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Text Display Settings
</Typography>
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
View Mode
</Typography>
<RadioGroup
value={snippetSettings.viewMode}
onChange={(e) => setSnippetSettings(prev => ({ ...prev, viewMode: e.target.value as SnippetViewMode }))}
>
<FormControlLabel
value="compact"
control={<Radio size="small" />}
label="Compact"
/>
<FormControlLabel
value="detailed"
control={<Radio size="small" />}
label="Detailed"
/>
<FormControlLabel
value="context"
control={<Radio size="small" />}
label="Context Focus"
/>
</RadioGroup>
</Box>
<Divider sx={{ my: 2 }} />
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Highlight Style
</Typography>
<RadioGroup
value={snippetSettings.highlightStyle}
onChange={(e) => setSnippetSettings(prev => ({ ...prev, highlightStyle: e.target.value as SnippetHighlightStyle }))}
>
<FormControlLabel
value="background"
control={<Radio size="small" />}
label="Background Color"
/>
<FormControlLabel
value="underline"
control={<Radio size="small" />}
label="Underline"
/>
<FormControlLabel
value="bold"
control={<Radio size="small" />}
label="Bold Text"
/>
</RadioGroup>
</Box>
<Divider sx={{ my: 2 }} />
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Font Size: {snippetSettings.fontSize}px
</Typography>
<Slider
value={snippetSettings.fontSize}
onChange={(_, value) => setSnippetSettings(prev => ({ ...prev, fontSize: value as number }))}
min={12}
max={20}
marks
valueLabelDisplay="auto"
/>
</Box>
<Divider sx={{ my: 2 }} />
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Snippets per result: {snippetSettings.maxSnippetsToShow}
</Typography>
<Slider
value={snippetSettings.maxSnippetsToShow}
onChange={(_, value) => setSnippetSettings(prev => ({ ...prev, maxSnippetsToShow: value as number }))}
min={1}
max={5}
marks
valueLabelDisplay="auto"
/>
</Box>
{snippetSettings.viewMode === 'context' && (
<>
<Divider sx={{ my: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary" gutterBottom>
Context Length: {snippetSettings.contextLength} characters
</Typography>
<Slider
value={snippetSettings.contextLength}
onChange={(_, value) => setSnippetSettings(prev => ({ ...prev, contextLength: value as number }))}
min={20}
max={200}
step={10}
marks
valueLabelDisplay="auto"
/>
</Box>
</>
)}
</Menu>
</Box>
);
};
export default SearchPage;