import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; 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 { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || ''); const [searchResults, setSearchResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [queryTime, setQueryTime] = useState(0); const [totalResults, setTotalResults] = useState(0); const [suggestions, setSuggestions] = useState([]); const [isTyping, setIsTyping] = useState(false); const [searchProgress, setSearchProgress] = useState(0); const [quickSuggestions, setQuickSuggestions] = useState([]); const [showFilters, setShowFilters] = useState(false); const searchTips = [ t('search.tips.exactPhrase'), t('search.tips.tags'), t('search.tips.combine'), t('search.tips.wildcards') ]; // Search settings - consolidated into advanced settings const [showAdvanced, setShowAdvanced] = useState(false); const [advancedSettings, setAdvancedSettings] = useState({ 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({ viewMode: 'detailed', highlightStyle: 'background', fontSize: 15, contextLength: 50, maxSnippetsToShow: 3, }); const [snippetSettingsAnchor, setSnippetSettingsAnchor] = useState(null); // Pagination states const [currentPage, setCurrentPage] = useState(1); const [resultsPerPage] = useState(20); // Filter states const [selectedTags, setSelectedTags] = useState([]); const [selectedMimeTypes, setSelectedMimeTypes] = useState([]); const [dateRange, setDateRange] = useState([0, 365]); // days const [fileSizeRange, setFileSizeRange] = useState([0, 100]); // MB const [hasOcr, setHasOcr] = useState('all'); // Available options (would typically come from API) const [availableTags, setAvailableTags] = useState([]); 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 => { 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 ; if (mimeType.includes('image')) return ; if (mimeType.includes('text')) return ; return ; }; 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 => { 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( {text.substring(lastIndex, range.start)} ); } // Add highlighted text parts.push( {text.substring(range.start, range.end)} ); lastIndex = range.end; }); // Add remaining text if (lastIndex < text.length) { parts.push( {text.substring(lastIndex)} ); } return parts; }; const handleSuggestionClick = (suggestion: string): void => { setSearchQuery(suggestion); }; const handleSearchModeChange = (event: React.MouseEvent, newMode: SearchMode | null): void => { if (newMode) { setAdvancedSettings(prev => ({ ...prev, searchMode: newMode })); } }; const handleTagsChange = (event: SelectChangeEvent): void => { const value = event.target.value; setSelectedTags(typeof value === 'string' ? value.split(',') : value); }; const handleMimeTypesChange = (event: SelectChangeEvent): void => { const value = event.target.value; setSelectedMimeTypes(typeof value === 'string' ? value.split(',') : value); }; const handleOcrChange = (event: SelectChangeEvent): void => { setHasOcr(event.target.value as OcrStatus); }; const handlePageChange = (event: React.ChangeEvent, 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 ( {/* Header with Prominent Search */} {t('search.title')} {/* Enhanced Search Bar */} setSearchQuery(e.target.value)} InputProps={{ startAdornment: ( ), endAdornment: ( {(loading || isTyping) && ( 0 ? "determinate" : "indeterminate"} value={searchProgress} /> )} {searchQuery && ( setSearchQuery('')} > )} setShowAdvanced(!showAdvanced)} color={showAdvanced ? 'primary' : 'default'} > setShowFilters(!showFilters)} color={showFilters ? 'primary' : 'default'} sx={{ display: { xs: 'inline-flex', md: 'none' } }} > ), }} 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) && ( 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', }} /> )} {/* Quick Stats */} {(searchQuery && !loading) && ( } label={t('search.status.resultsFound', { count: totalResults })} size="small" color="primary" variant="outlined" sx={{ flexShrink: 0 }} /> } label={`${queryTime}ms`} size="small" variant="outlined" sx={{ flexShrink: 0 }} /> {advancedSettings.useEnhancedSearch && ( } label={t('search.modes.enhanced')} size="small" color="success" variant="outlined" sx={{ flexShrink: 0 }} /> )} {/* Simplified Search Mode Selector */} {t('search.modes.smart')} {t('search.modes.exactPhrase')} {t('search.modes.similarWords')} {t('search.modes.advanced')} )} {/* Quick Suggestions */} {quickSuggestions.length > 0 && searchQuery && !loading && ( {t('search.quickSuggestions.title')} {quickSuggestions.map((suggestion, index) => ( handleSuggestionClick(suggestion)} clickable variant="outlined" color="primary" sx={{ flexShrink: 0, '&:hover': { backgroundColor: 'primary.main', color: 'primary.contrastText', } }} /> ))} )} {/* Server Suggestions */} {suggestions.length > 0 && ( {t('search.relatedSearches.title')} {suggestions.map((suggestion, index) => ( handleSuggestionClick(suggestion)} clickable variant="outlined" sx={{ flexShrink: 0, '&:hover': { backgroundColor: 'primary.light', color: 'primary.contrastText', } }} /> ))} )} {/* Enhanced Search Guide when not in advanced mode */} {!showAdvanced && ( )} {/* Advanced Search Panel */} setAdvancedSettings(prev => ({ ...prev, ...newSettings })) } expanded={showAdvanced} onExpandedChange={setShowAdvanced} /> {/* Mobile Filters Drawer */} {showFilters && ( {t('search.filters.title')} {/* Mobile filter content would go here - simplified */} Mobile filters coming soon... )} {/* Desktop Filters Sidebar */} {t('search.filters.title')} {/* Tags Filter */} }> {t('search.filters.tags')} {t('search.filters.selectTags')} multiple value={selectedTags} onChange={handleTagsChange} input={} renderValue={(selected) => ( {selected.map((value) => ( ))} )} > {availableTags.map((tag) => ( -1} /> ))} {/* File Type Filter with Facets */} {/* OCR Filter */} }> {t('search.filters.ocrStatus')} {t('search.filters.ocrText')} {/* Date Range Filter */} }> {t('search.filters.dateRange')} {t('search.filters.daysAgo', { min: dateRange[0], max: dateRange[1] })} setDateRange(newValue as number[])} valueLabelDisplay="auto" min={0} max={365} marks={[ { value: 0, label: t('search.filters.dateMarks.today') }, { value: 30, label: t('search.filters.dateMarks.30d') }, { value: 90, label: t('search.filters.dateMarks.90d') }, { value: 365, label: t('search.filters.dateMarks.1y') }, ]} /> {/* File Size Filter */} }> {t('search.filters.fileSize')} {t('search.filters.sizeRange', { min: fileSizeRange[0], max: fileSizeRange[1] })} 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' }, ]} /> {/* Search Results */} {/* Results Header */} {searchQuery && ( {loading ? t('search.status.searching') : t('search.status.resultsFound', { count: searchResults.length })} {/* Snippet Settings Button */} {/* Current Settings Preview */} {!loading && searchResults.length > 0 && ( {t('search.results.showing')} )} )} {/* Results */} {loading && ( )} {error && ( {error} )} {!loading && !error && searchQuery && searchResults.length === 0 && ( {t('search.noResults.title', { query: searchQuery })} {t('search.noResults.subtitle')} {/* Helpful suggestions for no results */} {t('search.noResults.suggestions.title')} • {t('search.noResults.suggestions.simpler')} • {t('search.noResults.suggestions.spelling')} • {t('search.noResults.suggestions.removeFilters')} • {t('search.noResults.suggestions.useQuotes')} )} {!loading && !error && !searchQuery && ( {t('search.empty.title')} {t('search.empty.subtitle')} {/* Search Tips */} {t('search.tips.title')} {searchTips.map((tip, index) => ( {tip} ))} { setSearchQuery('invoice'); setCurrentPage(1); }} /> { setSearchQuery('contract'); setCurrentPage(1); }} /> { setSearchQuery('tag:important'); setCurrentPage(1); }} /> )} {!loading && !error && searchResults.length > 0 && ( <> {searchResults.map((doc) => ( {getFileIcon(doc.mime_type)} {doc.original_filename} {formatFileSize(doc.file_size)} • {formatDate(doc.created_at)} {doc.has_ocr_text && t('search.results.hasOcr')} {doc.tags.length > 0 && ( {t('search.results.tags')} {doc.tags.slice(0, 3).map((tag, index) => ( ))} {doc.tags.length > 3 && ( {t('common.moreCount', { count: doc.tags.length - 3 })} )} )} {/* Enhanced Search Snippets */} {doc.snippets && doc.snippets.length > 0 && ( { console.log('Snippet clicked:', snippet, index); }} /> )} navigate(`/documents/${doc.id}`)} > handleDownload(doc)} > ))} {/* Pagination */} {totalResults > resultsPerPage && ( )} {/* Results Summary */} {t('search.results.pagination', { start: ((currentPage - 1) * resultsPerPage) + 1, end: Math.min(currentPage * resultsPerPage, totalResults), total: totalResults })} )} {/* Global Snippet Settings Menu */} setSnippetSettingsAnchor(null)} PaperProps={{ sx: { width: 320, p: 2 } }} > {t('search.display.textSettings')} {t('search.display.viewMode.label')} setSnippetSettings(prev => ({ ...prev, viewMode: e.target.value as SnippetViewMode }))} > } label={t('search.display.viewMode.compact')} /> } label={t('search.display.viewMode.detailed')} /> } label={t('search.display.viewMode.contextFocus')} /> {t('search.display.highlightStyle.label')} setSnippetSettings(prev => ({ ...prev, highlightStyle: e.target.value as SnippetHighlightStyle }))} > } label={t('search.display.highlightStyle.background')} /> } label={t('search.display.highlightStyle.underline')} /> } label={t('search.display.highlightStyle.bold')} /> {t('search.display.fontSizeLabel', { size: snippetSettings.fontSize })} setSnippetSettings(prev => ({ ...prev, fontSize: value as number }))} min={12} max={20} marks valueLabelDisplay="auto" /> {t('search.display.snippetsPerResult', { count: snippetSettings.maxSnippetsToShow })} setSnippetSettings(prev => ({ ...prev, maxSnippetsToShow: value as number }))} min={1} max={5} marks valueLabelDisplay="auto" /> {snippetSettings.viewMode === 'context' && ( <> {t('search.display.contextLength', { length: snippetSettings.contextLength })} setSnippetSettings(prev => ({ ...prev, contextLength: value as number }))} min={20} max={200} step={10} marks valueLabelDisplay="auto" /> )} ); }; export default SearchPage;