diff --git a/frontend/index.html b/frontend/index.html
index 83323fe..8e4f623 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,7 +2,7 @@
-
+
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000..3c5294e
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/readur-32.png b/frontend/public/readur-32.png
new file mode 100644
index 0000000..b21f223
Binary files /dev/null and b/frontend/public/readur-32.png differ
diff --git a/frontend/public/readur-64.png b/frontend/public/readur-64.png
new file mode 100644
index 0000000..bd1300d
Binary files /dev/null and b/frontend/public/readur-64.png differ
diff --git a/frontend/public/readur.png b/frontend/public/readur.png
new file mode 100644
index 0000000..7a67190
Binary files /dev/null and b/frontend/public/readur.png differ
diff --git a/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx b/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx
index 2923304..45f22bb 100644
--- a/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx
+++ b/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx
@@ -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 = ({
searchQuery,
maxSnippetsToShow = 3,
onSnippetClick,
+ viewMode: propViewMode,
+ highlightStyle: propHighlightStyle,
+ fontSize: propFontSize,
+ contextLength: propContextLength,
+ showSettings = true,
}) => {
const [expanded, setExpanded] = useState(false);
- const [viewMode, setViewMode] = useState('detailed');
- const [highlightStyle, setHighlightStyle] = useState('background');
- const [fontSize, setFontSize] = useState(14);
- const [contextLength, setContextLength] = useState(50);
+ const [viewMode, setViewMode] = useState(propViewMode || 'detailed');
+ const [highlightStyle, setHighlightStyle] = useState(propHighlightStyle || 'background');
+ const [fontSize, setFontSize] = useState(propFontSize || 15);
+ const [contextLength, setContextLength] = useState(propContextLength || 50);
const [settingsAnchor, setSettingsAnchor] = useState(null);
const [copiedIndex, setCopiedIndex] = useState(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 = ({
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 = ({
{!isCompact && (
-
-
- {snippet.page_number && (
-
- )}
- {snippet.confidence && snippet.confidence < 0.8 && (
-
- )}
+
+
+ {getSourceLabel(snippet.source)}
+ {snippet.page_number && ` • Page ${snippet.page_number}`}
+ {snippet.confidence && snippet.confidence < 0.8 && ` • ${(snippet.confidence * 100).toFixed(0)}% confidence`}
+
)}
@@ -237,74 +239,94 @@ const EnhancedSnippetViewer: React.FC = ({
color: 'text.primary',
wordWrap: 'break-word',
fontFamily: viewMode === 'context' ? 'monospace' : 'inherit',
+ mt: 0,
}}
>
{renderHighlightedText(snippet.text, snippet.highlight_ranges)}
-
-
- {
- e.stopPropagation();
- handleCopySnippet(snippet.text, index);
- }}
- sx={{
- color: copiedIndex === index ? 'success.main' : 'text.secondary'
- }}
- >
-
-
-
-
+ {!isCompact && (
+
+
+ {
+ e.stopPropagation();
+ handleCopySnippet(snippet.text, index);
+ }}
+ sx={{
+ color: copiedIndex === index ? 'success.main' : 'text.secondary',
+ p: 0.5,
+ }}
+ >
+
+
+
+
+ )}
);
};
return (
-
-
-
-
- Search Results
-
- {snippets.length > 0 && (
- 999 ? `${Math.floor(snippets.length/1000)}K` : snippets.length} matches`}
- size="small"
- color="primary"
- variant="outlined"
- sx={{ maxWidth: '100px', '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' } }}
- />
- )}
-
-
-
-
- setSettingsAnchor(e.currentTarget)}
- >
-
-
-
+
+ {showSettings && (
+
+
+
+ Search Results
+
+ {snippets.length > 0 && (
+ 999 ? `${Math.floor(snippets.length/1000)}K` : snippets.length} matches`}
+ size="small"
+ color="primary"
+ variant="outlined"
+ sx={{ maxWidth: '100px', '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' } }}
+ />
+ )}
+
- {snippets.length > maxSnippetsToShow && (
-
- )}
+
+
+ setSettingsAnchor(e.currentTarget)}
+ >
+
+
+
+
+ {snippets.length > maxSnippetsToShow && (
+
+ )}
+
-
+ )}
+
+ {!showSettings && snippets.length > maxSnippetsToShow && (
+
+
+
+ )}
- {searchQuery && (
+ {showSettings && searchQuery && (
Showing matches for: {searchQuery}
@@ -323,12 +345,13 @@ const EnhancedSnippetViewer: React.FC = ({
)}
{/* Settings Menu */}
-
>
)}
-
+
+ )}
);
};
diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx
index 5a66987..cd7b014 100644
--- a/frontend/src/components/Layout/AppLayout.tsx
+++ b/frontend/src/components/Layout/AppLayout.tsx
@@ -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 = ({ children }) => {
},
}}
>
- R
+
+
{
+ // Fallback to "R" if image fails to load
+ e.currentTarget.style.display = 'none';
+ e.currentTarget.parentElement!.innerHTML = 'R';
+ }}
+ />
+
= ({ children }) => {
Settings
+
+
diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx
index b3db44a..f37b626 100644
--- a/frontend/src/pages/SearchPage.tsx
+++ b/frontend/src/pages/SearchPage.tsx
@@ -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([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [viewMode, setViewMode] = useState('grid');
const [queryTime, setQueryTime] = useState(0);
const [totalResults, setTotalResults] = useState(0);
const [suggestions, setSuggestions] = useState([]);
@@ -164,6 +176,20 @@ const SearchPage: React.FC = () => {
enableAutoCorrect: true,
});
+ // Global snippet settings
+ const [snippetSettings, setSnippetSettings] = useState({
+ viewMode: 'detailed',
+ highlightStyle: 'background',
+ fontSize: 15,
+ contextLength: 50,
+ maxSnippetsToShow: 3,
+ });
+ const [snippetSettingsAnchor, setSnippetSettingsAnchor] = useState(null);
+
+ // Pagination states
+ const [currentPage, setCurrentPage] = useState(1);
+ const [resultsPerPage] = useState(20);
+
// Filter states
const [selectedTags, setSelectedTags] = useState([]);
const [selectedMimeTypes, setSelectedMimeTypes] = useState([]);
@@ -221,7 +247,7 @@ const SearchPage: React.FC = () => {
setQuickSuggestions(suggestions.slice(0, 3));
}, []);
- const performSearch = useCallback(async (query: string, filters: SearchFilters = {}): Promise => {
+ const performSearch = useCallback(async (query: string, filters: SearchFilters = {}, page: number = 1): Promise => {
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 ;
- if (mimeType.includes('image')) return ;
- if (mimeType.includes('text')) return ;
- return ;
+ if (mimeType.includes('pdf')) return ;
+ if (mimeType.includes('image')) return ;
+ if (mimeType.includes('text')) return ;
+ return ;
};
const formatFileSize = (bytes: number): string => {
@@ -447,11 +496,6 @@ const SearchPage: React.FC = () => {
setSearchQuery(suggestion);
};
- const handleViewModeChange = (event: React.MouseEvent, newView: ViewMode | null): void => {
- if (newView) {
- setViewMode(newView);
- }
- };
const handleSearchModeChange = (event: React.MouseEvent, 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, 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 (
@@ -535,13 +597,15 @@ const SearchPage: React.FC = () => {
)}
- setShowAdvanced(!showAdvanced)}
- color={showAdvanced ? 'primary' : 'default'}
- >
-
-
+
+ setShowAdvanced(!showAdvanced)}
+ color={showAdvanced ? 'primary' : 'default'}
+ >
+
+
+
setShowFilters(!showFilters)}
@@ -630,17 +694,19 @@ const SearchPage: React.FC = () => {
{/* Simplified Search Mode Selector */}
-
- Smart
- Exact phrase
- Similar words
- Advanced
-
+
+
+ Smart
+ Exact phrase
+ Similar words
+ Advanced
+
+
)}
@@ -892,33 +958,74 @@ const SearchPage: React.FC = () => {
{/* Search Results */}
-
+
- {/* Toolbar */}
+ {/* Results Header */}
{searchQuery && (
-
-
- {loading ? 'Searching...' : `${searchResults.length} results found`}
-
+
+
+
+ {loading ? 'Searching...' : `${searchResults.length} results found`}
+
+
+ {/* Snippet Settings Button */}
+ }
+ 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) && (
+
+ )}
+
+
-
-
-
-
-
-
-
-
+ {/* Current Settings Preview */}
+ {!loading && searchResults.length > 0 && (
+
+
+ Showing:
+
+
+
+
+
+ )}
)}
@@ -1023,35 +1130,42 @@ const SearchPage: React.FC = () => {
size="small"
variant="outlined"
clickable
- onClick={() => setSearchQuery('invoice')}
+ onClick={() => {
+ setSearchQuery('invoice');
+ setCurrentPage(1);
+ }}
/>
setSearchQuery('contract')}
+ onClick={() => {
+ setSearchQuery('contract');
+ setCurrentPage(1);
+ }}
/>
setSearchQuery('tag:important')}
+ onClick={() => {
+ setSearchQuery('tag:important');
+ setCurrentPage(1);
+ }}
/>
)}
{!loading && !error && searchResults.length > 0 && (
-
- {searchResults.map((doc) => (
+ <>
+
+ {searchResults.map((doc) => (
{
sx={{
height: '100%',
display: 'flex',
- flexDirection: viewMode === 'list' ? 'row' : 'column',
+ flexDirection: 'row',
}}
>
- {viewMode === 'grid' && (
-
-
+
+
+
+
{getFileIcon(doc.mime_type)}
-
- )}
-
-
-
- {viewMode === 'list' && (
-
- {getFileIcon(doc.mime_type)}
-
- )}
{
whiteSpace: 'nowrap',
display: 'block',
width: '100%',
+ color: 'text.primary',
}}
title={doc.original_filename}
>
{doc.original_filename}
-
-
-
- {doc.has_ocr_text && (
-
- )}
+
+
+ {formatFileSize(doc.file_size)} • {formatDate(doc.created_at)}
+ {doc.has_ocr_text && ' • OCR'}
+
{doc.tags.length > 0 && (
-
- {doc.tags.slice(0, 2).map((tag, index) => (
+
+
+ Tags:
+
+ {doc.tags.slice(0, 3).map((tag, index) => (
{
}}
/>
))}
- {doc.tags.length > 2 && (
-
+ {doc.tags.length > 3 && (
+
+ +{doc.tags.length - 3} more
+
)}
)}
{/* Enhanced Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && (
-
+
{
- // Could navigate to document with snippet highlighted
console.log('Snippet clicked:', snippet, index);
}}
/>
)}
- {/* Search Rank */}
- {doc.search_rank && (
-
-
-
- )}
-
+
navigate(`/documents/${doc.id}`)}
>
-
+
handleDownload(doc)}
>
-
+
@@ -1230,11 +1349,160 @@ const SearchPage: React.FC = () => {
- ))}
-
+ ))}
+
+
+ {/* Pagination */}
+ {totalResults > resultsPerPage && (
+
+
+
+ )}
+
+ {/* Results Summary */}
+
+
+ Showing {((currentPage - 1) * resultsPerPage) + 1}-{Math.min(currentPage * resultsPerPage, totalResults)} of {totalResults} results
+
+
+ >
)}
+
+ {/* Global Snippet Settings Menu */}
+ setSnippetSettingsAnchor(null)}
+ PaperProps={{ sx: { width: 320, p: 2 } }}
+ >
+
+ Text Display Settings
+
+
+
+
+ View Mode
+
+ setSnippetSettings(prev => ({ ...prev, viewMode: e.target.value as SnippetViewMode }))}
+ >
+ }
+ label="Compact"
+ />
+ }
+ label="Detailed"
+ />
+ }
+ label="Context Focus"
+ />
+
+
+
+
+
+
+
+ Highlight Style
+
+ setSnippetSettings(prev => ({ ...prev, highlightStyle: e.target.value as SnippetHighlightStyle }))}
+ >
+ }
+ label="Background Color"
+ />
+ }
+ label="Underline"
+ />
+ }
+ label="Bold Text"
+ />
+
+
+
+
+
+
+
+ Font Size: {snippetSettings.fontSize}px
+
+ setSnippetSettings(prev => ({ ...prev, fontSize: value as number }))}
+ min={12}
+ max={20}
+ marks
+ valueLabelDisplay="auto"
+ />
+
+
+
+
+
+
+ Snippets per result: {snippetSettings.maxSnippetsToShow}
+
+ setSnippetSettings(prev => ({ ...prev, maxSnippetsToShow: value as number }))}
+ min={1}
+ max={5}
+ marks
+ valueLabelDisplay="auto"
+ />
+
+
+ {snippetSettings.viewMode === 'context' && (
+ <>
+
+
+
+ Context Length: {snippetSettings.contextLength} characters
+
+ setSnippetSettings(prev => ({ ...prev, contextLength: value as number }))}
+ min={20}
+ max={200}
+ step={10}
+ marks
+ valueLabelDisplay="auto"
+ />
+
+ >
+ )}
+
);
};