import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Box, TextField, InputAdornment, IconButton, Paper, List, ListItem, ListItemText, ListItemIcon, Typography, Chip, Stack, ClickAwayListener, Grow, Popper, CircularProgress, LinearProgress, Skeleton, SxProps, Theme, useTheme, } from '@mui/material'; import { Search as SearchIcon, Clear as ClearIcon, Description as DocIcon, PictureAsPdf as PdfIcon, Image as ImageIcon, TextSnippet as TextIcon, TrendingUp as TrendingIcon, AccessTime as TimeIcon, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { documentService, SearchRequest, EnhancedDocument, SearchResponse } from '../../services/api'; interface GlobalSearchBarProps { sx?: SxProps; [key: string]: any; } const GlobalSearchBar: React.FC = ({ sx, ...props }) => { const navigate = useNavigate(); const theme = useTheme(); const { t } = useTranslation(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [showResults, setShowResults] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const [isTyping, setIsTyping] = useState(false); const [searchProgress, setSearchProgress] = useState(0); const [suggestions, setSuggestions] = useState([]); const [popularSearches] = useState(['invoice', 'contract', 'report', 'presentation', 'agreement']); const searchInputRef = useRef(null); const anchorRef = useRef(null); // Load recent searches from localStorage useEffect(() => { const saved = localStorage.getItem('recentSearches'); if (saved) { try { setRecentSearches(JSON.parse(saved)); } catch (e) { console.error('Failed to parse recent searches:', e); } } }, []); // Save recent searches to localStorage const saveRecentSearch = useCallback((searchQuery: string): void => { if (!searchQuery.trim()) return; const updated = [ searchQuery, ...recentSearches.filter(s => s !== searchQuery) ].slice(0, 5); // Keep only last 5 searches setRecentSearches(updated); localStorage.setItem('recentSearches', JSON.stringify(updated)); }, [recentSearches]); // Enhanced debounced search function 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); }; }, []); // Generate smart suggestions const generateSuggestions = useCallback((searchQuery: string): void => { if (!searchQuery || searchQuery.length < 2) { setSuggestions([]); return; } const smartSuggestions: string[] = []; // Add similar popular searches const similar = popularSearches.filter(search => search.toLowerCase().includes(searchQuery.toLowerCase()) || searchQuery.toLowerCase().includes(search.toLowerCase()) ); smartSuggestions.push(...similar); // Add exact phrase suggestion if (!searchQuery.includes('"')) { smartSuggestions.push(`"${searchQuery}"`); } // Add tag search suggestion if (!searchQuery.startsWith('tag:')) { smartSuggestions.push(`tag:${searchQuery}`); } setSuggestions(smartSuggestions.slice(0, 3)); }, [popularSearches]); const performSearch = useCallback(async (searchQuery: string): Promise => { if (!searchQuery.trim()) { setResults([]); setSuggestions([]); return; } try { setLoading(true); setSearchProgress(0); // Progressive loading for better UX const progressInterval = setInterval(() => { setSearchProgress(prev => Math.min(prev + 25, 90)); }, 50); const searchRequest: SearchRequest = { query: searchQuery.trim(), limit: 5, // Show only top 5 results in global search include_snippets: true, // Include snippets for context snippet_length: 200, // Longer snippets for better context in quick search search_mode: searchQuery.length < 4 ? 'fuzzy' : 'simple', // Use fuzzy for short queries (substring matching) }; const response = await documentService.enhancedSearch(searchRequest); clearInterval(progressInterval); setSearchProgress(100); setResults(response.data.documents || []); // Clear progress after brief delay setTimeout(() => setSearchProgress(0), 300); } catch (error) { console.error('Global search failed:', error); setResults([]); setSearchProgress(0); } finally { setLoading(false); } }, []); const debouncedSearch = useCallback( debounce(performSearch, 200), // Even faster debounce for global search [performSearch] ); const debouncedSuggestions = useCallback( debounce(generateSuggestions, 100), // Very fast suggestions [generateSuggestions] ); const handleInputChange = (event: React.ChangeEvent): void => { const value = event.target.value; setQuery(value); setShowResults(true); if (value.trim()) { debouncedSearch(value); debouncedSuggestions(value); } else { setResults([]); setSuggestions([]); } }; const handleInputFocus = (): void => { setShowResults(true); }; const handleClickAway = (): void => { setShowResults(false); }; const handleClear = (): void => { setQuery(''); setResults([]); setSuggestions([]); setShowResults(false); setIsTyping(false); setSearchProgress(0); }; const handleDocumentClick = (doc: EnhancedDocument): void => { saveRecentSearch(query); setShowResults(false); navigate(`/documents/${doc.id}`); }; const handleRecentSearchClick = (searchQuery: string): void => { setQuery(searchQuery); performSearch(searchQuery); }; const handleSuggestionClick = (suggestion: string): void => { setQuery(suggestion); performSearch(suggestion); }; const handlePopularSearchClick = (search: string): void => { setQuery(search); performSearch(search); setShowResults(false); }; const handleKeyDown = (event: React.KeyboardEvent): void => { if (event.key === 'Enter' && query.trim()) { saveRecentSearch(query); setShowResults(false); navigate(`/search?q=${encodeURIComponent(query)}`); } if (event.key === 'Escape') { setShowResults(false); searchInputRef.current?.blur(); } }; 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]; }; // Function to highlight search terms in text (including substrings) const highlightText = useCallback((text: string, searchTerm: string): React.ReactNode => { if (!searchTerm || !text) return text; const terms = searchTerm.toLowerCase().split(/\s+/).filter(term => term.length >= 2); let highlightedText = text; terms.forEach(term => { const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); highlightedText = highlightedText.replace(regex, (match) => `**${match}**`); }); // Split by ** markers and create spans const parts = highlightedText.split(/\*\*(.*?)\*\*/); return parts.map((part, index) => { if (index % 2 === 1) { // This is a highlighted part return ( {part} ); } return part; }); }, [theme.palette.mode, theme.palette.primary]); // Enhanced search with context snippets const generateContextSnippet = useCallback((filename: string, searchTerm: string): string => { if (!searchTerm || !filename) return filename; const lowerFilename = filename.toLowerCase(); const lowerTerm = searchTerm.toLowerCase(); // Find the best match (exact term or substring) const exactMatch = lowerFilename.indexOf(lowerTerm); if (exactMatch !== -1) { // Show context around the match const start = Math.max(0, exactMatch - 10); const end = Math.min(filename.length, exactMatch + searchTerm.length + 10); const snippet = filename.substring(start, end); return start > 0 ? `...${snippet}` : snippet; } // Look for partial word matches const words = filename.split(/[_\-\s\.]/); const matchingWord = words.find(word => word.toLowerCase().includes(lowerTerm) || lowerTerm.includes(word.toLowerCase()) ); if (matchingWord) { const wordIndex = words.indexOf(matchingWord); const contextWords = words.slice(Math.max(0, wordIndex - 1), Math.min(words.length, wordIndex + 2)); return contextWords.join(' '); } return filename; }, []); return ( ), endAdornment: ( {(loading || isTyping) && ( 0 ? "determinate" : "indeterminate"} value={searchProgress} /> )} {query && ( )} ), }} sx={{ width: '100%', minWidth: 600, maxWidth: 1200, '& .MuiOutlinedInput-root': { background: theme.palette.mode === 'light' ? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)' : 'linear-gradient(135deg, rgba(50,50,50,0.95) 0%, rgba(30,30,30,0.90) 100%)', backdropFilter: 'blur(20px)', border: theme.palette.mode === 'light' ? '1px solid rgba(226,232,240,0.5)' : '1px solid rgba(255,255,255,0.1)', borderRadius: 3, transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: theme.palette.mode === 'light' ? '0 4px 16px rgba(0,0,0,0.04)' : '0 4px 16px rgba(0,0,0,0.2)', '&:hover': { background: theme.palette.mode === 'light' ? 'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.95) 100%)' : 'linear-gradient(135deg, rgba(60,60,60,0.98) 0%, rgba(40,40,40,0.95) 100%)', borderColor: 'rgba(99,102,241,0.4)', transform: 'translateY(-2px)', boxShadow: '0 8px 32px rgba(99,102,241,0.15)', }, '&.Mui-focused': { background: theme.palette.mode === 'light' ? 'linear-gradient(135deg, rgba(255,255,255,1) 0%, rgba(248,250,252,0.98) 100%)' : 'linear-gradient(135deg, rgba(70,70,70,1) 0%, rgba(50,50,50,0.98) 100%)', borderColor: '#6366f1', borderWidth: 2, transform: 'translateY(-2px)', boxShadow: '0 12px 40px rgba(99,102,241,0.2)', }, '& .MuiInputBase-input': { fontWeight: 500, letterSpacing: '0.025em', fontSize: '0.95rem', color: theme.palette.text.primary, '&::placeholder': { color: theme.palette.mode === 'light' ? 'rgba(148,163,184,0.8)' : 'rgba(200,200,200,0.6)', fontWeight: 400, }, }, }, }} /> {/* Enhanced Loading Progress Bar */} {(loading || isTyping || searchProgress > 0) && ( 0 ? "determinate" : "indeterminate"} value={searchProgress} sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, borderRadius: '0 0 4px 4px', opacity: isTyping ? 0.6 : 1, transition: 'opacity 0.2s ease-in-out', }} /> )} {/* Search Results Dropdown */} {({ TransitionProps }) => ( {(loading || isTyping) && ( {isTyping ? t('search.searchingAsYouType') : t('search.searching')} )} {/* Loading Skeletons for better UX */} {loading && query && ( {[1, 2, 3].map((i) => ( } secondary={} /> ))} )} {!loading && !isTyping && query && results.length === 0 && ( {t('search.noDocumentsFound', { query })} {t('search.pressEnterAdvanced')} {/* Smart suggestions for no results */} {suggestions.length > 0 && ( <> {t('search.trySuggestions')} {suggestions.map((suggestion, index) => ( handleSuggestionClick(suggestion)} sx={{ fontSize: '0.7rem', height: 24, fontWeight: 500, border: '1px solid rgba(99,102,241,0.3)', background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)', backdropFilter: 'blur(10px)', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', color: 'white', transform: 'translateY(-2px)', boxShadow: '0 8px 24px rgba(99,102,241,0.2)', }, }} /> ))} )} )} {!loading && !isTyping && results.length > 0 && ( <> {t('search.quickResults')} {t('search.resultsCount', { count: results.length })} {results.map((doc) => ( handleDocumentClick(doc)} sx={{ py: 1.5, cursor: 'pointer', borderRadius: 2, mx: 1, minWidth: 0, overflow: 'hidden', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { background: 'linear-gradient(135deg, rgba(99,102,241,0.08) 0%, rgba(139,92,246,0.08) 100%)', transform: 'translateX(4px)', boxShadow: '0 4px 16px rgba(99,102,241,0.1)', }, }} > {getFileIcon(doc.mime_type)} {highlightText(doc.original_filename || doc.filename, query)} } secondary={ {formatFileSize(doc.file_size)} {doc.has_ocr_text && ( )} {doc.search_rank && ( } label={`${(doc.search_rank * 100).toFixed(0)}%`} size="small" color="info" variant="outlined" sx={{ height: 16, fontSize: '0.6rem', '& .MuiChip-label': { color: theme.palette.mode === 'light' ? 'info.dark' : 'rgba(100, 181, 246, 0.8)', }, }} /> )} {/* Show content snippet if available */} {doc.snippets && doc.snippets.length > 0 && ( {highlightText(doc.snippets[0]?.text?.substring(0, 150) + '...' || '', query)} )} } /> ))} {results.length >= 5 && ( { saveRecentSearch(query); setShowResults(false); navigate(`/search?q=${encodeURIComponent(query)}`); }} > {t('search.viewAllResults', { query })} )} )} {!query && recentSearches.length > 0 && ( <> {t('search.recentSearches')} {recentSearches.map((search, index) => ( handleRecentSearchClick(search)} sx={{ py: 1.5, cursor: 'pointer', borderRadius: 2, mx: 1, minWidth: 0, overflow: 'hidden', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { background: 'linear-gradient(135deg, rgba(99,102,241,0.08) 0%, rgba(139,92,246,0.08) 100%)', transform: 'translateX(4px)', boxShadow: '0 4px 16px rgba(99,102,241,0.1)', }, }} > {search} } /> ))} )} {!query && recentSearches.length === 0 && ( {t('search.startTyping')} {t('search.popularSearches')} {popularSearches.slice(0, 3).map((search, index) => ( handlePopularSearchClick(search)} sx={{ fontSize: '0.75rem', fontWeight: 500, border: '1px solid rgba(99,102,241,0.3)', background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)', backdropFilter: 'blur(10px)', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', color: 'white', transform: 'translateY(-2px)', boxShadow: '0 8px 24px rgba(99,102,241,0.2)', } }} /> ))} )} )} ); }; export default GlobalSearchBar;