Merge pull request #54 from readur/feat/search-page-and-icons
feat(client): fixup search page and add icons
This commit is contained in:
commit
b8ddd1b263
|
|
@ -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>
|
||||||
|
|
|
||||||
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 |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue