feat(client): update the search functionality
This commit is contained in:
parent
52d006d403
commit
0abc8f272a
|
|
@ -30,6 +30,8 @@ dotenvy = "0.15"
|
|||
hostname = "0.4"
|
||||
walkdir = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
utoipa = { version = "4", features = ["axum_extras", "chrono", "uuid"] }
|
||||
utoipa-swagger-ui = { version = "6", features = ["axum"] }
|
||||
|
||||
[features]
|
||||
default = ["ocr"]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
ClickAwayListener,
|
||||
Grow,
|
||||
Popper,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
|
|
@ -36,6 +39,10 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [recentSearches, setRecentSearches] = useState([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState(0);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [popularSearches] = useState(['invoice', 'contract', 'report', 'presentation', 'agreement']);
|
||||
const searchInputRef = useRef(null);
|
||||
const anchorRef = useRef(null);
|
||||
|
||||
|
|
@ -64,23 +71,64 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
localStorage.setItem('recentSearches', JSON.stringify(updated));
|
||||
}, [recentSearches]);
|
||||
|
||||
// Debounced search function
|
||||
// Enhanced debounced search function with typing indicators
|
||||
const debounce = useCallback((func, delay) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||
setIsTyping(true);
|
||||
timeoutId = setTimeout(() => {
|
||||
setIsTyping(false);
|
||||
func.apply(null, args);
|
||||
}, delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Generate smart suggestions
|
||||
const generateSuggestions = useCallback((searchQuery) => {
|
||||
if (!searchQuery || searchQuery.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const smartSuggestions = [];
|
||||
|
||||
// Add similar popular searches
|
||||
const similar = popularSearches.filter(search =>
|
||||
search.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
searchQuery.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
smartSuggestions.push(...similar);
|
||||
|
||||
// Add exact phrase suggestion
|
||||
if (!searchQuery.includes('"')) {
|
||||
smartSuggestions.push(`"${searchQuery}"`);
|
||||
}
|
||||
|
||||
// Add tag search suggestion
|
||||
if (!searchQuery.startsWith('tag:')) {
|
||||
smartSuggestions.push(`tag:${searchQuery}`);
|
||||
}
|
||||
|
||||
setSuggestions(smartSuggestions.slice(0, 3));
|
||||
}, [popularSearches]);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setSearchProgress(0);
|
||||
|
||||
// Progressive loading for better UX
|
||||
const progressInterval = setInterval(() => {
|
||||
setSearchProgress(prev => Math.min(prev + 25, 90));
|
||||
}, 50);
|
||||
|
||||
const response = await documentService.enhancedSearch({
|
||||
query: searchQuery.trim(),
|
||||
limit: 5, // Show only top 5 results in global search
|
||||
|
|
@ -88,19 +136,30 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
search_mode: 'simple',
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setSearchProgress(100);
|
||||
setResults(response.data.documents || []);
|
||||
|
||||
// Clear progress after brief delay
|
||||
setTimeout(() => setSearchProgress(0), 300);
|
||||
} catch (error) {
|
||||
console.error('Global search failed:', error);
|
||||
setResults([]);
|
||||
setSearchProgress(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(performSearch, 300), // Faster debounce for global search
|
||||
debounce(performSearch, 200), // Even faster debounce for global search
|
||||
[performSearch]
|
||||
);
|
||||
|
||||
const debouncedSuggestions = useCallback(
|
||||
debounce(generateSuggestions, 100), // Very fast suggestions
|
||||
[generateSuggestions]
|
||||
);
|
||||
|
||||
const handleInputChange = (event) => {
|
||||
const value = event.target.value;
|
||||
|
|
@ -109,8 +168,10 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
|
||||
if (value.trim()) {
|
||||
debouncedSearch(value);
|
||||
debouncedSuggestions(value);
|
||||
} else {
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -125,7 +186,10 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
const handleClear = () => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
setShowResults(false);
|
||||
setIsTyping(false);
|
||||
setSearchProgress(0);
|
||||
};
|
||||
|
||||
const handleDocumentClick = (doc) => {
|
||||
|
|
@ -138,6 +202,17 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
setQuery(searchQuery);
|
||||
performSearch(searchQuery);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion) => {
|
||||
setQuery(suggestion);
|
||||
performSearch(suggestion);
|
||||
};
|
||||
|
||||
const handlePopularSearchClick = (search) => {
|
||||
setQuery(search);
|
||||
performSearch(search);
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && query.trim()) {
|
||||
|
|
@ -168,42 +243,77 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
return (
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<Box sx={{ position: 'relative', ...sx }} {...props}>
|
||||
<TextField
|
||||
ref={searchInputRef}
|
||||
size="small"
|
||||
placeholder="Search documents..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: query && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={handleClear}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 300,
|
||||
maxWidth: 400,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: 'background.paper',
|
||||
'&:hover': {
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TextField
|
||||
ref={searchInputRef}
|
||||
size="small"
|
||||
placeholder="Search documents..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{(loading || isTyping) && (
|
||||
<CircularProgress
|
||||
size={16}
|
||||
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||
value={searchProgress}
|
||||
/>
|
||||
)}
|
||||
{query && (
|
||||
<IconButton size="small" onClick={handleClear}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 300,
|
||||
maxWidth: 400,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: 'background.paper',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: 'background.paper',
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
backgroundColor: 'background.paper',
|
||||
borderColor: 'primary.main',
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
backgroundColor: 'background.paper',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Enhanced Loading Progress Bar */}
|
||||
{(loading || isTyping || searchProgress > 0) && (
|
||||
<LinearProgress
|
||||
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||
value={searchProgress}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
borderRadius: '0 0 4px 4px',
|
||||
opacity: isTyping ? 0.6 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
<Popper
|
||||
|
|
@ -225,31 +335,78 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
{(loading || isTyping) && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Searching...
|
||||
</Typography>
|
||||
<Stack spacing={1} alignItems="center">
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{isTyping ? 'Searching as you type...' : 'Searching...'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading Skeletons for better UX */}
|
||||
{loading && query && (
|
||||
<List sx={{ py: 0 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<ListItem key={i} sx={{ py: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Skeleton variant="circular" width={24} height={24} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Skeleton variant="text" width="80%" />}
|
||||
secondary={<Skeleton variant="text" width="60%" />}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{!loading && query && results.length === 0 && (
|
||||
{!loading && !isTyping && query && results.length === 0 && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No documents found
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
No documents found for "{query}"
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
|
||||
Press Enter to search with advanced options
|
||||
</Typography>
|
||||
|
||||
{/* Smart suggestions for no results */}
|
||||
{suggestions.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.primary" gutterBottom sx={{ display: 'block' }}>
|
||||
Try these suggestions:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} justifyContent="center" flexWrap="wrap">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={suggestion}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
{!loading && !isTyping && results.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ px: 1 }}>
|
||||
Quick Results
|
||||
</Typography>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Quick Results
|
||||
</Typography>
|
||||
<Typography variant="caption" color="primary">
|
||||
{results.length} found
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<List sx={{ py: 0 }}>
|
||||
{results.map((doc) => (
|
||||
|
|
@ -371,13 +528,30 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
|||
|
||||
{!query && recentSearches.length === 0 && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Start typing to search documents
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 1 }}>
|
||||
<Chip label="invoice" size="small" variant="outlined" />
|
||||
<Chip label="contract" size="small" variant="outlined" />
|
||||
<Chip label="report" size="small" variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
|
||||
Popular searches:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||
{popularSearches.slice(0, 3).map((search, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={search}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => handlePopularSearchClick(search)}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Stack,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Paper,
|
||||
IconButton,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Help as HelpIcon,
|
||||
Search as SearchIcon,
|
||||
FormatQuote as QuoteIcon,
|
||||
Tag as TagIcon,
|
||||
Extension as ExtensionIcon,
|
||||
Close as CloseIcon,
|
||||
TrendingUp as TrendingIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const SearchGuidance = ({ onExampleClick, compact = false, sx, ...props }) => {
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const searchExamples = [
|
||||
{
|
||||
query: 'invoice 2024',
|
||||
description: 'Find documents containing both "invoice" and "2024"',
|
||||
icon: <SearchIcon />,
|
||||
},
|
||||
{
|
||||
query: '"project proposal"',
|
||||
description: 'Search for exact phrase "project proposal"',
|
||||
icon: <QuoteIcon />,
|
||||
},
|
||||
{
|
||||
query: 'tag:important',
|
||||
description: 'Find all documents tagged as "important"',
|
||||
icon: <TagIcon />,
|
||||
},
|
||||
{
|
||||
query: 'contract AND payment',
|
||||
description: 'Advanced search using AND operator',
|
||||
icon: <ExtensionIcon />,
|
||||
},
|
||||
{
|
||||
query: 'proj*',
|
||||
description: 'Wildcard search for project, projects, etc.',
|
||||
icon: <TrendingIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
const searchTips = [
|
||||
'Use quotes for exact phrases: "annual report"',
|
||||
'Search by tags: tag:urgent or tag:personal',
|
||||
'Use AND/OR for complex queries: (invoice OR receipt) AND 2024',
|
||||
'Wildcards work great: proj* finds project, projects, projection',
|
||||
'Search OCR text in images and PDFs automatically',
|
||||
'File types are searchable: PDF, Word, Excel, images',
|
||||
];
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Box sx={{ position: 'relative', ...sx }} {...props}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
color={showHelp ? 'primary' : 'default'}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: 'background.paper',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showHelp ? <CloseIcon /> : <HelpIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Collapse in={showHelp}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 2,
|
||||
mt: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.light',
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HelpIcon color="primary" />
|
||||
Quick Search Tips
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={1}>
|
||||
{searchTips.slice(0, 3).map((tip, index) => (
|
||||
<Typography key={index} variant="body2" color="text.secondary" sx={{ fontSize: '0.8rem' }}>
|
||||
• {tip}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Try these examples:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mt: 0.5 }}>
|
||||
{searchExamples.slice(0, 3).map((example, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={example.query}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onExampleClick?.(example.query)}
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={sx} {...props}>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HelpIcon color="primary" />
|
||||
Search Help & Examples
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
{/* Search Examples */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Example Searches
|
||||
</Typography>
|
||||
<List dense>
|
||||
{searchExamples.map((example, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
button
|
||||
onClick={() => onExampleClick?.(example.query)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{example.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{example.query}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{example.description}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Search Tips */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Search Tips
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{searchTips.map((tip, index) => (
|
||||
<Typography key={index} variant="body2" color="text.secondary">
|
||||
• {tip}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Quick Start
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{searchExamples.map((example, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={example.query}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onExampleClick?.(example.query)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchGuidance;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SearchGuidance';
|
||||
|
|
@ -1,3 +1,74 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Enhanced search responsiveness styles */
|
||||
.search-input-responsive {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.search-input-responsive:focus-within {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Mobile-friendly search results */
|
||||
@media (max-width: 768px) {
|
||||
.search-results-grid {
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.search-chip {
|
||||
font-size: 0.7rem !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly interactive elements */
|
||||
@media (pointer: coarse) {
|
||||
.search-suggestion-chip {
|
||||
min-height: 32px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.search-filter-button {
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth animations for search loading states */
|
||||
.search-loading-fade {
|
||||
opacity: 0;
|
||||
animation: searchFadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes searchFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced hover states for better UX */
|
||||
.search-result-card {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Improved focus states for accessibility */
|
||||
.search-focusable:focus-visible {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ import {
|
|||
TrendingUp as TrendingIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { documentService } from '../services/api';
|
||||
import SearchGuidance from '../components/SearchGuidance';
|
||||
|
||||
const SearchPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -70,6 +71,16 @@ const SearchPage = () => {
|
|||
const [queryTime, setQueryTime] = useState(0);
|
||||
const [totalResults, setTotalResults] = useState(0);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState(0);
|
||||
const [quickSuggestions, setQuickSuggestions] = useState([]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchTips, setSearchTips] = useState([
|
||||
'Use quotes for exact phrases: "project plan"',
|
||||
'Search by tags: tag:important or tag:invoice',
|
||||
'Combine terms: contract AND payment',
|
||||
'Use wildcards: proj* for project, projects, etc.'
|
||||
]);
|
||||
|
||||
// Search settings
|
||||
const [useEnhancedSearch, setUseEnhancedSearch] = useState(true);
|
||||
|
|
@ -95,27 +106,65 @@ const SearchPage = () => {
|
|||
{ value: 'application/vnd.openxmlformats-officedocument', label: 'Office Documents' },
|
||||
];
|
||||
|
||||
// Debounced search
|
||||
// Enhanced debounced search with typing indicators
|
||||
const debounce = useCallback((func, delay) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||
setIsTyping(true);
|
||||
timeoutId = setTimeout(() => {
|
||||
setIsTyping(false);
|
||||
func.apply(null, args);
|
||||
}, delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Quick suggestions generator
|
||||
const generateQuickSuggestions = useCallback((query) => {
|
||||
if (!query || query.length < 2) {
|
||||
setQuickSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = [];
|
||||
|
||||
// Add exact phrase suggestion
|
||||
if (!query.includes('"')) {
|
||||
suggestions.push(`"${query}"`);
|
||||
}
|
||||
|
||||
// Add tag suggestions
|
||||
if (!query.startsWith('tag:')) {
|
||||
suggestions.push(`tag:${query}`);
|
||||
}
|
||||
|
||||
// Add wildcard suggestion
|
||||
if (!query.includes('*')) {
|
||||
suggestions.push(`${query}*`);
|
||||
}
|
||||
|
||||
setQuickSuggestions(suggestions.slice(0, 3));
|
||||
}, []);
|
||||
|
||||
const performSearch = useCallback(async (query, filters = {}) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setTotalResults(0);
|
||||
setQueryTime(0);
|
||||
setSuggestions([]);
|
||||
setQuickSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSearchProgress(0);
|
||||
|
||||
// Simulate progressive loading for better UX
|
||||
const progressInterval = setInterval(() => {
|
||||
setSearchProgress(prev => Math.min(prev + 20, 90));
|
||||
}, 100);
|
||||
|
||||
const searchRequest = {
|
||||
query: query.trim(),
|
||||
|
|
@ -162,6 +211,9 @@ const SearchPage = () => {
|
|||
});
|
||||
}
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setSearchProgress(100);
|
||||
|
||||
setSearchResults(results);
|
||||
setTotalResults(response.data.total || results.length);
|
||||
setQueryTime(response.data.query_time_ms || 0);
|
||||
|
|
@ -171,7 +223,12 @@ const SearchPage = () => {
|
|||
const tags = [...new Set(results.flatMap(doc => doc.tags))];
|
||||
setAvailableTags(tags);
|
||||
|
||||
// Clear progress after a brief delay
|
||||
setTimeout(() => setSearchProgress(0), 500);
|
||||
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
setSearchProgress(0);
|
||||
setError('Search failed. Please try again.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
|
|
@ -180,9 +237,14 @@ const SearchPage = () => {
|
|||
}, [useEnhancedSearch, includeSnippets, snippetLength, searchMode]);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((query, filters) => performSearch(query, filters), 500),
|
||||
debounce((query, filters) => performSearch(query, filters), 300),
|
||||
[performSearch]
|
||||
);
|
||||
|
||||
const quickSuggestionsDebounced = useCallback(
|
||||
debounce((query) => generateQuickSuggestions(query), 150),
|
||||
[generateQuickSuggestions]
|
||||
);
|
||||
|
||||
// Handle URL search params
|
||||
useEffect(() => {
|
||||
|
|
@ -201,6 +263,7 @@ const SearchPage = () => {
|
|||
hasOcr: hasOcr,
|
||||
};
|
||||
debouncedSearch(searchQuery, filters);
|
||||
quickSuggestionsDebounced(searchQuery);
|
||||
|
||||
// Update URL params when search query changes
|
||||
if (searchQuery) {
|
||||
|
|
@ -208,7 +271,7 @@ const SearchPage = () => {
|
|||
} else {
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, setSearchParams]);
|
||||
}, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, quickSuggestionsDebounced, setSearchParams]);
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedTags([]);
|
||||
|
|
@ -331,6 +394,7 @@ const SearchPage = () => {
|
|||
{/* Enhanced Search Bar */}
|
||||
<Paper
|
||||
elevation={3}
|
||||
className="search-input-responsive"
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
|
|
@ -355,7 +419,13 @@ const SearchPage = () => {
|
|||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Stack direction="row" spacing={1}>
|
||||
{loading && <CircularProgress size={20} />}
|
||||
{(loading || isTyping) && (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||
value={searchProgress}
|
||||
/>
|
||||
)}
|
||||
{searchQuery && (
|
||||
<IconButton
|
||||
size="small"
|
||||
|
|
@ -371,6 +441,14 @@ const SearchPage = () => {
|
|||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
color={showFilters ? 'primary' : 'default'}
|
||||
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
|
||||
>
|
||||
<FilterIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</InputAdornment>
|
||||
),
|
||||
|
|
@ -394,15 +472,19 @@ const SearchPage = () => {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* Loading Progress Bar */}
|
||||
{loading && (
|
||||
{/* Enhanced Loading Progress Bar */}
|
||||
{(loading || isTyping || searchProgress > 0) && (
|
||||
<LinearProgress
|
||||
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||
value={searchProgress}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderRadius: '0 0 4px 4px',
|
||||
opacity: isTyping ? 0.5 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -443,26 +525,54 @@ const SearchPage = () => {
|
|||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Search Mode Selector */}
|
||||
{/* Simplified Search Mode Selector */}
|
||||
<ToggleButtonGroup
|
||||
value={searchMode}
|
||||
exclusive
|
||||
onChange={(e, newMode) => newMode && setSearchMode(newMode)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="simple">Simple</ToggleButton>
|
||||
<ToggleButton value="phrase">Phrase</ToggleButton>
|
||||
<ToggleButton value="fuzzy">Fuzzy</ToggleButton>
|
||||
<ToggleButton value="boolean">Boolean</ToggleButton>
|
||||
<ToggleButton value="simple">Smart</ToggleButton>
|
||||
<ToggleButton value="phrase">Exact phrase</ToggleButton>
|
||||
<ToggleButton value="fuzzy">Similar words</ToggleButton>
|
||||
<ToggleButton value="boolean">Advanced</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{/* Quick Suggestions */}
|
||||
{quickSuggestions.length > 0 && searchQuery && !loading && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Quick suggestions:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{quickSuggestions.map((suggestion, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={suggestion}
|
||||
size="small"
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
clickable
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Server Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Suggestions:
|
||||
Related searches:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
|
|
@ -488,47 +598,58 @@ const SearchPage = () => {
|
|||
{/* Advanced Search Options */}
|
||||
{showAdvanced && (
|
||||
<Box sx={{ mt: 3, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Search Options
|
||||
</Typography>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={useEnhancedSearch}
|
||||
onChange={(e) => setUseEnhancedSearch(e.target.checked)}
|
||||
color="primary"
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Search Options
|
||||
</Typography>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={useEnhancedSearch}
|
||||
onChange={(e) => setUseEnhancedSearch(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Enhanced Search"
|
||||
/>
|
||||
}
|
||||
label="Enhanced Search"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={includeSnippets}
|
||||
onChange={(e) => setIncludeSnippets(e.target.checked)}
|
||||
color="primary"
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={includeSnippets}
|
||||
onChange={(e) => setIncludeSnippets(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Show Snippets"
|
||||
/>
|
||||
}
|
||||
label="Show Snippets"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Snippet Length</InputLabel>
|
||||
<Select
|
||||
value={snippetLength}
|
||||
onChange={(e) => setSnippetLength(e.target.value)}
|
||||
label="Snippet Length"
|
||||
>
|
||||
<MenuItem value={100}>Short (100)</MenuItem>
|
||||
<MenuItem value={200}>Medium (200)</MenuItem>
|
||||
<MenuItem value={400}>Long (400)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Snippet Length</InputLabel>
|
||||
<Select
|
||||
value={snippetLength}
|
||||
onChange={(e) => setSnippetLength(e.target.value)}
|
||||
label="Snippet Length"
|
||||
>
|
||||
<MenuItem value={100}>Short (100)</MenuItem>
|
||||
<MenuItem value={200}>Medium (200)</MenuItem>
|
||||
<MenuItem value={400}>Long (400)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Grid item xs={12} md={4}>
|
||||
<SearchGuidance
|
||||
compact
|
||||
onExampleClick={setSearchQuery}
|
||||
sx={{ position: 'relative' }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
|
@ -537,9 +658,32 @@ const SearchPage = () => {
|
|||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Filters Sidebar */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card sx={{ position: 'sticky', top: 20 }}>
|
||||
{/* Mobile Filters Drawer */}
|
||||
{showFilters && (
|
||||
<Grid item xs={12} sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
</Typography>
|
||||
<Button size="small" onClick={handleClearFilters} startIcon={<ClearIcon />}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
{/* Mobile filter content would go here - simplified */}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Mobile filters coming soon...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Desktop Filters Sidebar */}
|
||||
<Grid item xs={12} md={3} sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Card sx={{ position: 'sticky', top: 20 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
|
|
@ -750,11 +894,42 @@ const SearchPage = () => {
|
|||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No results found
|
||||
No results found for "{searchQuery}"
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Try adjusting your search terms or filters
|
||||
</Typography>
|
||||
|
||||
{/* Helpful suggestions for no results */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.primary" gutterBottom>
|
||||
Suggestions:
|
||||
</Typography>
|
||||
<Stack spacing={1} alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">• Try simpler or more general terms</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• Check spelling and try different keywords</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• Remove some filters to broaden your search</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• Use quotes for exact phrases</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleClearFilters}
|
||||
startIcon={<ClearIcon />}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
New Search
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
|
@ -771,13 +946,46 @@ const SearchPage = () => {
|
|||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Start searching your documents
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Use the enhanced search bar above to find documents by content, filename, or tags
|
||||
</Typography>
|
||||
|
||||
{/* Search Tips */}
|
||||
<Box sx={{ mb: 3, maxWidth: 600, mx: 'auto' }}>
|
||||
<Typography variant="subtitle2" color="text.primary" gutterBottom>
|
||||
Search Tips:
|
||||
</Typography>
|
||||
<Stack spacing={1} alignItems="center">
|
||||
{searchTips.map((tip, index) => (
|
||||
<Typography key={index} variant="body2" color="text.secondary" sx={{ fontSize: '0.85rem' }}>
|
||||
{tip}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||
<Chip label="Try: invoice" size="small" variant="outlined" />
|
||||
<Chip label="Try: contract" size="small" variant="outlined" />
|
||||
<Chip label="Try: tag:important" size="small" variant="outlined" />
|
||||
<Chip
|
||||
label="Try: invoice"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => setSearchQuery('invoice')}
|
||||
/>
|
||||
<Chip
|
||||
label="Try: contract"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => setSearchQuery('contract')}
|
||||
/>
|
||||
<Chip
|
||||
label="Try: tag:important"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => setSearchQuery('tag:important')}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -794,15 +1002,11 @@ const SearchPage = () => {
|
|||
key={doc.id}
|
||||
>
|
||||
<Card
|
||||
className="search-result-card search-loading-fade"
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: viewMode === 'list' ? 'row' : 'column',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: (theme) => theme.shadows[4],
|
||||
},
|
||||
}}
|
||||
>
|
||||
{viewMode === 'grid' && (
|
||||
|
|
@ -821,7 +1025,7 @@ const SearchPage = () => {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<CardContent className="search-card" sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
{viewMode === 'list' && (
|
||||
<Box sx={{ mr: 1, mt: 0.5 }}>
|
||||
|
|
@ -847,17 +1051,20 @@ const SearchPage = () => {
|
|||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<Chip
|
||||
className="search-chip"
|
||||
label={formatFileSize(doc.file_size)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
className="search-chip"
|
||||
label={formatDate(doc.created_at)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{doc.has_ocr_text && (
|
||||
<Chip
|
||||
className="search-chip"
|
||||
label="OCR"
|
||||
size="small"
|
||||
color="success"
|
||||
|
|
@ -871,6 +1078,7 @@ const SearchPage = () => {
|
|||
{doc.tags.slice(0, 2).map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
className="search-chip"
|
||||
label={tag}
|
||||
size="small"
|
||||
color="primary"
|
||||
|
|
@ -880,6 +1088,7 @@ const SearchPage = () => {
|
|||
))}
|
||||
{doc.tags.length > 2 && (
|
||||
<Chip
|
||||
className="search-chip"
|
||||
label={`+${doc.tags.length - 2}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
|
|
@ -928,6 +1137,7 @@ const SearchPage = () => {
|
|||
{doc.search_rank && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Chip
|
||||
className="search-chip"
|
||||
label={`Relevance: ${(doc.search_rank * 100).toFixed(1)}%`}
|
||||
size="small"
|
||||
color="info"
|
||||
|
|
@ -940,6 +1150,7 @@ const SearchPage = () => {
|
|||
|
||||
<Tooltip title="View Details">
|
||||
<IconButton
|
||||
className="search-filter-button search-focusable"
|
||||
size="small"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
>
|
||||
|
|
@ -948,6 +1159,7 @@ const SearchPage = () => {
|
|||
</Tooltip>
|
||||
<Tooltip title="Download">
|
||||
<IconButton
|
||||
className="search-filter-button search-focusable"
|
||||
size="small"
|
||||
onClick={() => handleDownload(doc)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod ocr;
|
|||
mod ocr_queue;
|
||||
mod routes;
|
||||
mod seed;
|
||||
mod swagger;
|
||||
mod watcher;
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -54,6 +55,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.nest("/api/search", routes::search::router())
|
||||
.nest("/api/settings", routes::settings::router())
|
||||
.nest("/api/users", routes::users::router())
|
||||
.merge(swagger::create_swagger_router())
|
||||
.nest_service("/", ServeDir::new("/app/frontend"))
|
||||
.fallback(serve_spa)
|
||||
.layer(CorsLayer::permissive())
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
|
|
@ -13,26 +14,26 @@ pub struct User {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateUser {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
|
|
@ -55,7 +56,7 @@ pub struct Document {
|
|||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
|
|
@ -67,7 +68,7 @@ pub struct DocumentResponse {
|
|||
pub has_ocr_text: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SearchRequest {
|
||||
pub query: String,
|
||||
pub tags: Option<Vec<String>>,
|
||||
|
|
@ -79,7 +80,7 @@ pub struct SearchRequest {
|
|||
pub search_mode: Option<SearchMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub enum SearchMode {
|
||||
#[serde(rename = "simple")]
|
||||
Simple,
|
||||
|
|
@ -97,7 +98,7 @@ impl Default for SearchMode {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SearchSnippet {
|
||||
pub text: String,
|
||||
pub start_offset: i32,
|
||||
|
|
@ -105,13 +106,13 @@ pub struct SearchSnippet {
|
|||
pub highlight_ranges: Vec<HighlightRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HighlightRange {
|
||||
pub start: i32,
|
||||
pub end: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EnhancedDocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
|
|
@ -125,7 +126,7 @@ pub struct EnhancedDocumentResponse {
|
|||
pub snippets: Vec<SearchSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SearchResponse {
|
||||
pub documents: Vec<EnhancedDocumentResponse>,
|
||||
pub total: i64,
|
||||
|
|
@ -158,14 +159,14 @@ impl From<User> for UserResponse {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateUser {
|
||||
pub username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)]
|
||||
pub struct Settings {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
|
|
@ -189,7 +190,7 @@ pub struct Settings {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SettingsResponse {
|
||||
pub ocr_language: String,
|
||||
pub concurrent_ocr_jobs: i32,
|
||||
|
|
@ -209,7 +210,7 @@ pub struct SettingsResponse {
|
|||
pub enable_background_ocr: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateSettings {
|
||||
pub ocr_language: Option<String>,
|
||||
pub concurrent_ocr_jobs: Option<i32>,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use axum::{
|
|||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use utoipa::path;
|
||||
|
||||
use crate::{
|
||||
auth::{create_jwt, AuthUser},
|
||||
|
|
@ -20,6 +21,16 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/me", get(me))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/register",
|
||||
tag = "auth",
|
||||
request_body = CreateUser,
|
||||
responses(
|
||||
(status = 200, description = "User registered successfully", body = UserResponse),
|
||||
(status = 400, description = "Bad request - invalid user data")
|
||||
)
|
||||
)]
|
||||
async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(user_data): Json<CreateUser>,
|
||||
|
|
@ -33,6 +44,16 @@ async fn register(
|
|||
Ok(Json(user.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/login",
|
||||
tag = "auth",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful", body = LoginResponse),
|
||||
(status = 401, description = "Unauthorized - invalid credentials")
|
||||
)
|
||||
)]
|
||||
async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(login_data): Json<LoginRequest>,
|
||||
|
|
@ -60,6 +81,18 @@ async fn login(
|
|||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/me",
|
||||
tag = "auth",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Current user information", body = UserResponse),
|
||||
(status = 401, description = "Unauthorized - invalid or missing token")
|
||||
)
|
||||
)]
|
||||
async fn me(auth_user: AuthUser) -> Json<UserResponse> {
|
||||
Json(auth_user.user.into())
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use axum::{
|
|||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use utoipa::{path, ToSchema};
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
|
|
@ -16,7 +17,7 @@ use crate::{
|
|||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
struct PaginationQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
|
|
@ -29,6 +30,21 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/:id/download", get(download_document))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents",
|
||||
tag = "documents",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
request_body(content = String, description = "Multipart form data with file", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Document uploaded successfully", body = DocumentResponse),
|
||||
(status = 400, description = "Bad request - invalid file or data"),
|
||||
(status = 413, description = "Payload too large - file exceeds size limit"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
async fn upload_document(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -119,6 +135,22 @@ async fn upload_document(
|
|||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents",
|
||||
tag = "documents",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "Number of documents to return (default: 50)"),
|
||||
("offset" = Option<i64>, Query, description = "Number of documents to skip (default: 0)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of user documents", body = Vec<DocumentResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
async fn list_documents(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -138,6 +170,22 @@ async fn list_documents(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/download",
|
||||
tag = "documents",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
params(
|
||||
("id" = uuid::Uuid, Path, description = "Document ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Document file content", content_type = "application/octet-stream"),
|
||||
(status = 404, description = "Document not found"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
async fn download_document(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use axum::{
|
|||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use utoipa::path;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
|
|
@ -19,6 +20,21 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/enhanced", get(enhanced_search_documents))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/search",
|
||||
tag = "search",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
params(
|
||||
SearchRequest
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Search results", body = SearchResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
async fn search_documents(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -51,6 +67,21 @@ async fn search_documents(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/search/enhanced",
|
||||
tag = "search",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
params(
|
||||
SearchRequest
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Enhanced search results with snippets and suggestions", body = SearchResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
async fn enhanced_search_documents(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ use axum::{
|
|||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::get,
|
||||
routing::{get, put},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use utoipa::path;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
models::{
|
||||
CreateUser, LoginRequest, LoginResponse, UserResponse, UpdateUser,
|
||||
DocumentResponse, SearchRequest, SearchResponse, EnhancedDocumentResponse,
|
||||
SettingsResponse, UpdateSettings, SearchMode, SearchSnippet, HighlightRange
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
// Auth endpoints
|
||||
crate::routes::auth::register,
|
||||
crate::routes::auth::login,
|
||||
crate::routes::auth::me,
|
||||
// Document endpoints
|
||||
crate::routes::documents::upload_document,
|
||||
crate::routes::documents::list_documents,
|
||||
crate::routes::documents::download_document,
|
||||
// Search endpoints
|
||||
crate::routes::search::search_documents,
|
||||
// Settings endpoints
|
||||
crate::routes::settings::get_settings,
|
||||
crate::routes::settings::update_settings,
|
||||
// User endpoints
|
||||
crate::routes::users::get_user,
|
||||
crate::routes::users::update_user,
|
||||
crate::routes::users::delete_user,
|
||||
// Queue endpoints
|
||||
crate::routes::queue::get_queue_status,
|
||||
crate::routes::queue::get_queue_stats,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
CreateUser, LoginRequest, LoginResponse, UserResponse, UpdateUser,
|
||||
DocumentResponse, SearchRequest, SearchResponse, EnhancedDocumentResponse,
|
||||
SettingsResponse, UpdateSettings, SearchMode, SearchSnippet, HighlightRange
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "auth", description = "Authentication endpoints"),
|
||||
(name = "documents", description = "Document management endpoints"),
|
||||
(name = "search", description = "Document search endpoints"),
|
||||
(name = "settings", description = "User settings endpoints"),
|
||||
(name = "users", description = "User management endpoints"),
|
||||
(name = "queue", description = "OCR queue management endpoints"),
|
||||
),
|
||||
info(
|
||||
title = "Readur API",
|
||||
version = "0.1.0",
|
||||
description = "Document management and OCR processing API",
|
||||
contact(
|
||||
name = "Readur Team",
|
||||
email = "support@readur.dev"
|
||||
)
|
||||
),
|
||||
servers(
|
||||
(url = "/api", description = "API base path")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
pub fn create_swagger_router() -> Router<Arc<AppState>> {
|
||||
SwaggerUi::new("/swagger-ui")
|
||||
.url("/api-docs/openapi.json", ApiDoc::openapi())
|
||||
}
|
||||
Loading…
Reference in New Issue