901 lines
36 KiB
TypeScript
901 lines
36 KiB
TypeScript
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<Theme>;
|
|
[key: string]: any;
|
|
}
|
|
|
|
const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|
const navigate = useNavigate();
|
|
const theme = useTheme();
|
|
const { t } = useTranslation();
|
|
const [query, setQuery] = useState<string>('');
|
|
const [results, setResults] = useState<EnhancedDocument[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [showResults, setShowResults] = useState<boolean>(false);
|
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
const [isTyping, setIsTyping] = useState<boolean>(false);
|
|
const [searchProgress, setSearchProgress] = useState<number>(0);
|
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
const [popularSearches] = useState<string[]>(['invoice', 'contract', 'report', 'presentation', 'agreement']);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const anchorRef = useRef<HTMLDivElement>(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<void> => {
|
|
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<HTMLInputElement>): 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<HTMLInputElement>): 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 <PdfIcon color="error" />;
|
|
if (mimeType.includes('image')) return <ImageIcon color="primary" />;
|
|
if (mimeType.includes('text')) return <TextIcon color="info" />;
|
|
return <DocIcon color="secondary" />;
|
|
};
|
|
|
|
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 (
|
|
<Box
|
|
key={index}
|
|
component="mark"
|
|
sx={{
|
|
backgroundColor: theme.palette.mode === 'light'
|
|
? 'rgba(102, 126, 234, 0.2)'
|
|
: 'rgba(155, 181, 255, 0.25)',
|
|
color: theme.palette.mode === 'light'
|
|
? theme.palette.primary.dark
|
|
: theme.palette.primary.light,
|
|
padding: '0 2px',
|
|
borderRadius: '2px',
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{part}
|
|
</Box>
|
|
);
|
|
}
|
|
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 (
|
|
<ClickAwayListener onClickAway={handleClickAway}>
|
|
<Box sx={{ position: 'relative', ...sx }} {...props}>
|
|
<Box sx={{ position: 'relative' }}>
|
|
<TextField
|
|
ref={searchInputRef}
|
|
size="small"
|
|
placeholder={t('search.searchPlaceholder')}
|
|
value={query}
|
|
onChange={handleInputChange}
|
|
onFocus={handleInputFocus}
|
|
onKeyDown={handleKeyDown}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon color="action" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: (
|
|
<InputAdornment position="end">
|
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
|
{(loading || isTyping) && (
|
|
<CircularProgress
|
|
size={16}
|
|
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
|
value={searchProgress}
|
|
/>
|
|
)}
|
|
{query && (
|
|
<IconButton size="small" onClick={handleClear}>
|
|
<ClearIcon />
|
|
</IconButton>
|
|
)}
|
|
</Stack>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
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) && (
|
|
<LinearProgress
|
|
variant={searchProgress > 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',
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Search Results Dropdown */}
|
|
<Popper
|
|
open={showResults}
|
|
anchorEl={searchInputRef.current}
|
|
placement="bottom-start"
|
|
style={{ zIndex: 1300, width: searchInputRef.current?.offsetWidth }}
|
|
transition
|
|
>
|
|
{({ TransitionProps }) => (
|
|
<Grow {...TransitionProps}>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
mt: 1,
|
|
maxHeight: 420,
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
background: theme.palette.mode === 'light'
|
|
? 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.95) 100%)'
|
|
: 'linear-gradient(180deg, rgba(40,40,40,0.98) 0%, rgba(25,25,25,0.95) 100%)',
|
|
backdropFilter: 'blur(24px)',
|
|
border: theme.palette.mode === 'light'
|
|
? '1px solid rgba(226,232,240,0.6)'
|
|
: '1px solid rgba(255,255,255,0.1)',
|
|
borderRadius: 3,
|
|
boxShadow: theme.palette.mode === 'light'
|
|
? '0 20px 60px rgba(0,0,0,0.12), 0 8px 25px rgba(0,0,0,0.08)'
|
|
: '0 20px 60px rgba(0,0,0,0.4), 0 8px 25px rgba(0,0,0,0.3)',
|
|
width: '100%',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{(loading || isTyping) && (
|
|
<Box sx={{
|
|
p: 3,
|
|
textAlign: 'center',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, rgba(139,92,246,0.02) 100%)',
|
|
}}>
|
|
<Stack spacing={1.5} alignItems="center">
|
|
<Box sx={{
|
|
p: 1.5,
|
|
borderRadius: 2,
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}>
|
|
<CircularProgress size={20} thickness={4} sx={{ color: '#6366f1' }} />
|
|
</Box>
|
|
<Typography variant="body2" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 500,
|
|
letterSpacing: '0.025em',
|
|
}}>
|
|
{isTyping ? t('search.searchingAsYouType') : t('search.searching')}
|
|
</Typography>
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Loading Skeletons for better UX */}
|
|
{loading && query && (
|
|
<List sx={{ py: 0 }}>
|
|
{[1, 2, 3].map((i) => (
|
|
<ListItem key={i} sx={{ py: 1 }}>
|
|
<ListItemIcon sx={{ minWidth: 40 }}>
|
|
<Skeleton variant="circular" width={24} height={24} />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={<Skeleton variant="text" width="80%" />}
|
|
secondary={<Skeleton variant="text" width="60%" />}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
)}
|
|
|
|
{!loading && !isTyping && query && results.length === 0 && (
|
|
<Box sx={{
|
|
p: 3,
|
|
textAlign: 'center',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, rgba(139,92,246,0.02) 100%)',
|
|
}}>
|
|
<Typography variant="body2" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 500,
|
|
letterSpacing: '0.025em',
|
|
mb: 1,
|
|
}}>
|
|
{t('search.noDocumentsFound', { query })}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 500,
|
|
mb: 2,
|
|
display: 'block',
|
|
}}>
|
|
{t('search.pressEnterAdvanced')}
|
|
</Typography>
|
|
|
|
{/* Smart suggestions for no results */}
|
|
{suggestions.length > 0 && (
|
|
<>
|
|
<Typography variant="caption" sx={{
|
|
color: 'text.primary',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.05em',
|
|
textTransform: 'uppercase',
|
|
fontSize: '0.7rem',
|
|
mb: 1.5,
|
|
display: 'block',
|
|
}}>
|
|
{t('search.trySuggestions')}
|
|
</Typography>
|
|
<Stack direction="row" spacing={0.5} justifyContent="center" flexWrap="wrap">
|
|
{suggestions.map((suggestion, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={suggestion}
|
|
size="small"
|
|
variant="outlined"
|
|
clickable
|
|
onClick={() => 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)',
|
|
},
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{!loading && !isTyping && results.length > 0 && (
|
|
<>
|
|
<Box sx={{
|
|
p: 2,
|
|
borderBottom: '1px solid rgba(226,232,240,0.4)',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.03) 0%, rgba(139,92,246,0.03) 100%)',
|
|
}}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
<Typography variant="caption" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.05em',
|
|
textTransform: 'uppercase',
|
|
fontSize: '0.7rem',
|
|
}}>
|
|
{t('search.quickResults')}
|
|
</Typography>
|
|
<Box sx={{
|
|
px: 1.5,
|
|
py: 0.5,
|
|
borderRadius: 2,
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
color: 'white',
|
|
}}>
|
|
<Typography variant="caption" sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.7rem',
|
|
}}>
|
|
{t('search.resultsCount', { count: results.length })}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
<List sx={{ py: 0 }}>
|
|
{results.map((doc) => (
|
|
<ListItem
|
|
key={doc.id}
|
|
component="div"
|
|
onClick={() => 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)',
|
|
},
|
|
}}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: 40 }}>
|
|
{getFileIcon(doc.mime_type)}
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '100%',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{highlightText(doc.original_filename || doc.filename, query)}
|
|
</Typography>
|
|
}
|
|
secondary={
|
|
<Box>
|
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{formatFileSize(doc.file_size)}
|
|
</Typography>
|
|
{doc.has_ocr_text && (
|
|
<Chip
|
|
label="OCR"
|
|
size="small"
|
|
color="success"
|
|
variant="outlined"
|
|
sx={{
|
|
height: 16,
|
|
fontSize: '0.6rem',
|
|
'& .MuiChip-label': {
|
|
color: theme.palette.mode === 'light'
|
|
? 'success.dark'
|
|
: 'rgba(102, 187, 106, 0.8)',
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
{doc.search_rank && (
|
|
<Chip
|
|
icon={<TrendingIcon sx={{ fontSize: 10 }} />}
|
|
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)',
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Show content snippet if available */}
|
|
{doc.snippets && doc.snippets.length > 0 && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{
|
|
display: '-webkit-box',
|
|
overflow: 'hidden',
|
|
WebkitBoxOrient: 'vertical',
|
|
WebkitLineClamp: 2,
|
|
fontSize: '0.7rem',
|
|
fontStyle: 'italic',
|
|
maxWidth: '100%',
|
|
flex: 1,
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{highlightText(doc.snippets[0]?.text?.substring(0, 150) + '...' || '', query)}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
|
|
{results.length >= 5 && (
|
|
<Box sx={{
|
|
p: 2,
|
|
textAlign: 'center',
|
|
borderTop: '1px solid rgba(226,232,240,0.4)',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.03) 0%, rgba(139,92,246,0.03) 100%)',
|
|
}}>
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
px: 3,
|
|
py: 1.5,
|
|
borderRadius: 2,
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
'&:hover': {
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
transform: 'translateY(-2px)',
|
|
boxShadow: '0 8px 24px rgba(99,102,241,0.2)',
|
|
'& .view-all-text': {
|
|
color: 'white',
|
|
},
|
|
},
|
|
}}
|
|
onClick={() => {
|
|
saveRecentSearch(query);
|
|
setShowResults(false);
|
|
navigate(`/search?q=${encodeURIComponent(query)}`);
|
|
}}
|
|
>
|
|
<Typography
|
|
className="view-all-text"
|
|
variant="caption"
|
|
sx={{
|
|
color: '#6366f1',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.025em',
|
|
fontSize: '0.8rem',
|
|
transition: 'color 0.2s ease-in-out',
|
|
}}
|
|
>
|
|
{t('search.viewAllResults', { query })}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!query && recentSearches.length > 0 && (
|
|
<>
|
|
<Box sx={{
|
|
p: 2,
|
|
borderBottom: '1px solid rgba(226,232,240,0.4)',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.03) 0%, rgba(139,92,246,0.03) 100%)',
|
|
}}>
|
|
<Typography variant="caption" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.05em',
|
|
textTransform: 'uppercase',
|
|
fontSize: '0.7rem',
|
|
}}>
|
|
{t('search.recentSearches')}
|
|
</Typography>
|
|
</Box>
|
|
<List sx={{ py: 0 }}>
|
|
{recentSearches.map((search, index) => (
|
|
<ListItem
|
|
key={index}
|
|
component="div"
|
|
onClick={() => 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)',
|
|
},
|
|
}}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: 40 }}>
|
|
<TimeIcon color="action" />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '100%',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{search}
|
|
</Typography>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</>
|
|
)}
|
|
|
|
{!query && recentSearches.length === 0 && (
|
|
<Box sx={{
|
|
p: 3,
|
|
textAlign: 'center',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, rgba(139,92,246,0.02) 100%)',
|
|
}}>
|
|
<Typography variant="body2" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 500,
|
|
letterSpacing: '0.025em',
|
|
mb: 1,
|
|
}}>
|
|
{t('search.startTyping')}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{
|
|
color: 'text.secondary',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.05em',
|
|
textTransform: 'uppercase',
|
|
fontSize: '0.7rem',
|
|
mb: 2,
|
|
display: 'block',
|
|
}}>
|
|
{t('search.popularSearches')}
|
|
</Typography>
|
|
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
|
{popularSearches.slice(0, 3).map((search, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={search}
|
|
size="small"
|
|
variant="outlined"
|
|
clickable
|
|
onClick={() => 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)',
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
</Grow>
|
|
)}
|
|
</Popper>
|
|
</Box>
|
|
</ClickAwayListener>
|
|
);
|
|
};
|
|
|
|
export default GlobalSearchBar; |