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 */} - setSettingsAnchor(null)} - PaperProps={{ sx: { width: 320, p: 2 } }} - > + {showSettings && ( + setSettingsAnchor(null)} + PaperProps={{ sx: { width: 320, p: 2 } }} + > Snippet Display Settings @@ -422,7 +445,8 @@ const EnhancedSnippetViewer: React.FC = ({ )} - + + )} ); }; 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 + + Readur Logo { + // Fallback to "R" if image fails to load + e.currentTarget.style.display = 'none'; + e.currentTarget.parentElement!.innerHTML = 'R'; + }} + /> + = ({ children }) => { Settings + window.open('/swagger-ui', '_blank')}> + API Documentation + + Logout 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 */} + + - - - - - - - - + {/* 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" + /> + + + )} + ); };