Merge pull request #54 from readur/feat/search-page-and-icons

feat(client): fixup search page and add icons
This commit is contained in:
Jon Fuller 2025-06-26 12:36:24 -07:00 committed by GitHub
commit b8ddd1b263
8 changed files with 562 additions and 249 deletions

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
frontend/public/readur.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

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,7 @@ import {
Error as ErrorIcon, Error as ErrorIcon,
Label as LabelIcon, Label as LabelIcon,
Block as BlockIcon, Block as BlockIcon,
Api as ApiIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@ -160,7 +161,23 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
}, },
}} }}
> >
<Box sx={{ position: 'relative', zIndex: 1 }}>R</Box> <Box sx={{ position: 'relative', zIndex: 1 }}>
<img
src="/readur-32.png"
srcSet="/readur-32.png 1x, /readur-64.png 2x"
alt="Readur Logo"
style={{
width: '32px',
height: '32px',
objectFit: 'contain',
}}
onError={(e) => {
// Fallback to "R" if image fails to load
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement!.innerHTML = 'R';
}}
/>
</Box>
</Box> </Box>
<Box> <Box>
<Typography variant="h6" sx={{ <Typography variant="h6" sx={{
@ -514,6 +531,10 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
<SettingsIcon sx={{ mr: 2 }} /> Settings <SettingsIcon sx={{ mr: 2 }} /> Settings
</MenuItem> </MenuItem>
<Divider /> <Divider />
<MenuItem onClick={() => window.open('/swagger-ui', '_blank')}>
<ApiIcon sx={{ mr: 2 }} /> API Documentation
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}> <MenuItem onClick={handleLogout}>
<LogoutIcon sx={{ mr: 2 }} /> Logout <LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem> </MenuItem>

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 {
@ -42,8 +46,6 @@ import {
FilterList as FilterIcon, FilterList as FilterIcon,
Clear as ClearIcon, Clear as ClearIcon,
ExpandMore as ExpandMoreIcon, ExpandMore as ExpandMoreIcon,
GridView as GridViewIcon,
ViewList as ListViewIcon,
Download as DownloadIcon, Download as DownloadIcon,
PictureAsPdf as PdfIcon, PictureAsPdf as PdfIcon,
Image as ImageIcon, Image as ImageIcon,
@ -57,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';
@ -108,7 +111,6 @@ interface SearchFilters {
hasOcr?: string; hasOcr?: string;
} }
type ViewMode = 'grid' | 'list';
type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean'; type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean';
type OcrStatus = 'all' | 'yes' | 'no'; type OcrStatus = 'all' | 'yes' | 'no';
@ -126,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();
@ -133,7 +146,6 @@ const SearchPage: React.FC = () => {
const [searchResults, setSearchResults] = useState<Document[]>([]); const [searchResults, setSearchResults] = useState<Document[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [queryTime, setQueryTime] = useState<number>(0); const [queryTime, setQueryTime] = useState<number>(0);
const [totalResults, setTotalResults] = useState<number>(0); const [totalResults, setTotalResults] = useState<number>(0);
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<string[]>([]);
@ -164,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[]>([]);
@ -221,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);
@ -245,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,
@ -311,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]
); );
@ -336,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 });
@ -353,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 => {
@ -447,11 +496,6 @@ const SearchPage: React.FC = () => {
setSearchQuery(suggestion); setSearchQuery(suggestion);
}; };
const handleViewModeChange = (event: React.MouseEvent<HTMLElement>, newView: ViewMode | null): void => {
if (newView) {
setViewMode(newView);
}
};
const handleSearchModeChange = (event: React.MouseEvent<HTMLElement>, newMode: SearchMode | null): void => { const handleSearchModeChange = (event: React.MouseEvent<HTMLElement>, newMode: SearchMode | null): void => {
if (newMode) { if (newMode) {
@ -473,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 }}>
@ -535,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)}
@ -630,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>
)} )}
@ -892,33 +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">
{/* Toolbar */} {/* Results Header */}
{searchQuery && ( {searchQuery && (
<Box sx={{ <Box sx={{ mb: 3 }}>
mb: 3, <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 1 }}>
display: 'flex', <Typography variant="body2" color="text.secondary">
justifyContent: 'space-between', {loading ? 'Searching...' : `${searchResults.length} results found`}
alignItems: 'center', </Typography>
}}>
<Typography variant="body2" color="text.secondary"> {/* Snippet Settings Button */}
{loading ? 'Searching...' : `${searchResults.length} results found`} <Button
</Typography> 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>
<ToggleButtonGroup {/* Current Settings Preview */}
value={viewMode} {!loading && searchResults.length > 0 && (
exclusive <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
onChange={handleViewModeChange} <Typography variant="caption" color="text.secondary">
size="small" Showing:
> </Typography>
<ToggleButton value="grid"> <Chip
<GridViewIcon /> label={`${snippetSettings.maxSnippetsToShow} snippets`}
</ToggleButton> size="small"
<ToggleButton value="list"> variant="outlined"
<ListViewIcon /> sx={{ fontSize: '0.7rem' }}
</ToggleButton> />
</ToggleButtonGroup> <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>
)} )}
@ -1023,35 +1130,42 @@ 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={viewMode === 'grid' ? 3 : 1}> <>
{searchResults.map((doc) => ( <Grid container spacing={1}>
{searchResults.map((doc) => (
<Grid <Grid
item item
xs={12} xs={12}
sm={viewMode === 'grid' ? 6 : 12}
md={viewMode === 'grid' ? 6 : 12}
lg={viewMode === 'grid' ? 4 : 12}
key={doc.id} key={doc.id}
> >
<Card <Card
@ -1059,38 +1173,37 @@ const SearchPage: React.FC = () => {
sx={{ sx={{
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: viewMode === 'list' ? 'row' : 'column', flexDirection: 'row',
}} }}
> >
{viewMode === 'grid' && (
<Box <CardContent
sx={{ className="search-card"
height: 100, sx={{
display: 'flex', flexGrow: 1,
alignItems: 'center', overflow: 'hidden',
justifyContent: 'center', py: 1.5,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)', px: 2,
}} '&:last-child': {
> pb: 1.5
<Box sx={{ fontSize: '2.5rem' }}> }
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
width: '100%'
}}>
<Box sx={{ mr: 1.5, mt: 0.5, flexShrink: 0 }}>
{getFileIcon(doc.mime_type)} {getFileIcon(doc.mime_type)}
</Box> </Box>
</Box>
)}
<CardContent className="search-card" sx={{ flexGrow: 1, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%' }}>
{viewMode === 'list' && (
<Box sx={{ mr: 1, mt: 0.5 }}>
{getFileIcon(doc.mime_type)}
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0, overflow: 'hidden' }}> <Box sx={{ flexGrow: 1, minWidth: 0, overflow: 'hidden' }}>
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
fontSize: '0.95rem', fontSize: '1.05rem',
fontWeight: 600, fontWeight: 600,
mb: 1, mb: 1,
overflow: 'hidden', overflow: 'hidden',
@ -1098,42 +1211,40 @@ const SearchPage: React.FC = () => {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
display: 'block', display: 'block',
width: '100%', width: '100%',
color: 'text.primary',
}} }}
title={doc.original_filename} title={doc.original_filename}
> >
{doc.original_filename} {doc.original_filename}
</Typography> </Typography>
<Box sx={{ mb: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5, overflow: 'hidden' }}> <Box sx={{
<Chip mb: 0.5,
className="search-chip" display: 'flex',
label={formatFileSize(doc.file_size)} flexWrap: 'wrap',
size="small" gap: 0.75,
variant="outlined" overflow: 'hidden',
sx={{ flexShrink: 0 }} alignItems: 'center',
/> }}>
<Chip <Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
className="search-chip" {formatFileSize(doc.file_size)} {formatDate(doc.created_at)}
label={formatDate(doc.created_at)} {doc.has_ocr_text && ' • OCR'}
size="small" </Typography>
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={{ mb: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5, overflow: 'hidden' }}> <Box sx={{
{doc.tags.slice(0, 2).map((tag, index) => ( 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 <Chip
key={index} key={index}
className="search-chip" className="search-chip"
@ -1154,75 +1265,83 @@ 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>
)} )}
{/* Enhanced Search Snippets */} {/* Enhanced Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && ( {doc.snippets && doc.snippets.length > 0 && (
<Box sx={{ mt: 2, mb: 1 }}> <Box sx={{
mt: 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: 1, 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={{ display: 'flex', flexShrink: 0, ml: 'auto' }}> <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"> <Tooltip title="View Details">
<IconButton <IconButton
className="search-filter-button search-focusable" className="search-filter-button search-focusable"
size="small" 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}`)} onClick={() => navigate(`/documents/${doc.id}`)}
> >
<ViewIcon /> <ViewIcon sx={{ fontSize: '1.1rem' }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Download"> <Tooltip title="Download">
<IconButton <IconButton
className="search-filter-button search-focusable" className="search-filter-button search-focusable"
size="small" size="small"
sx={{
p: 0.75,
minWidth: 32,
minHeight: 32,
bgcolor: 'action.hover',
'&:hover': {
bgcolor: 'action.selected',
}
}}
onClick={() => handleDownload(doc)} onClick={() => handleDownload(doc)}
> >
<DownloadIcon /> <DownloadIcon sx={{ fontSize: '1.1rem' }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
@ -1230,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>
); );
}; };