feat(client): paginate search results and they look much better now

This commit is contained in:
perf3ct 2025-06-26 19:13:34 +00:00
parent 7dab092898
commit 33e697eb96
2 changed files with 498 additions and 208 deletions

View File

@ -49,6 +49,11 @@ interface EnhancedSnippetViewerProps {
searchQuery?: string; searchQuery?: string;
maxSnippetsToShow?: number; maxSnippetsToShow?: number;
onSnippetClick?: (snippet: Snippet, index: number) => void; onSnippetClick?: (snippet: Snippet, index: number) => void;
viewMode?: ViewMode;
highlightStyle?: HighlightStyle;
fontSize?: number;
contextLength?: number;
showSettings?: boolean;
} }
type ViewMode = 'compact' | 'detailed' | 'context'; type ViewMode = 'compact' | 'detailed' | 'context';
@ -59,15 +64,28 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
searchQuery, searchQuery,
maxSnippetsToShow = 3, maxSnippetsToShow = 3,
onSnippetClick, onSnippetClick,
viewMode: propViewMode,
highlightStyle: propHighlightStyle,
fontSize: propFontSize,
contextLength: propContextLength,
showSettings = true,
}) => { }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('detailed'); const [viewMode, setViewMode] = useState<ViewMode>(propViewMode || 'detailed');
const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>('background'); const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>(propHighlightStyle || 'background');
const [fontSize, setFontSize] = useState<number>(14); const [fontSize, setFontSize] = useState<number>(propFontSize || 15);
const [contextLength, setContextLength] = useState<number>(50); const [contextLength, setContextLength] = useState<number>(propContextLength || 50);
const [settingsAnchor, setSettingsAnchor] = useState<null | HTMLElement>(null); const [settingsAnchor, setSettingsAnchor] = useState<null | HTMLElement>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null); const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
// Update local state when props change
React.useEffect(() => {
if (propViewMode) setViewMode(propViewMode);
if (propHighlightStyle) setHighlightStyle(propHighlightStyle);
if (propFontSize) setFontSize(propFontSize);
if (propContextLength) setContextLength(propContextLength);
}, [propViewMode, propHighlightStyle, propFontSize, propContextLength]);
const visibleSnippets = expanded ? snippets : snippets.slice(0, maxSnippetsToShow); const visibleSnippets = expanded ? snippets : snippets.slice(0, maxSnippetsToShow);
const handleCopySnippet = (text: string, index: number) => { const handleCopySnippet = (text: string, index: number) => {
@ -187,16 +205,16 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
key={index} key={index}
variant="outlined" variant="outlined"
sx={{ sx={{
p: isCompact ? 1 : 2, p: isCompact ? 1 : 1.5,
mb: 1.5, mb: 0.75,
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.50' : 'grey.900', backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.50' : 'grey.900',
borderLeft: '3px solid', borderLeft: '2px solid',
borderLeftColor: snippet.source === 'ocr_text' ? 'warning.main' : 'primary.main', borderLeftColor: snippet.source === 'ocr_text' ? 'warning.main' : 'primary.main',
cursor: onSnippetClick ? 'pointer' : 'default', cursor: onSnippetClick ? 'pointer' : 'default',
transition: 'all 0.2s', transition: 'all 0.2s',
'&:hover': onSnippetClick ? { '&:hover': onSnippetClick ? {
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.100' : 'grey.800', backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.100' : 'grey.800',
transform: 'translateX(4px)', transform: 'translateX(2px)',
} : {}, } : {},
}} }}
onClick={() => onSnippetClick?.(snippet, index)} onClick={() => onSnippetClick?.(snippet, index)}
@ -204,28 +222,12 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
<Box display="flex" alignItems="flex-start" justifyContent="space-between"> <Box display="flex" alignItems="flex-start" justifyContent="space-between">
<Box flex={1}> <Box flex={1}>
{!isCompact && ( {!isCompact && (
<Box display="flex" alignItems="center" gap={1} mb={1}> <Box display="flex" alignItems="center" gap={1} mb={0.5}>
<Chip <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
icon={getSourceIcon(snippet.source)} {getSourceLabel(snippet.source)}
label={getSourceLabel(snippet.source)} {snippet.page_number && ` • Page ${snippet.page_number}`}
size="small" {snippet.confidence && snippet.confidence < 0.8 && `${(snippet.confidence * 100).toFixed(0)}% confidence`}
variant="outlined" </Typography>
/>
{snippet.page_number && (
<Chip
label={`Page ${snippet.page_number}`}
size="small"
variant="outlined"
/>
)}
{snippet.confidence && snippet.confidence < 0.8 && (
<Chip
label={`${(snippet.confidence * 100).toFixed(0)}% confidence`}
size="small"
color="warning"
variant="outlined"
/>
)}
</Box> </Box>
)} )}
@ -237,74 +239,94 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
color: 'text.primary', color: 'text.primary',
wordWrap: 'break-word', wordWrap: 'break-word',
fontFamily: viewMode === 'context' ? 'monospace' : 'inherit', fontFamily: viewMode === 'context' ? 'monospace' : 'inherit',
mt: 0,
}} }}
> >
{renderHighlightedText(snippet.text, snippet.highlight_ranges)} {renderHighlightedText(snippet.text, snippet.highlight_ranges)}
</Typography> </Typography>
</Box> </Box>
<Box display="flex" gap={0.5} ml={2}> {!isCompact && (
<Tooltip title="Copy snippet"> <Box display="flex" gap={0.5} ml={2}>
<IconButton <Tooltip title="Copy snippet">
size="small" <IconButton
onClick={(e) => { size="small"
e.stopPropagation(); onClick={(e) => {
handleCopySnippet(snippet.text, index); e.stopPropagation();
}} handleCopySnippet(snippet.text, index);
sx={{ }}
color: copiedIndex === index ? 'success.main' : 'text.secondary' sx={{
}} color: copiedIndex === index ? 'success.main' : 'text.secondary',
> p: 0.5,
<CopyIcon fontSize="small" /> }}
</IconButton> >
</Tooltip> <CopyIcon fontSize="small" />
</Box> </IconButton>
</Tooltip>
</Box>
)}
</Box> </Box>
</Paper> </Paper>
); );
}; };
return ( return (
<Box> <Box sx={{ mt: 0.5 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}> {showSettings && (
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Typography variant="subtitle2" fontWeight="bold"> <Box display="flex" alignItems="center" gap={1}>
Search Results <Typography variant="subtitle2" fontWeight="bold">
</Typography> Search Results
{snippets.length > 0 && ( </Typography>
<Chip {snippets.length > 0 && (
label={`${snippets.length > 999 ? `${Math.floor(snippets.length/1000)}K` : snippets.length} matches`} <Chip
size="small" label={`${snippets.length > 999 ? `${Math.floor(snippets.length/1000)}K` : snippets.length} matches`}
color="primary" size="small"
variant="outlined" color="primary"
sx={{ maxWidth: '100px', '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' } }} variant="outlined"
/> sx={{ maxWidth: '100px', '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' } }}
)} />
</Box> )}
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Tooltip title="Snippet settings">
<IconButton
size="small"
onClick={(e) => setSettingsAnchor(e.currentTarget)}
>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
{snippets.length > maxSnippetsToShow && ( <Box display="flex" alignItems="center" gap={1}>
<Button <Tooltip title="Snippet settings">
size="small" <IconButton
onClick={() => setExpanded(!expanded)} size="small"
endIcon={expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />} onClick={(e) => setSettingsAnchor(e.currentTarget)}
> >
{expanded ? 'Show Less' : `Show All (${snippets.length})`} <SettingsIcon fontSize="small" />
</Button> </IconButton>
)} </Tooltip>
{snippets.length > maxSnippetsToShow && (
<Button
size="small"
onClick={() => setExpanded(!expanded)}
endIcon={expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
>
{expanded ? 'Show Less' : `Show All (${snippets.length})`}
</Button>
)}
</Box>
</Box> </Box>
</Box> )}
{!showSettings && snippets.length > maxSnippetsToShow && (
<Box display="flex" alignItems="center" justifyContent="flex-end" mb={0.5}>
<Button
size="small"
variant="text"
onClick={() => setExpanded(!expanded)}
endIcon={expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ fontSize: '0.75rem', minHeight: 'auto', py: 0.25 }}
>
{expanded ? 'Show Less' : `Show All (${snippets.length})`}
</Button>
</Box>
)}
{searchQuery && ( {showSettings && searchQuery && (
<Box mb={2}> <Box mb={2}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Showing matches for: <strong>{searchQuery}</strong> Showing matches for: <strong>{searchQuery}</strong>
@ -323,12 +345,13 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
)} )}
{/* Settings Menu */} {/* Settings Menu */}
<Menu {showSettings && (
anchorEl={settingsAnchor} <Menu
open={Boolean(settingsAnchor)} anchorEl={settingsAnchor}
onClose={() => setSettingsAnchor(null)} open={Boolean(settingsAnchor)}
PaperProps={{ sx: { width: 320, p: 2 } }} onClose={() => setSettingsAnchor(null)}
> PaperProps={{ sx: { width: 320, p: 2 } }}
>
<Typography variant="subtitle2" sx={{ mb: 2 }}> <Typography variant="subtitle2" sx={{ mb: 2 }}>
Snippet Display Settings Snippet Display Settings
</Typography> </Typography>
@ -422,7 +445,8 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
</Box> </Box>
</> </>
)} )}
</Menu> </Menu>
)}
</Box> </Box>
); );
}; };

View File

@ -35,6 +35,10 @@ import {
Paper, Paper,
Skeleton, Skeleton,
SelectChangeEvent, SelectChangeEvent,
Menu,
RadioGroup,
Radio,
Pagination,
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/GridLegacy'; import Grid from '@mui/material/GridLegacy';
import { import {
@ -55,6 +59,7 @@ import {
Speed as SpeedIcon, Speed as SpeedIcon,
AccessTime as TimeIcon, AccessTime as TimeIcon,
TrendingUp as TrendingIcon, TrendingUp as TrendingIcon,
TextFormat as TextFormatIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { documentService, SearchRequest } from '../services/api'; import { documentService, SearchRequest } from '../services/api';
import SearchGuidance from '../components/SearchGuidance'; import SearchGuidance from '../components/SearchGuidance';
@ -123,6 +128,17 @@ interface AdvancedSearchSettings {
enableAutoCorrect: 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 SearchPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -160,6 +176,20 @@ const SearchPage: React.FC = () => {
enableAutoCorrect: true, 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 // Filter states
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedMimeTypes, setSelectedMimeTypes] = useState<string[]>([]); const [selectedMimeTypes, setSelectedMimeTypes] = useState<string[]>([]);
@ -217,7 +247,7 @@ const SearchPage: React.FC = () => {
setQuickSuggestions(suggestions.slice(0, 3)); setQuickSuggestions(suggestions.slice(0, 3));
}, []); }, []);
const performSearch = useCallback(async (query: string, filters: SearchFilters = {}): Promise<void> => { const performSearch = useCallback(async (query: string, filters: SearchFilters = {}, page: number = 1): Promise<void> => {
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]); setSearchResults([]);
setTotalResults(0); setTotalResults(0);
@ -241,8 +271,8 @@ const SearchPage: React.FC = () => {
query: query.trim(), query: query.trim(),
tags: filters.tags?.length ? filters.tags : undefined, tags: filters.tags?.length ? filters.tags : undefined,
mime_types: filters.mimeTypes?.length ? filters.mimeTypes : undefined, mime_types: filters.mimeTypes?.length ? filters.mimeTypes : undefined,
limit: advancedSettings.resultLimit, limit: resultsPerPage,
offset: 0, offset: (page - 1) * resultsPerPage,
include_snippets: advancedSettings.includeSnippets, include_snippets: advancedSettings.includeSnippets,
snippet_length: advancedSettings.snippetLength, snippet_length: advancedSettings.snippetLength,
search_mode: advancedSettings.searchMode, search_mode: advancedSettings.searchMode,
@ -307,7 +337,15 @@ const SearchPage: React.FC = () => {
}, [advancedSettings]); }, [advancedSettings]);
const debouncedSearch = useCallback( const debouncedSearch = useCallback(
debounce((query: string, filters: SearchFilters) => performSearch(query, filters), 300), 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] [performSearch]
); );
@ -332,9 +370,23 @@ const SearchPage: React.FC = () => {
fileSizeRange: fileSizeRange, fileSizeRange: fileSizeRange,
hasOcr: hasOcr, hasOcr: hasOcr,
}; };
debouncedSearch(searchQuery, filters); // 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); quickSuggestionsDebounced(searchQuery);
if (shouldResetPage) {
setCurrentPage(1);
}
// Update URL params when search query changes // Update URL params when search query changes
if (searchQuery) { if (searchQuery) {
setSearchParams({ q: searchQuery }); setSearchParams({ q: searchQuery });
@ -349,13 +401,14 @@ const SearchPage: React.FC = () => {
setDateRange([0, 365]); setDateRange([0, 365]);
setFileSizeRange([0, 100]); setFileSizeRange([0, 100]);
setHasOcr('all'); setHasOcr('all');
setCurrentPage(1);
}; };
const getFileIcon = (mimeType: string): React.ReactElement => { const getFileIcon = (mimeType: string): React.ReactElement => {
if (mimeType.includes('pdf')) return <PdfIcon color="error" />; if (mimeType.includes('pdf')) return <PdfIcon color="error" sx={{ fontSize: '1.2rem' }} />;
if (mimeType.includes('image')) return <ImageIcon color="primary" />; if (mimeType.includes('image')) return <ImageIcon color="primary" sx={{ fontSize: '1.2rem' }} />;
if (mimeType.includes('text')) return <TextIcon color="info" />; if (mimeType.includes('text')) return <TextIcon color="info" sx={{ fontSize: '1.2rem' }} />;
return <DocIcon color="secondary" />; return <DocIcon color="secondary" sx={{ fontSize: '1.2rem' }} />;
}; };
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
@ -464,6 +517,24 @@ const SearchPage: React.FC = () => {
setHasOcr(event.target.value as OcrStatus); 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 ( return (
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
@ -526,13 +597,15 @@ const SearchPage: React.FC = () => {
<ClearIcon /> <ClearIcon />
</IconButton> </IconButton>
)} )}
<IconButton <Tooltip title="Search Settings">
size="small" <IconButton
onClick={() => setShowAdvanced(!showAdvanced)} size="small"
color={showAdvanced ? 'primary' : 'default'} onClick={() => setShowAdvanced(!showAdvanced)}
> color={showAdvanced ? 'primary' : 'default'}
<SettingsIcon /> >
</IconButton> <SettingsIcon />
</IconButton>
</Tooltip>
<IconButton <IconButton
size="small" size="small"
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
@ -621,17 +694,19 @@ const SearchPage: React.FC = () => {
</Box> </Box>
{/* Simplified Search Mode Selector */} {/* Simplified Search Mode Selector */}
<ToggleButtonGroup <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
value={advancedSettings.searchMode} <ToggleButtonGroup
exclusive value={advancedSettings.searchMode}
onChange={handleSearchModeChange} exclusive
size="small" onChange={handleSearchModeChange}
> size="small"
<ToggleButton value="simple">Smart</ToggleButton> >
<ToggleButton value="phrase">Exact phrase</ToggleButton> <ToggleButton value="simple">Smart</ToggleButton>
<ToggleButton value="fuzzy">Similar words</ToggleButton> <ToggleButton value="phrase">Exact phrase</ToggleButton>
<ToggleButton value="boolean">Advanced</ToggleButton> <ToggleButton value="fuzzy">Similar words</ToggleButton>
</ToggleButtonGroup> <ToggleButton value="boolean">Advanced</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box> </Box>
)} )}
@ -883,14 +958,74 @@ const SearchPage: React.FC = () => {
</Grid> </Grid>
{/* Search Results */} {/* Search Results */}
<Grid item xs={12} md={9}> <Grid item xs={12} md={9} className="search-results-container">
{/* Results Header */} {/* Results Header */}
{searchQuery && ( {searchQuery && (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="body2" color="text.secondary"> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 1 }}>
{loading ? 'Searching...' : `${searchResults.length} results found`} <Typography variant="body2" color="text.secondary">
</Typography> {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> </Box>
)} )}
@ -995,29 +1130,39 @@ const SearchPage: React.FC = () => {
size="small" size="small"
variant="outlined" variant="outlined"
clickable clickable
onClick={() => setSearchQuery('invoice')} onClick={() => {
setSearchQuery('invoice');
setCurrentPage(1);
}}
/> />
<Chip <Chip
label="Try: contract" label="Try: contract"
size="small" size="small"
variant="outlined" variant="outlined"
clickable clickable
onClick={() => setSearchQuery('contract')} onClick={() => {
setSearchQuery('contract');
setCurrentPage(1);
}}
/> />
<Chip <Chip
label="Try: tag:important" label="Try: tag:important"
size="small" size="small"
variant="outlined" variant="outlined"
clickable clickable
onClick={() => setSearchQuery('tag:important')} onClick={() => {
setSearchQuery('tag:important');
setCurrentPage(1);
}}
/> />
</Stack> </Stack>
</Box> </Box>
)} )}
{!loading && !error && searchResults.length > 0 && ( {!loading && !error && searchResults.length > 0 && (
<Grid container spacing={1}> <>
{searchResults.map((doc) => ( <Grid container spacing={1}>
{searchResults.map((doc) => (
<Grid <Grid
item item
xs={12} xs={12}
@ -1050,7 +1195,7 @@ const SearchPage: React.FC = () => {
gap: 1, gap: 1,
width: '100%' width: '100%'
}}> }}>
<Box sx={{ mr: 1, mt: 0.5 }}> <Box sx={{ mr: 1.5, mt: 0.5, flexShrink: 0 }}>
{getFileIcon(doc.mime_type)} {getFileIcon(doc.mime_type)}
</Box> </Box>
@ -1058,14 +1203,15 @@ const SearchPage: React.FC = () => {
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
fontSize: '0.95rem', fontSize: '1.05rem',
fontWeight: 600, fontWeight: 600,
mb: 0.5, mb: 1,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
display: 'block', display: 'block',
width: '100%', width: '100%',
color: 'text.primary',
}} }}
title={doc.original_filename} title={doc.original_filename}
> >
@ -1076,44 +1222,29 @@ const SearchPage: React.FC = () => {
mb: 0.5, mb: 0.5,
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 0.5, gap: 0.75,
overflow: 'hidden' overflow: 'hidden',
alignItems: 'center',
}}> }}>
<Chip <Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
className="search-chip" {formatFileSize(doc.file_size)} {formatDate(doc.created_at)}
label={formatFileSize(doc.file_size)} {doc.has_ocr_text && ' • OCR'}
size="small" </Typography>
variant="outlined"
sx={{ flexShrink: 0 }}
/>
<Chip
className="search-chip"
label={formatDate(doc.created_at)}
size="small"
variant="outlined"
sx={{ flexShrink: 0 }}
/>
{doc.has_ocr_text && (
<Chip
className="search-chip"
label="OCR"
size="small"
color="success"
variant="outlined"
sx={{ flexShrink: 0 }}
/>
)}
</Box> </Box>
{doc.tags.length > 0 && ( {doc.tags.length > 0 && (
<Box sx={{ <Box sx={{
mb: 0.5, mb: 1,
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 0.5, gap: 0.5,
overflow: 'hidden' overflow: 'hidden',
alignItems: 'center',
}}> }}>
{doc.tags.slice(0, 2).map((tag, index) => ( <Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>
Tags:
</Typography>
{doc.tags.slice(0, 3).map((tag, index) => (
<Chip <Chip
key={index} key={index}
className="search-chip" className="search-chip"
@ -1134,14 +1265,10 @@ const SearchPage: React.FC = () => {
}} }}
/> />
))} ))}
{doc.tags.length > 2 && ( {doc.tags.length > 3 && (
<Chip <Typography variant="caption" color="text.secondary">
className="search-chip" +{doc.tags.length - 3} more
label={`+${doc.tags.length - 2}`} </Typography>
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', height: '18px', flexShrink: 0 }}
/>
)} )}
</Box> </Box>
)} )}
@ -1149,68 +1276,54 @@ const SearchPage: React.FC = () => {
{/* Enhanced Search Snippets */} {/* Enhanced Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && ( {doc.snippets && doc.snippets.length > 0 && (
<Box sx={{ <Box sx={{
mt: 1, mt: 0.5,
mb: 0.5 mb: 1
}}> }}>
<EnhancedSnippetViewer <EnhancedSnippetViewer
snippets={doc.snippets} snippets={doc.snippets}
searchQuery={searchQuery} searchQuery={searchQuery}
maxSnippetsToShow={2} maxSnippetsToShow={snippetSettings.maxSnippetsToShow}
viewMode={snippetSettings.viewMode}
highlightStyle={snippetSettings.highlightStyle}
fontSize={snippetSettings.fontSize}
contextLength={snippetSettings.contextLength}
showSettings={false}
onSnippetClick={(snippet, index) => { onSnippetClick={(snippet, index) => {
// Could navigate to document with snippet highlighted
console.log('Snippet clicked:', snippet, index); console.log('Snippet clicked:', snippet, index);
}} }}
/> />
</Box> </Box>
)} )}
{/* Search Rank */}
{doc.search_rank && (
<Box sx={{
mt: 0.5,
overflow: 'hidden'
}}>
<Chip
className="search-chip"
label={`Relevance: ${(doc.search_rank * 100).toFixed(1)}%`}
size="small"
color="info"
variant="outlined"
sx={{
fontSize: '0.7rem',
height: '18px',
flexShrink: 0,
maxWidth: '150px',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
}}
/>
</Box>
)}
</Box> </Box>
<Box sx={{ <Box sx={{
display: 'flex', display: 'flex',
flexDirection: 'column',
flexShrink: 0, flexShrink: 0,
ml: 'auto', ml: 2,
gap: 0.5, gap: 0.5,
alignItems: 'center' alignItems: 'center',
justifyContent: 'flex-start',
pt: 0.5,
}}> }}>
<Tooltip title="View Details"> <Tooltip title="View Details">
<IconButton <IconButton
className="search-filter-button search-focusable" className="search-filter-button search-focusable"
size="small" size="small"
sx={{ sx={{
p: 0.5, p: 0.75,
minWidth: 28, minWidth: 32,
minHeight: 28 minHeight: 32,
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
bgcolor: 'primary.dark',
}
}} }}
onClick={() => navigate(`/documents/${doc.id}`)} onClick={() => navigate(`/documents/${doc.id}`)}
> >
<ViewIcon sx={{ fontSize: '1rem' }} /> <ViewIcon sx={{ fontSize: '1.1rem' }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Download"> <Tooltip title="Download">
@ -1218,13 +1331,17 @@ const SearchPage: React.FC = () => {
className="search-filter-button search-focusable" className="search-filter-button search-focusable"
size="small" size="small"
sx={{ sx={{
p: 0.5, p: 0.75,
minWidth: 28, minWidth: 32,
minHeight: 28 minHeight: 32,
bgcolor: 'action.hover',
'&:hover': {
bgcolor: 'action.selected',
}
}} }}
onClick={() => handleDownload(doc)} onClick={() => handleDownload(doc)}
> >
<DownloadIcon sx={{ fontSize: '1rem' }} /> <DownloadIcon sx={{ fontSize: '1.1rem' }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
@ -1232,11 +1349,160 @@ const SearchPage: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
))} ))}
</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>
</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> </Box>
); );
}; };