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"
|
hostname = "0.4"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
utoipa = { version = "4", features = ["axum_extras", "chrono", "uuid"] }
|
||||||
|
utoipa-swagger-ui = { version = "6", features = ["axum"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["ocr"]
|
default = ["ocr"]
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
ClickAwayListener,
|
ClickAwayListener,
|
||||||
Grow,
|
Grow,
|
||||||
Popper,
|
Popper,
|
||||||
|
CircularProgress,
|
||||||
|
LinearProgress,
|
||||||
|
Skeleton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
|
|
@ -36,6 +39,10 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const [recentSearches, setRecentSearches] = useState([]);
|
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 searchInputRef = useRef(null);
|
||||||
const anchorRef = useRef(null);
|
const anchorRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -64,23 +71,64 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
localStorage.setItem('recentSearches', JSON.stringify(updated));
|
localStorage.setItem('recentSearches', JSON.stringify(updated));
|
||||||
}, [recentSearches]);
|
}, [recentSearches]);
|
||||||
|
|
||||||
// Debounced search function
|
// Enhanced debounced search function with typing indicators
|
||||||
const debounce = useCallback((func, delay) => {
|
const debounce = useCallback((func, delay) => {
|
||||||
let timeoutId;
|
let timeoutId;
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
clearTimeout(timeoutId);
|
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) => {
|
const performSearch = useCallback(async (searchQuery) => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setSuggestions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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({
|
const response = await documentService.enhancedSearch({
|
||||||
query: searchQuery.trim(),
|
query: searchQuery.trim(),
|
||||||
limit: 5, // Show only top 5 results in global search
|
limit: 5, // Show only top 5 results in global search
|
||||||
|
|
@ -88,19 +136,30 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
search_mode: 'simple',
|
search_mode: 'simple',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setSearchProgress(100);
|
||||||
setResults(response.data.documents || []);
|
setResults(response.data.documents || []);
|
||||||
|
|
||||||
|
// Clear progress after brief delay
|
||||||
|
setTimeout(() => setSearchProgress(0), 300);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Global search failed:', error);
|
console.error('Global search failed:', error);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setSearchProgress(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const debouncedSearch = useCallback(
|
const debouncedSearch = useCallback(
|
||||||
debounce(performSearch, 300), // Faster debounce for global search
|
debounce(performSearch, 200), // Even faster debounce for global search
|
||||||
[performSearch]
|
[performSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const debouncedSuggestions = useCallback(
|
||||||
|
debounce(generateSuggestions, 100), // Very fast suggestions
|
||||||
|
[generateSuggestions]
|
||||||
|
);
|
||||||
|
|
||||||
const handleInputChange = (event) => {
|
const handleInputChange = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
|
|
@ -109,8 +168,10 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
|
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
debouncedSearch(value);
|
debouncedSearch(value);
|
||||||
|
debouncedSuggestions(value);
|
||||||
} else {
|
} else {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -125,7 +186,10 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setSuggestions([]);
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
|
setIsTyping(false);
|
||||||
|
setSearchProgress(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDocumentClick = (doc) => {
|
const handleDocumentClick = (doc) => {
|
||||||
|
|
@ -138,6 +202,17 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
setQuery(searchQuery);
|
setQuery(searchQuery);
|
||||||
performSearch(searchQuery);
|
performSearch(searchQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSuggestionClick = (suggestion) => {
|
||||||
|
setQuery(suggestion);
|
||||||
|
performSearch(suggestion);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopularSearchClick = (search) => {
|
||||||
|
setQuery(search);
|
||||||
|
performSearch(search);
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
if (event.key === 'Enter' && query.trim()) {
|
if (event.key === 'Enter' && query.trim()) {
|
||||||
|
|
@ -168,42 +243,77 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<ClickAwayListener onClickAway={handleClickAway}>
|
<ClickAwayListener onClickAway={handleClickAway}>
|
||||||
<Box sx={{ position: 'relative', ...sx }} {...props}>
|
<Box sx={{ position: 'relative', ...sx }} {...props}>
|
||||||
<TextField
|
<Box sx={{ position: 'relative' }}>
|
||||||
ref={searchInputRef}
|
<TextField
|
||||||
size="small"
|
ref={searchInputRef}
|
||||||
placeholder="Search documents..."
|
size="small"
|
||||||
value={query}
|
placeholder="Search documents..."
|
||||||
onChange={handleInputChange}
|
value={query}
|
||||||
onFocus={handleInputFocus}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onFocus={handleInputFocus}
|
||||||
InputProps={{
|
onKeyDown={handleKeyDown}
|
||||||
startAdornment: (
|
InputProps={{
|
||||||
<InputAdornment position="start">
|
startAdornment: (
|
||||||
<SearchIcon color="action" />
|
<InputAdornment position="start">
|
||||||
</InputAdornment>
|
<SearchIcon color="action" />
|
||||||
),
|
</InputAdornment>
|
||||||
endAdornment: query && (
|
),
|
||||||
<InputAdornment position="end">
|
endAdornment: (
|
||||||
<IconButton size="small" onClick={handleClear}>
|
<InputAdornment position="end">
|
||||||
<ClearIcon />
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
</IconButton>
|
{(loading || isTyping) && (
|
||||||
</InputAdornment>
|
<CircularProgress
|
||||||
),
|
size={16}
|
||||||
}}
|
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||||
sx={{
|
value={searchProgress}
|
||||||
minWidth: 300,
|
/>
|
||||||
maxWidth: 400,
|
)}
|
||||||
'& .MuiOutlinedInput-root': {
|
{query && (
|
||||||
backgroundColor: 'background.paper',
|
<IconButton size="small" onClick={handleClear}>
|
||||||
'&:hover': {
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
minWidth: 300,
|
||||||
|
maxWidth: 400,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
backgroundColor: 'background.paper',
|
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 */}
|
{/* Search Results Dropdown */}
|
||||||
<Popper
|
<Popper
|
||||||
|
|
@ -225,31 +335,78 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading && (
|
{(loading || isTyping) && (
|
||||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Stack spacing={1} alignItems="center">
|
||||||
Searching...
|
<CircularProgress size={20} />
|
||||||
</Typography>
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{isTyping ? 'Searching as you type...' : 'Searching...'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</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' }}>
|
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
No documents found
|
No documents found for "{query}"
|
||||||
</Typography>
|
</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
|
Press Enter to search with advanced options
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && results.length > 0 && (
|
{!loading && !isTyping && results.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
|
<Box sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ px: 1 }}>
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1 }}>
|
||||||
Quick Results
|
<Typography variant="caption" color="text.secondary">
|
||||||
</Typography>
|
Quick Results
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="primary">
|
||||||
|
{results.length} found
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<List sx={{ py: 0 }}>
|
<List sx={{ py: 0 }}>
|
||||||
{results.map((doc) => (
|
{results.map((doc) => (
|
||||||
|
|
@ -371,13 +528,30 @@ const GlobalSearchBar = ({ sx, ...props }) => {
|
||||||
|
|
||||||
{!query && recentSearches.length === 0 && (
|
{!query && recentSearches.length === 0 && (
|
||||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
Start typing to search documents
|
Start typing to search documents
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 1 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
|
||||||
<Chip label="invoice" size="small" variant="outlined" />
|
Popular searches:
|
||||||
<Chip label="contract" size="small" variant="outlined" />
|
</Typography>
|
||||||
<Chip label="report" size="small" variant="outlined" />
|
<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>
|
</Stack>
|
||||||
</Box>
|
</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 base;
|
||||||
@tailwind components;
|
@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,
|
TrendingUp as TrendingIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { documentService } from '../services/api';
|
import { documentService } from '../services/api';
|
||||||
|
import SearchGuidance from '../components/SearchGuidance';
|
||||||
|
|
||||||
const SearchPage = () => {
|
const SearchPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -70,6 +71,16 @@ const SearchPage = () => {
|
||||||
const [queryTime, setQueryTime] = useState(0);
|
const [queryTime, setQueryTime] = useState(0);
|
||||||
const [totalResults, setTotalResults] = useState(0);
|
const [totalResults, setTotalResults] = useState(0);
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
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
|
// Search settings
|
||||||
const [useEnhancedSearch, setUseEnhancedSearch] = useState(true);
|
const [useEnhancedSearch, setUseEnhancedSearch] = useState(true);
|
||||||
|
|
@ -95,27 +106,65 @@ const SearchPage = () => {
|
||||||
{ value: 'application/vnd.openxmlformats-officedocument', label: 'Office Documents' },
|
{ value: 'application/vnd.openxmlformats-officedocument', label: 'Office Documents' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Debounced search
|
// Enhanced debounced search with typing indicators
|
||||||
const debounce = useCallback((func, delay) => {
|
const debounce = useCallback((func, delay) => {
|
||||||
let timeoutId;
|
let timeoutId;
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
clearTimeout(timeoutId);
|
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 = {}) => {
|
const performSearch = useCallback(async (query, filters = {}) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setTotalResults(0);
|
setTotalResults(0);
|
||||||
setQueryTime(0);
|
setQueryTime(0);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setQuickSuggestions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSearchProgress(0);
|
||||||
|
|
||||||
|
// Simulate progressive loading for better UX
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setSearchProgress(prev => Math.min(prev + 20, 90));
|
||||||
|
}, 100);
|
||||||
|
|
||||||
const searchRequest = {
|
const searchRequest = {
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
|
|
@ -162,6 +211,9 @@ const SearchPage = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setSearchProgress(100);
|
||||||
|
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
setTotalResults(response.data.total || results.length);
|
setTotalResults(response.data.total || results.length);
|
||||||
setQueryTime(response.data.query_time_ms || 0);
|
setQueryTime(response.data.query_time_ms || 0);
|
||||||
|
|
@ -171,7 +223,12 @@ const SearchPage = () => {
|
||||||
const tags = [...new Set(results.flatMap(doc => doc.tags))];
|
const tags = [...new Set(results.flatMap(doc => doc.tags))];
|
||||||
setAvailableTags(tags);
|
setAvailableTags(tags);
|
||||||
|
|
||||||
|
// Clear progress after a brief delay
|
||||||
|
setTimeout(() => setSearchProgress(0), 500);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setSearchProgress(0);
|
||||||
setError('Search failed. Please try again.');
|
setError('Search failed. Please try again.');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -180,9 +237,14 @@ const SearchPage = () => {
|
||||||
}, [useEnhancedSearch, includeSnippets, snippetLength, searchMode]);
|
}, [useEnhancedSearch, includeSnippets, snippetLength, searchMode]);
|
||||||
|
|
||||||
const debouncedSearch = useCallback(
|
const debouncedSearch = useCallback(
|
||||||
debounce((query, filters) => performSearch(query, filters), 500),
|
debounce((query, filters) => performSearch(query, filters), 300),
|
||||||
[performSearch]
|
[performSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const quickSuggestionsDebounced = useCallback(
|
||||||
|
debounce((query) => generateQuickSuggestions(query), 150),
|
||||||
|
[generateQuickSuggestions]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle URL search params
|
// Handle URL search params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -201,6 +263,7 @@ const SearchPage = () => {
|
||||||
hasOcr: hasOcr,
|
hasOcr: hasOcr,
|
||||||
};
|
};
|
||||||
debouncedSearch(searchQuery, filters);
|
debouncedSearch(searchQuery, filters);
|
||||||
|
quickSuggestionsDebounced(searchQuery);
|
||||||
|
|
||||||
// Update URL params when search query changes
|
// Update URL params when search query changes
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
|
|
@ -208,7 +271,7 @@ const SearchPage = () => {
|
||||||
} else {
|
} else {
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
}
|
}
|
||||||
}, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, setSearchParams]);
|
}, [searchQuery, selectedTags, selectedMimeTypes, dateRange, fileSizeRange, hasOcr, debouncedSearch, quickSuggestionsDebounced, setSearchParams]);
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
const handleClearFilters = () => {
|
||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
|
|
@ -331,6 +394,7 @@ const SearchPage = () => {
|
||||||
{/* Enhanced Search Bar */}
|
{/* Enhanced Search Bar */}
|
||||||
<Paper
|
<Paper
|
||||||
elevation={3}
|
elevation={3}
|
||||||
|
className="search-input-responsive"
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2,
|
||||||
mb: 3,
|
mb: 3,
|
||||||
|
|
@ -355,7 +419,13 @@ const SearchPage = () => {
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
{loading && <CircularProgress size={20} />}
|
{(loading || isTyping) && (
|
||||||
|
<CircularProgress
|
||||||
|
size={20}
|
||||||
|
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||||
|
value={searchProgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -371,6 +441,14 @@ const SearchPage = () => {
|
||||||
>
|
>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
color={showFilters ? 'primary' : 'default'}
|
||||||
|
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
|
||||||
|
>
|
||||||
|
<FilterIcon />
|
||||||
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
|
|
@ -394,15 +472,19 @@ const SearchPage = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading Progress Bar */}
|
{/* Enhanced Loading Progress Bar */}
|
||||||
{loading && (
|
{(loading || isTyping || searchProgress > 0) && (
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
|
variant={searchProgress > 0 ? "determinate" : "indeterminate"}
|
||||||
|
value={searchProgress}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
borderRadius: '0 0 4px 4px',
|
borderRadius: '0 0 4px 4px',
|
||||||
|
opacity: isTyping ? 0.5 : 1,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -443,26 +525,54 @@ const SearchPage = () => {
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Search Mode Selector */}
|
{/* Simplified Search Mode Selector */}
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={searchMode}
|
value={searchMode}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={(e, newMode) => newMode && setSearchMode(newMode)}
|
onChange={(e, newMode) => newMode && setSearchMode(newMode)}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<ToggleButton value="simple">Simple</ToggleButton>
|
<ToggleButton value="simple">Smart</ToggleButton>
|
||||||
<ToggleButton value="phrase">Phrase</ToggleButton>
|
<ToggleButton value="phrase">Exact phrase</ToggleButton>
|
||||||
<ToggleButton value="fuzzy">Fuzzy</ToggleButton>
|
<ToggleButton value="fuzzy">Similar words</ToggleButton>
|
||||||
<ToggleButton value="boolean">Boolean</ToggleButton>
|
<ToggleButton value="boolean">Advanced</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
</Box>
|
</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 && (
|
{suggestions.length > 0 && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
Suggestions:
|
Related searches:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
|
|
@ -488,47 +598,58 @@ const SearchPage = () => {
|
||||||
{/* Advanced Search Options */}
|
{/* Advanced Search Options */}
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<Box sx={{ mt: 3, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
<Box sx={{ mt: 3, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
<Grid container spacing={2}>
|
||||||
Search Options
|
<Grid item xs={12} md={8}>
|
||||||
</Typography>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
<Grid container spacing={2} alignItems="center">
|
Search Options
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
</Typography>
|
||||||
<FormControlLabel
|
<Grid container spacing={2} alignItems="center">
|
||||||
control={
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
<Switch
|
<FormControlLabel
|
||||||
checked={useEnhancedSearch}
|
control={
|
||||||
onChange={(e) => setUseEnhancedSearch(e.target.checked)}
|
<Switch
|
||||||
color="primary"
|
checked={useEnhancedSearch}
|
||||||
|
onChange={(e) => setUseEnhancedSearch(e.target.checked)}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enhanced Search"
|
||||||
/>
|
/>
|
||||||
}
|
</Grid>
|
||||||
label="Enhanced Search"
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
/>
|
<FormControlLabel
|
||||||
</Grid>
|
control={
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Switch
|
||||||
<FormControlLabel
|
checked={includeSnippets}
|
||||||
control={
|
onChange={(e) => setIncludeSnippets(e.target.checked)}
|
||||||
<Switch
|
color="primary"
|
||||||
checked={includeSnippets}
|
/>
|
||||||
onChange={(e) => setIncludeSnippets(e.target.checked)}
|
}
|
||||||
color="primary"
|
label="Show Snippets"
|
||||||
/>
|
/>
|
||||||
}
|
</Grid>
|
||||||
label="Show Snippets"
|
<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>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} md={4}>
|
||||||
<FormControl size="small" fullWidth>
|
<SearchGuidance
|
||||||
<InputLabel>Snippet Length</InputLabel>
|
compact
|
||||||
<Select
|
onExampleClick={setSearchQuery}
|
||||||
value={snippetLength}
|
sx={{ position: 'relative' }}
|
||||||
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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -537,9 +658,32 @@ const SearchPage = () => {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Filters Sidebar */}
|
{/* Mobile Filters Drawer */}
|
||||||
<Grid item xs={12} md={3}>
|
{showFilters && (
|
||||||
<Card sx={{ position: 'sticky', top: 20 }}>
|
<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>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
|
@ -750,11 +894,42 @@ const SearchPage = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
No results found
|
No results found for "{searchQuery}"
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
Try adjusting your search terms or filters
|
Try adjusting your search terms or filters
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -771,13 +946,46 @@ const SearchPage = () => {
|
||||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
Start searching your documents
|
Start searching your documents
|
||||||
</Typography>
|
</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
|
Use the enhanced search bar above to find documents by content, filename, or tags
|
||||||
</Typography>
|
</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">
|
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||||
<Chip label="Try: invoice" size="small" variant="outlined" />
|
<Chip
|
||||||
<Chip label="Try: contract" size="small" variant="outlined" />
|
label="Try: invoice"
|
||||||
<Chip label="Try: tag:important" size="small" variant="outlined" />
|
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>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
@ -794,15 +1002,11 @@ const SearchPage = () => {
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
className="search-result-card search-loading-fade"
|
||||||
sx={{
|
sx={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: viewMode === 'list' ? 'row' : 'column',
|
flexDirection: viewMode === 'list' ? 'row' : 'column',
|
||||||
transition: 'all 0.2s ease-in-out',
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'translateY(-2px)',
|
|
||||||
boxShadow: (theme) => theme.shadows[4],
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{viewMode === 'grid' && (
|
{viewMode === 'grid' && (
|
||||||
|
|
@ -821,7 +1025,7 @@ const SearchPage = () => {
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent className="search-card" sx={{ flexGrow: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
<Box sx={{ mr: 1, mt: 0.5 }}>
|
<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 }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
<Chip
|
<Chip
|
||||||
|
className="search-chip"
|
||||||
label={formatFileSize(doc.file_size)}
|
label={formatFileSize(doc.file_size)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
|
className="search-chip"
|
||||||
label={formatDate(doc.created_at)}
|
label={formatDate(doc.created_at)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
{doc.has_ocr_text && (
|
{doc.has_ocr_text && (
|
||||||
<Chip
|
<Chip
|
||||||
|
className="search-chip"
|
||||||
label="OCR"
|
label="OCR"
|
||||||
size="small"
|
size="small"
|
||||||
color="success"
|
color="success"
|
||||||
|
|
@ -871,6 +1078,7 @@ const SearchPage = () => {
|
||||||
{doc.tags.slice(0, 2).map((tag, index) => (
|
{doc.tags.slice(0, 2).map((tag, index) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={index}
|
key={index}
|
||||||
|
className="search-chip"
|
||||||
label={tag}
|
label={tag}
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -880,6 +1088,7 @@ const SearchPage = () => {
|
||||||
))}
|
))}
|
||||||
{doc.tags.length > 2 && (
|
{doc.tags.length > 2 && (
|
||||||
<Chip
|
<Chip
|
||||||
|
className="search-chip"
|
||||||
label={`+${doc.tags.length - 2}`}
|
label={`+${doc.tags.length - 2}`}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|
@ -928,6 +1137,7 @@ const SearchPage = () => {
|
||||||
{doc.search_rank && (
|
{doc.search_rank && (
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<Chip
|
<Chip
|
||||||
|
className="search-chip"
|
||||||
label={`Relevance: ${(doc.search_rank * 100).toFixed(1)}%`}
|
label={`Relevance: ${(doc.search_rank * 100).toFixed(1)}%`}
|
||||||
size="small"
|
size="small"
|
||||||
color="info"
|
color="info"
|
||||||
|
|
@ -940,6 +1150,7 @@ const SearchPage = () => {
|
||||||
|
|
||||||
<Tooltip title="View Details">
|
<Tooltip title="View Details">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
className="search-filter-button search-focusable"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||||
>
|
>
|
||||||
|
|
@ -948,6 +1159,7 @@ const SearchPage = () => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Download">
|
<Tooltip title="Download">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
className="search-filter-button search-focusable"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleDownload(doc)}
|
onClick={() => handleDownload(doc)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ mod ocr;
|
||||||
mod ocr_queue;
|
mod ocr_queue;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod seed;
|
mod seed;
|
||||||
|
mod swagger;
|
||||||
mod watcher;
|
mod watcher;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -54,6 +55,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
.nest("/api/search", routes::search::router())
|
.nest("/api/search", routes::search::router())
|
||||||
.nest("/api/settings", routes::settings::router())
|
.nest("/api/settings", routes::settings::router())
|
||||||
.nest("/api/users", routes::users::router())
|
.nest("/api/users", routes::users::router())
|
||||||
|
.merge(swagger::create_swagger_router())
|
||||||
.nest_service("/", ServeDir::new("/app/frontend"))
|
.nest_service("/", ServeDir::new("/app/frontend"))
|
||||||
.fallback(serve_spa)
|
.fallback(serve_spa)
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
@ -13,26 +14,26 @@ pub struct User {
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CreateUser {
|
pub struct CreateUser {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub user: UserResponse,
|
pub user: UserResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
@ -55,7 +56,7 @@ pub struct Document {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct DocumentResponse {
|
pub struct DocumentResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
|
|
@ -67,7 +68,7 @@ pub struct DocumentResponse {
|
||||||
pub has_ocr_text: bool,
|
pub has_ocr_text: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct SearchRequest {
|
pub struct SearchRequest {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
|
|
@ -79,7 +80,7 @@ pub struct SearchRequest {
|
||||||
pub search_mode: Option<SearchMode>,
|
pub search_mode: Option<SearchMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub enum SearchMode {
|
pub enum SearchMode {
|
||||||
#[serde(rename = "simple")]
|
#[serde(rename = "simple")]
|
||||||
Simple,
|
Simple,
|
||||||
|
|
@ -97,7 +98,7 @@ impl Default for SearchMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct SearchSnippet {
|
pub struct SearchSnippet {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub start_offset: i32,
|
pub start_offset: i32,
|
||||||
|
|
@ -105,13 +106,13 @@ pub struct SearchSnippet {
|
||||||
pub highlight_ranges: Vec<HighlightRange>,
|
pub highlight_ranges: Vec<HighlightRange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct HighlightRange {
|
pub struct HighlightRange {
|
||||||
pub start: i32,
|
pub start: i32,
|
||||||
pub end: i32,
|
pub end: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct EnhancedDocumentResponse {
|
pub struct EnhancedDocumentResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
|
|
@ -125,7 +126,7 @@ pub struct EnhancedDocumentResponse {
|
||||||
pub snippets: Vec<SearchSnippet>,
|
pub snippets: Vec<SearchSnippet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct SearchResponse {
|
pub struct SearchResponse {
|
||||||
pub documents: Vec<EnhancedDocumentResponse>,
|
pub documents: Vec<EnhancedDocumentResponse>,
|
||||||
pub total: i64,
|
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 struct UpdateUser {
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
|
@ -189,7 +190,7 @@ pub struct Settings {
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct SettingsResponse {
|
pub struct SettingsResponse {
|
||||||
pub ocr_language: String,
|
pub ocr_language: String,
|
||||||
pub concurrent_ocr_jobs: i32,
|
pub concurrent_ocr_jobs: i32,
|
||||||
|
|
@ -209,7 +210,7 @@ pub struct SettingsResponse {
|
||||||
pub enable_background_ocr: bool,
|
pub enable_background_ocr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateSettings {
|
pub struct UpdateSettings {
|
||||||
pub ocr_language: Option<String>,
|
pub ocr_language: Option<String>,
|
||||||
pub concurrent_ocr_jobs: Option<i32>,
|
pub concurrent_ocr_jobs: Option<i32>,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use axum::{
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use utoipa::path;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{create_jwt, AuthUser},
|
auth::{create_jwt, AuthUser},
|
||||||
|
|
@ -20,6 +21,16 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/me", get(me))
|
.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(
|
async fn register(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(user_data): Json<CreateUser>,
|
Json(user_data): Json<CreateUser>,
|
||||||
|
|
@ -33,6 +44,16 @@ async fn register(
|
||||||
Ok(Json(user.into()))
|
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(
|
async fn login(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(login_data): Json<LoginRequest>,
|
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> {
|
async fn me(auth_user: AuthUser) -> Json<UserResponse> {
|
||||||
Json(auth_user.user.into())
|
Json(auth_user.user.into())
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use utoipa::{path, ToSchema};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthUser,
|
auth::AuthUser,
|
||||||
|
|
@ -16,7 +17,7 @@ use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
struct PaginationQuery {
|
struct PaginationQuery {
|
||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
offset: Option<i64>,
|
offset: Option<i64>,
|
||||||
|
|
@ -29,6 +30,21 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/:id/download", get(download_document))
|
.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(
|
async fn upload_document(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -119,6 +135,22 @@ async fn upload_document(
|
||||||
Err(StatusCode::BAD_REQUEST)
|
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(
|
async fn list_documents(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -138,6 +170,22 @@ async fn list_documents(
|
||||||
Ok(Json(response))
|
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(
|
async fn download_document(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use axum::{
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use utoipa::path;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthUser,
|
auth::AuthUser,
|
||||||
|
|
@ -19,6 +20,21 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/enhanced", get(enhanced_search_documents))
|
.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(
|
async fn search_documents(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -51,6 +67,21 @@ async fn search_documents(
|
||||||
Ok(Json(response))
|
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(
|
async fn enhanced_search_documents(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::get,
|
routing::{get, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use utoipa::path;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthUser,
|
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