Skip to content

Commit b0bd0d7

Browse files
Merge pull request #608 from ResearchHub/search-improvements
Search Improvements
2 parents f1eb16c + d685cbe commit b0bd0d7

File tree

6 files changed

+195
-60
lines changed

6 files changed

+195
-60
lines changed

components/Search/Search.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ export function Search({
138138
onSelect={handleSelect}
139139
displayMode={displayMode}
140140
showSuggestionsOnFocus={showSuggestionsOnFocus}
141-
indices={indices}
142141
/>
143142
</div>
144143
);

components/Search/SearchModal.tsx

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client';
22

3-
import { useState, useEffect, useRef } from 'react';
4-
import { Search as SearchIcon, X, ArrowLeft, ArrowRight } from 'lucide-react';
3+
import { useState, useEffect, useRef, useMemo } from 'react';
4+
import { Search as SearchIcon, X, ArrowLeft, Filter, FilterX } from 'lucide-react';
55
import { SearchSuggestions } from './SearchSuggestions';
6+
import { SearchSuggestionFilters, AVAILABLE_INDICES } from './SearchSuggestionFilters';
67
import { useSearchSuggestions } from '@/hooks/useSearchSuggestions';
78
import { SearchSuggestion } from '@/types/search';
9+
import type { EntityType } from '@/types/search';
810
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
911
import { navigateToAuthorProfile } from '@/utils/navigation';
1012
import { BaseModal } from '@/components/ui/BaseModal';
@@ -19,12 +21,26 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
1921
const inputRef = useRef<HTMLInputElement>(null);
2022
const [query, setQuery] = useState('');
2123
const [isFocused, setIsFocused] = useState(true);
24+
const [selectedIndices, setSelectedIndices] = useState<EntityType[]>([]);
25+
const [isFiltersExpanded, setIsFiltersExpanded] = useState(false);
2226
const router = useRouter();
2327
const pathname = usePathname();
2428
const searchParams = useSearchParams();
2529
const hasPrefetchedRef = useRef(false);
2630
const navigatingToSearchRef = useRef(false);
2731

32+
const indicesToUse = useMemo(() => {
33+
if (selectedIndices.length === 0) {
34+
return AVAILABLE_INDICES;
35+
}
36+
37+
return selectedIndices;
38+
}, [selectedIndices]);
39+
40+
const hasActiveFilters = useMemo(() => {
41+
return selectedIndices.length > 0;
42+
}, [selectedIndices]);
43+
2844
const prefetchSearchRoute = () => {
2945
if (!hasPrefetchedRef.current) {
3046
try {
@@ -35,8 +51,9 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
3551
};
3652

3753
// Get search suggestions
38-
const { loading, suggestions } = useSearchSuggestions({
54+
const { loading, suggestions, hasLocalSuggestions, clearSearchHistory } = useSearchSuggestions({
3955
query,
56+
indices: indicesToUse,
4057
includeLocalSuggestions: true,
4158
});
4259

@@ -86,11 +103,13 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
86103
}
87104
};
88105

89-
// Reset query when modal closes (but preserve if navigating to search page)
106+
// Reset query and filters when modal closes (but preserve if navigating to search page)
90107
useEffect(() => {
91108
if (!isOpen) {
92109
if (!navigatingToSearchRef.current) {
93110
setQuery('');
111+
setSelectedIndices([]);
112+
setIsFiltersExpanded(false);
94113
}
95114
navigatingToSearchRef.current = false;
96115
}
@@ -142,49 +161,68 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
142161
>
143162
{/* Search Input */}
144163
<div className="border-b border-gray-200 p-4">
145-
<div className="relative">
146-
<SearchIcon className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
147-
<input
148-
ref={inputRef}
149-
type="text"
150-
placeholder="Search papers, topics, authors..."
151-
className="h-12 w-full rounded-lg border border-gray-200 bg-white pl-10 pr-8 md:!pr-24 text-base focus:border-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-400"
152-
value={query}
153-
onChange={(e) => setQuery(e.target.value)}
154-
onClick={(e) => {
155-
(e.target as HTMLInputElement).select();
156-
}}
157-
onFocus={() => {
158-
setIsFocused(true);
159-
prefetchSearchRoute();
160-
inputRef.current?.select();
161-
}}
162-
onKeyDown={(e) => {
163-
if (e.key === 'Enter' && e.shiftKey && query.trim()) {
164-
e.preventDefault();
165-
navigatingToSearchRef.current = true;
166-
router.push(`/search?debug&q=${encodeURIComponent(query.trim())}`);
167-
onClose();
168-
}
169-
}}
170-
/>
171-
{/* Keyboard shortcut hint - desktop only */}
172-
<div className="absolute right-3 top-1/2 -translate-y-1/2 hidden md:!flex items-center space-x-1 text-xs text-gray-400">
173-
<kbd className="px-1.5 py-0.5 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded">
174-
{shortcutKey}
175-
</kbd>
176-
<kbd className="px-1.5 py-0.5 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded">
177-
K
178-
</kbd>
164+
<div className="flex items-center gap-2">
165+
<div className="relative flex-1">
166+
<SearchIcon className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
167+
<input
168+
ref={inputRef}
169+
type="text"
170+
placeholder="Search papers, topics, authors..."
171+
className="h-12 w-full rounded-lg border border-gray-200 bg-white pl-10 pr-10 text-base focus:border-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-400"
172+
value={query}
173+
onChange={(e) => setQuery(e.target.value)}
174+
onClick={(e) => {
175+
(e.target as HTMLInputElement).select();
176+
}}
177+
onFocus={() => {
178+
setIsFocused(true);
179+
prefetchSearchRoute();
180+
inputRef.current?.select();
181+
}}
182+
onKeyDown={(e) => {
183+
if (e.key === 'Enter' && e.shiftKey && query.trim()) {
184+
e.preventDefault();
185+
navigatingToSearchRef.current = true;
186+
router.push(`/search?debug&q=${encodeURIComponent(query.trim())}`);
187+
onClose();
188+
}
189+
}}
190+
/>
191+
{query && (
192+
<button
193+
onClick={() => setQuery('')}
194+
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-gray-100"
195+
>
196+
<X className="h-4 w-4 text-gray-400" />
197+
</button>
198+
)}
199+
</div>
200+
{/* Settings/Filter Button */}
201+
<Button
202+
variant="outlined"
203+
size="icon"
204+
onClick={() => setIsFiltersExpanded(!isFiltersExpanded)}
205+
className="flex-shrink-0 h-12 w-12"
206+
aria-label="Toggle filters"
207+
>
208+
{hasActiveFilters ? (
209+
<FilterX className="h-4 w-4 text-gray-500" />
210+
) : (
211+
<Filter className="h-4 w-4 text-gray-500" />
212+
)}
213+
</Button>
214+
</div>
215+
{/* Filter Section */}
216+
<div
217+
className="grid transition-[grid-template-rows] duration-[500ms] ease-in-out"
218+
style={{ gridTemplateRows: isFiltersExpanded ? '1fr' : '0fr' }}
219+
>
220+
<div className="overflow-hidden">
221+
<SearchSuggestionFilters
222+
selectedIndices={selectedIndices}
223+
onIndicesChange={setSelectedIndices}
224+
/>
179225
</div>
180-
{query && (
181-
<button
182-
onClick={() => setQuery('')}
183-
className="absolute right-2 md:!right-20 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-gray-100"
184-
>
185-
<X className="h-4 w-4 text-gray-400" />
186-
</button>
187-
)}
188226
</div>
189227
</div>
190228

@@ -197,6 +235,10 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
197235
onSelect={handleSelect}
198236
displayMode="inline"
199237
showSuggestionsOnFocus={true}
238+
loading={loading}
239+
suggestions={suggestions}
240+
hasLocalSuggestions={hasLocalSuggestions}
241+
clearSearchHistory={clearSearchHistory}
200242
/>
201243
) : (
202244
<div className="p-8 text-center text-gray-500">
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Badge } from '@/components/ui/Badge';
2+
import { cn } from '@/utils/styles';
3+
import type { EntityType } from '@/types/search';
4+
5+
// Available entity types for filtering
6+
export const AVAILABLE_INDICES: EntityType[] = ['paper', 'post', 'hub', 'user'];
7+
8+
// Entity type labels for display
9+
export const ENTITY_LABELS: Record<EntityType, string> = {
10+
paper: 'Papers',
11+
user: 'Users',
12+
hub: 'Topics',
13+
post: 'Posts',
14+
author: 'Authors',
15+
};
16+
17+
interface SearchSuggestionFiltersProps {
18+
selectedIndices: EntityType[];
19+
onIndicesChange: (indices: EntityType[]) => void;
20+
}
21+
22+
export function SearchSuggestionFilters({
23+
selectedIndices,
24+
onIndicesChange,
25+
}: SearchSuggestionFiltersProps) {
26+
const noneSelected = selectedIndices.length === 0;
27+
28+
// Handle index selection toggle
29+
const handleIndexToggle = (index: EntityType) => {
30+
if (selectedIndices.includes(index)) {
31+
// Remove index - if removing the last one, this will make noneSelected true
32+
const newIndices = selectedIndices.filter((i) => i !== index);
33+
onIndicesChange(newIndices);
34+
} else {
35+
// Add index
36+
onIndicesChange([...selectedIndices, index]);
37+
}
38+
};
39+
40+
// Handle "All" selection - clear all selections (show "All" as selected)
41+
const handleSelectAll = () => {
42+
onIndicesChange([]);
43+
};
44+
45+
return (
46+
<div className="min-h-0 mt-2">
47+
{/* Index Selection Badges */}
48+
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide">
49+
{/* Show "All" badge - only selected when noneSelected is true */}
50+
<Badge
51+
variant={noneSelected ? 'primary' : 'default'}
52+
className={cn(
53+
'cursor-pointer rounded-full px-3 py-1 flex items-center gap-1.5 transition-colors flex-shrink-0',
54+
noneSelected ? 'hover:bg-primary-200' : 'hover:bg-gray-200'
55+
)}
56+
onClick={handleSelectAll}
57+
>
58+
<span>All</span>
59+
</Badge>
60+
61+
{/* Individual index badges - always visible, show selected state based on selectedIndices */}
62+
{AVAILABLE_INDICES.map((index) => {
63+
const isSelected = selectedIndices.includes(index);
64+
65+
return (
66+
<Badge
67+
key={index}
68+
variant={isSelected ? 'primary' : 'default'}
69+
className={cn(
70+
'cursor-pointer rounded-full px-3 py-1 flex items-center gap-1.5 transition-colors flex-shrink-0',
71+
isSelected ? 'hover:bg-primary-200' : 'hover:bg-gray-200'
72+
)}
73+
onClick={() => handleIndexToggle(index)}
74+
>
75+
<span>{ENTITY_LABELS[index]}</span>
76+
</Badge>
77+
);
78+
})}
79+
</div>
80+
</div>
81+
);
82+
}

components/Search/SearchSuggestions.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { useState } from 'react';
22
import { FileText, History, Search, X, ArrowRight, User, Hash, HelpCircle } from 'lucide-react';
33
import Icon from '@/components/ui/icons/Icon';
4-
import { useSearchSuggestions } from '@/hooks/useSearchSuggestions';
54
import { cn } from '@/utils/styles';
65
import { SearchSuggestion } from '@/types/search';
7-
import type { EntityType } from '@/types/search';
86
import { FollowTopicButton } from '@/components/ui/FollowTopicButton';
97
import { Avatar } from '@/components/ui/Avatar';
108
import { VerifiedBadge } from '@/components/ui/VerifiedBadge';
@@ -15,7 +13,10 @@ interface SearchSuggestionsProps {
1513
onSelect?: (suggestion: SearchSuggestion) => void;
1614
displayMode?: 'dropdown' | 'inline';
1715
showSuggestionsOnFocus?: boolean;
18-
indices?: EntityType[];
16+
loading?: boolean;
17+
suggestions?: SearchSuggestion[];
18+
hasLocalSuggestions?: boolean;
19+
clearSearchHistory?: () => void;
1920
}
2021

2122
// Maximum number of search results to display
@@ -35,14 +36,12 @@ export function SearchSuggestions({
3536
onSelect,
3637
displayMode = 'dropdown',
3738
showSuggestionsOnFocus = true,
38-
indices,
39+
loading = false,
40+
suggestions = [],
41+
hasLocalSuggestions = false,
42+
clearSearchHistory,
3943
}: SearchSuggestionsProps) {
4044
const [erroredSuggestions, setErroredSuggestions] = useState<Set<string>>(new Set());
41-
const { loading, suggestions, hasLocalSuggestions, clearSearchHistory } = useSearchSuggestions({
42-
query,
43-
indices,
44-
includeLocalSuggestions: true,
45-
});
4645

4746
// Only hide when explicitly not focused
4847
if (isFocused === false) {
@@ -84,6 +83,7 @@ export function SearchSuggestions({
8483
const isTopicSuggestion = suggestion.entityType === 'hub';
8584
const isPostSuggestion = suggestion.entityType === 'post';
8685
const isGrantPost = isPostSuggestion && suggestion?.documentType === 'GRANT';
86+
const isProposalRequest = isPostSuggestion && suggestion?.documentType === 'PREREGISTRATION';
8787

8888
// Safely access nested properties
8989
const safeGetAuthorsList = () => {
@@ -155,7 +155,7 @@ export function SearchSuggestions({
155155
}
156156
if (isTopicSuggestion)
157157
return <Hash className="h-5 w-8 text-gray-500 mt-0.5 flex-shrink-0" />;
158-
if (isGrantPost)
158+
if (isGrantPost || isProposalRequest)
159159
return (
160160
<div style={{ padding: '6px' }} className="mt-0.5 flex-shrink-0">
161161
<Icon name="fund" size={20} className="text-gray-500" />

hooks/useSearchSuggestions.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface UseSearchSuggestionsConfig {
1010
includeLocalSuggestions?: boolean;
1111
debounceMs?: number;
1212
minQueryLength?: number;
13+
externalSearch?: boolean;
1314
}
1415

1516
export function useSearchSuggestions({
@@ -18,6 +19,7 @@ export function useSearchSuggestions({
1819
includeLocalSuggestions = false,
1920
debounceMs = 300,
2021
minQueryLength = 2,
22+
externalSearch = false,
2123
}: UseSearchSuggestionsConfig) {
2224
const [loading, setLoading] = useState(false);
2325
const [isFocused, setIsFocused] = useState(false);
@@ -82,7 +84,12 @@ export function useSearchSuggestions({
8284

8385
const fetchSuggestions = async () => {
8486
try {
85-
const suggestions = await SearchService.getSuggestions(query, indices);
87+
const suggestions = await SearchService.getSuggestions(
88+
query,
89+
indices,
90+
undefined,
91+
externalSearch
92+
);
8693
if (mounted) {
8794
setApiSuggestions(suggestions);
8895
}
@@ -103,7 +110,7 @@ export function useSearchSuggestions({
103110
mounted = false;
104111
clearTimeout(debounceTimer);
105112
};
106-
}, [query, indices, minQueryLength, debounceMs]);
113+
}, [query, indices, minQueryLength, debounceMs, externalSearch]);
107114

108115
// Combine suggestions, prioritizing local results if enabled
109116
const suggestions = useMemo(() => {

services/search.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ export class SearchService {
8888
static async getSuggestions(
8989
query: string,
9090
indices?: EntityType | EntityType[],
91-
limit?: number
91+
limit?: number,
92+
externalSearch?: boolean
9293
): Promise<SearchSuggestion[]> {
9394
const params = new URLSearchParams({ q: query });
9495

@@ -102,6 +103,10 @@ export class SearchService {
102103
params.append('limit', limit.toString());
103104
}
104105

106+
if (externalSearch) {
107+
params.append('enable_openalex', 'true');
108+
}
109+
105110
const response = await ApiClient.get<any[]>(
106111
`${this.BASE_PATH}/search/suggest/?${params.toString()}`
107112
);

0 commit comments

Comments
 (0)