feat(client): update the search functionality

This commit is contained in:
perfectra1n 2025-06-12 21:31:46 -07:00
parent 52d006d403
commit 0abc8f272a
13 changed files with 1026 additions and 140 deletions

View File

@ -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"]

View File

@ -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>
)}

View File

@ -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;

View File

@ -0,0 +1 @@
export { default } from './SearchGuidance';

View File

@ -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;
}

View File

@ -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)}
>

View File

@ -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())

View File

@ -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>,

View File

@ -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())
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

72
src/swagger.rs Normal file
View File

@ -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())
}