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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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