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">
<head>
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<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;
maxSnippetsToShow?: number;
onSnippetClick?: (snippet: Snippet, index: number) => void;
viewMode?: ViewMode;
highlightStyle?: HighlightStyle;
fontSize?: number;
contextLength?: number;
showSettings?: boolean;
}
type ViewMode = 'compact' | 'detailed' | 'context';
@ -59,15 +64,28 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
searchQuery,
maxSnippetsToShow = 3,
onSnippetClick,
viewMode: propViewMode,
highlightStyle: propHighlightStyle,
fontSize: propFontSize,
contextLength: propContextLength,
showSettings = true,
}) => {
const [expanded, setExpanded] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('detailed');
const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>('background');
const [fontSize, setFontSize] = useState<number>(14);
const [contextLength, setContextLength] = useState<number>(50);
const [viewMode, setViewMode] = useState<ViewMode>(propViewMode || 'detailed');
const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>(propHighlightStyle || 'background');
const [fontSize, setFontSize] = useState<number>(propFontSize || 15);
const [contextLength, setContextLength] = useState<number>(propContextLength || 50);
const [settingsAnchor, setSettingsAnchor] = useState<null | HTMLElement>(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 handleCopySnippet = (text: string, index: number) => {
@ -187,16 +205,16 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
key={index}
variant="outlined"
sx={{
p: isCompact ? 1 : 2,
mb: 1.5,
p: isCompact ? 1 : 1.5,
mb: 0.75,
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',
cursor: onSnippetClick ? 'pointer' : 'default',
transition: 'all 0.2s',
'&:hover': onSnippetClick ? {
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.100' : 'grey.800',
transform: 'translateX(4px)',
transform: 'translateX(2px)',
} : {},
}}
onClick={() => onSnippetClick?.(snippet, index)}
@ -204,28 +222,12 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
<Box display="flex" alignItems="flex-start" justifyContent="space-between">
<Box flex={1}>
{!isCompact && (
<Box display="flex" alignItems="center" gap={1} mb={1}>
<Chip
icon={getSourceIcon(snippet.source)}
label={getSourceLabel(snippet.source)}
size="small"
variant="outlined"
/>
{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 display="flex" alignItems="center" gap={1} mb={0.5}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{getSourceLabel(snippet.source)}
{snippet.page_number && ` • Page ${snippet.page_number}`}
{snippet.confidence && snippet.confidence < 0.8 && `${(snippet.confidence * 100).toFixed(0)}% confidence`}
</Typography>
</Box>
)}
@ -237,12 +239,14 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
color: 'text.primary',
wordWrap: 'break-word',
fontFamily: viewMode === 'context' ? 'monospace' : 'inherit',
mt: 0,
}}
>
{renderHighlightedText(snippet.text, snippet.highlight_ranges)}
</Typography>
</Box>
{!isCompact && (
<Box display="flex" gap={0.5} ml={2}>
<Tooltip title="Copy snippet">
<IconButton
@ -252,20 +256,23 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
handleCopySnippet(snippet.text, index);
}}
sx={{
color: copiedIndex === index ? 'success.main' : 'text.secondary'
color: copiedIndex === index ? 'success.main' : 'text.secondary',
p: 0.5,
}}
>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
</Paper>
);
};
return (
<Box>
<Box sx={{ mt: 0.5 }}>
{showSettings && (
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="subtitle2" fontWeight="bold">
@ -303,8 +310,23 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
)}
</Box>
</Box>
)}
{searchQuery && (
{!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>
)}
{showSettings && searchQuery && (
<Box mb={2}>
<Typography variant="caption" color="text.secondary">
Showing matches for: <strong>{searchQuery}</strong>
@ -323,6 +345,7 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
)}
{/* Settings Menu */}
{showSettings && (
<Menu
anchorEl={settingsAnchor}
open={Boolean(settingsAnchor)}
@ -423,6 +446,7 @@ const EnhancedSnippetViewer: React.FC<EnhancedSnippetViewerProps> = ({
</>
)}
</Menu>
)}
</Box>
);
};

View File

@ -35,6 +35,7 @@ import {
Error as ErrorIcon,
Label as LabelIcon,
Block as BlockIcon,
Api as ApiIcon,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
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>
<Typography variant="h6" sx={{
@ -514,6 +531,10 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
<SettingsIcon sx={{ mr: 2 }} /> Settings
</MenuItem>
<Divider />
<MenuItem onClick={() => window.open('/swagger-ui', '_blank')}>
<ApiIcon sx={{ mr: 2 }} /> API Documentation
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>

View File

@ -35,6 +35,10 @@ import {
Paper,
Skeleton,
SelectChangeEvent,
Menu,
RadioGroup,
Radio,
Pagination,
} from '@mui/material';
import Grid from '@mui/material/GridLegacy';
import {
@ -42,8 +46,6 @@ import {
FilterList as FilterIcon,
Clear as ClearIcon,
ExpandMore as ExpandMoreIcon,
GridView as GridViewIcon,
ViewList as ListViewIcon,
Download as DownloadIcon,
PictureAsPdf as PdfIcon,
Image as ImageIcon,
@ -57,6 +59,7 @@ import {
Speed as SpeedIcon,
AccessTime as TimeIcon,
TrendingUp as TrendingIcon,
TextFormat as TextFormatIcon,
} from '@mui/icons-material';
import { documentService, SearchRequest } from '../services/api';
import SearchGuidance from '../components/SearchGuidance';
@ -108,7 +111,6 @@ interface SearchFilters {
hasOcr?: string;
}
type ViewMode = 'grid' | 'list';
type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean';
type OcrStatus = 'all' | 'yes' | 'no';
@ -126,6 +128,17 @@ interface AdvancedSearchSettings {
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 navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -133,7 +146,6 @@ const SearchPage: React.FC = () => {
const [searchResults, setSearchResults] = useState<Document[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [queryTime, setQueryTime] = useState<number>(0);
const [totalResults, setTotalResults] = useState<number>(0);
const [suggestions, setSuggestions] = useState<string[]>([]);
@ -164,6 +176,20 @@ const SearchPage: React.FC = () => {
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
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedMimeTypes, setSelectedMimeTypes] = useState<string[]>([]);
@ -221,7 +247,7 @@ const SearchPage: React.FC = () => {
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()) {
setSearchResults([]);
setTotalResults(0);
@ -245,8 +271,8 @@ const SearchPage: React.FC = () => {
query: query.trim(),
tags: filters.tags?.length ? filters.tags : undefined,
mime_types: filters.mimeTypes?.length ? filters.mimeTypes : undefined,
limit: advancedSettings.resultLimit,
offset: 0,
limit: resultsPerPage,
offset: (page - 1) * resultsPerPage,
include_snippets: advancedSettings.includeSnippets,
snippet_length: advancedSettings.snippetLength,
search_mode: advancedSettings.searchMode,
@ -311,7 +337,15 @@ const SearchPage: React.FC = () => {
}, [advancedSettings]);
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]
);
@ -336,9 +370,23 @@ const SearchPage: React.FC = () => {
fileSizeRange: fileSizeRange,
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);
if (shouldResetPage) {
setCurrentPage(1);
}
// Update URL params when search query changes
if (searchQuery) {
setSearchParams({ q: searchQuery });
@ -353,13 +401,14 @@ const SearchPage: React.FC = () => {
setDateRange([0, 365]);
setFileSizeRange([0, 100]);
setHasOcr('all');
setCurrentPage(1);
};
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" />;
if (mimeType.includes('pdf')) return <PdfIcon color="error" sx={{ fontSize: '1.2rem' }} />;
if (mimeType.includes('image')) return <ImageIcon color="primary" sx={{ fontSize: '1.2rem' }} />;
if (mimeType.includes('text')) return <TextIcon color="info" sx={{ fontSize: '1.2rem' }} />;
return <DocIcon color="secondary" sx={{ fontSize: '1.2rem' }} />;
};
const formatFileSize = (bytes: number): string => {
@ -447,11 +496,6 @@ const SearchPage: React.FC = () => {
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 => {
if (newMode) {
@ -473,6 +517,24 @@ const SearchPage: React.FC = () => {
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 (
<Box sx={{ p: 3 }}>
@ -535,6 +597,7 @@ const SearchPage: React.FC = () => {
<ClearIcon />
</IconButton>
)}
<Tooltip title="Search Settings">
<IconButton
size="small"
onClick={() => setShowAdvanced(!showAdvanced)}
@ -542,6 +605,7 @@ const SearchPage: React.FC = () => {
>
<SettingsIcon />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => setShowFilters(!showFilters)}
@ -630,6 +694,7 @@ const SearchPage: React.FC = () => {
</Box>
{/* Simplified Search Mode Selector */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<ToggleButtonGroup
value={advancedSettings.searchMode}
exclusive
@ -642,6 +707,7 @@ const SearchPage: React.FC = () => {
<ToggleButton value="boolean">Advanced</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
)}
{/* Quick Suggestions */}
@ -892,33 +958,74 @@ const SearchPage: React.FC = () => {
</Grid>
{/* Search Results */}
<Grid item xs={12} md={9}>
<Grid item xs={12} md={9} className="search-results-container">
{/* Toolbar */}
{/* Results Header */}
{searchQuery && (
<Box sx={{
mb: 3,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 1 }}>
<Typography variant="body2" color="text.secondary">
{loading ? 'Searching...' : `${searchResults.length} results found`}
</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={handleViewModeChange}
{/* Snippet Settings Button */}
<Button
variant="outlined"
size="small"
startIcon={<TextFormatIcon />}
onClick={(e) => setSnippetSettingsAnchor(e.currentTarget)}
sx={{
flexShrink: 0,
position: 'relative',
}}
>
<ToggleButton value="grid">
<GridViewIcon />
</ToggleButton>
<ToggleButton value="list">
<ListViewIcon />
</ToggleButton>
</ToggleButtonGroup>
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>
)}
@ -1023,35 +1130,42 @@ const SearchPage: React.FC = () => {
size="small"
variant="outlined"
clickable
onClick={() => setSearchQuery('invoice')}
onClick={() => {
setSearchQuery('invoice');
setCurrentPage(1);
}}
/>
<Chip
label="Try: contract"
size="small"
variant="outlined"
clickable
onClick={() => setSearchQuery('contract')}
onClick={() => {
setSearchQuery('contract');
setCurrentPage(1);
}}
/>
<Chip
label="Try: tag:important"
size="small"
variant="outlined"
clickable
onClick={() => setSearchQuery('tag:important')}
onClick={() => {
setSearchQuery('tag:important');
setCurrentPage(1);
}}
/>
</Stack>
</Box>
)}
{!loading && !error && searchResults.length > 0 && (
<Grid container spacing={viewMode === 'grid' ? 3 : 1}>
<>
<Grid container spacing={1}>
{searchResults.map((doc) => (
<Grid
item
xs={12}
sm={viewMode === 'grid' ? 6 : 12}
md={viewMode === 'grid' ? 6 : 12}
lg={viewMode === 'grid' ? 4 : 12}
key={doc.id}
>
<Card
@ -1059,38 +1173,37 @@ const SearchPage: React.FC = () => {
sx={{
height: '100%',
display: 'flex',
flexDirection: viewMode === 'list' ? 'row' : 'column',
flexDirection: 'row',
}}
>
{viewMode === 'grid' && (
<Box
<CardContent
className="search-card"
sx={{
height: 100,
flexGrow: 1,
overflow: 'hidden',
py: 1.5,
px: 2,
'&:last-child': {
pb: 1.5
}
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
}}
>
<Box sx={{ fontSize: '2.5rem' }}>
gap: 1,
width: '100%'
}}>
<Box sx={{ mr: 1.5, mt: 0.5, flexShrink: 0 }}>
{getFileIcon(doc.mime_type)}
</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' }}>
<Typography
variant="h6"
sx={{
fontSize: '0.95rem',
fontSize: '1.05rem',
fontWeight: 600,
mb: 1,
overflow: 'hidden',
@ -1098,42 +1211,40 @@ const SearchPage: React.FC = () => {
whiteSpace: 'nowrap',
display: 'block',
width: '100%',
color: 'text.primary',
}}
title={doc.original_filename}
>
{doc.original_filename}
</Typography>
<Box sx={{ mb: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5, overflow: 'hidden' }}>
<Chip
className="search-chip"
label={formatFileSize(doc.file_size)}
size="small"
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 sx={{
mb: 0.5,
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
overflow: 'hidden',
alignItems: 'center',
}}>
<Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
{formatFileSize(doc.file_size)} {formatDate(doc.created_at)}
{doc.has_ocr_text && ' • OCR'}
</Typography>
</Box>
{doc.tags.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5, overflow: 'hidden' }}>
{doc.tags.slice(0, 2).map((tag, index) => (
<Box sx={{
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
key={index}
className="search-chip"
@ -1154,75 +1265,83 @@ const SearchPage: React.FC = () => {
}}
/>
))}
{doc.tags.length > 2 && (
<Chip
className="search-chip"
label={`+${doc.tags.length - 2}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', height: '18px', flexShrink: 0 }}
/>
{doc.tags.length > 3 && (
<Typography variant="caption" color="text.secondary">
+{doc.tags.length - 3} more
</Typography>
)}
</Box>
)}
{/* Enhanced Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && (
<Box sx={{ mt: 2, mb: 1 }}>
<Box sx={{
mt: 0.5,
mb: 1
}}>
<EnhancedSnippetViewer
snippets={doc.snippets}
searchQuery={searchQuery}
maxSnippetsToShow={2}
maxSnippetsToShow={snippetSettings.maxSnippetsToShow}
viewMode={snippetSettings.viewMode}
highlightStyle={snippetSettings.highlightStyle}
fontSize={snippetSettings.fontSize}
contextLength={snippetSettings.contextLength}
showSettings={false}
onSnippetClick={(snippet, index) => {
// Could navigate to document with snippet highlighted
console.log('Snippet clicked:', snippet, index);
}}
/>
</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 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">
<IconButton
className="search-filter-button search-focusable"
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}`)}
>
<ViewIcon />
<ViewIcon sx={{ fontSize: '1.1rem' }} />
</IconButton>
</Tooltip>
<Tooltip title="Download">
<IconButton
className="search-filter-button search-focusable"
size="small"
sx={{
p: 0.75,
minWidth: 32,
minHeight: 32,
bgcolor: 'action.hover',
'&:hover': {
bgcolor: 'action.selected',
}
}}
onClick={() => handleDownload(doc)}
>
<DownloadIcon />
<DownloadIcon sx={{ fontSize: '1.1rem' }} />
</IconButton>
</Tooltip>
</Box>
@ -1232,9 +1351,158 @@ const SearchPage: React.FC = () => {
</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>
{/* 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>
);
};