@@ -5,7 +5,7 @@ import { TierList } from '@/components/TierList'
55import { TierListExample } from '@/components/TierListExample'
66import { SearchResults } from '@/components/SearchResults'
77import { resourceLoader } from '@/utils/resource-loader'
8- import { SearchQuery , SearchResult } from '@/utils/search-core'
8+ import { SearchQuery , SearchResult , getAvailableCategories } from '@/utils/search-core'
99import { DevResource } from '@/types/dev-resource'
1010import { useSearchQuery } from '@/hooks/useSearchUrlSync'
1111import { type ToolKind , TOOL_KINDS } from '@/utils/search-core'
@@ -27,12 +27,14 @@ import { type ToolKind, TOOL_KINDS } from '@/utils/search-core'
2727
2828interface ToolsClientProps {
2929 initialKindFilter : ToolKind [ ]
30+ availableCategories : string [ ]
3031 searchResults : SearchResult [ ]
3132 totalCount : number
3233}
3334
3435export default function ToolsClient ( {
3536 initialKindFilter,
37+ availableCategories,
3638 searchResults,
3739 totalCount
3840} : ToolsClientProps ) {
@@ -42,11 +44,13 @@ export default function ToolsClient({
4244 const [ error , setError ] = useState < string | null > ( null )
4345 const [ results , setResults ] = useState < SearchResult [ ] > ( searchResults )
4446 const [ mounted , setMounted ] = useState ( false )
47+ const [ currentAvailableCategories , setCurrentAvailableCategories ] = useState < string [ ] > ( availableCategories )
4548
4649 // URL-synced search query state
4750 const [ query , setQuery ] = useSearchQuery ( {
4851 text : '' ,
4952 kindFilter : initialKindFilter ,
53+ categoryFilter : [ ] ,
5054 limit : undefined
5155 } )
5256
@@ -93,7 +97,7 @@ export default function ToolsClient({
9397 }
9498
9599 // Load immediately if user has searched, otherwise after 3 seconds
96- if ( query . text . trim ( ) || query . kindFilter . length !== initialKindFilter . length ) {
100+ if ( query . text . trim ( ) || query . kindFilter . length !== initialKindFilter . length || query . categoryFilter . length > 0 ) {
97101 loadResources ( )
98102 } else {
99103 timeoutId = setTimeout ( loadResources , 3000 )
@@ -111,6 +115,29 @@ export default function ToolsClient({
111115 setResults ( newResults )
112116 } , [ allResources ] ) // Only trigger when resources become available
113117
118+ // Update available categories when kind filter or resources change
119+ useEffect ( ( ) => {
120+ if ( ! allResources ) {
121+ // Use server-provided categories when client resources aren't loaded yet
122+ setCurrentAvailableCategories ( availableCategories )
123+ return
124+ }
125+
126+ // Dynamically calculate available categories based on current kind filter
127+ const newAvailableCategories = getAvailableCategories ( allResources , query . kindFilter )
128+ setCurrentAvailableCategories ( newAvailableCategories )
129+
130+ // Clear category filter if selected categories are no longer available
131+ if ( query . categoryFilter . length > 0 ) {
132+ const validCategories = query . categoryFilter . filter ( cat =>
133+ newAvailableCategories . includes ( cat )
134+ )
135+ if ( validCategories . length !== query . categoryFilter . length ) {
136+ setQuery ( prev => ( { ...prev , categoryFilter : validCategories } ) )
137+ }
138+ }
139+ } , [ query . kindFilter , allResources , availableCategories ] )
140+
114141 const kindDisplayNames : Record < ToolKind , string > = {
115142 'mcp' : 'MCP Servers' ,
116143 'agent' : 'Agents' ,
@@ -155,6 +182,30 @@ export default function ToolsClient({
155182 setQuery ( prev => ( { ...prev , text } ) )
156183 }
157184
185+ // Handle category changes
186+ const handleCategoryChange = ( category : string | null , isShiftClick = false ) => {
187+ let newSelectedCategories : string [ ]
188+ const currentCategories = query . categoryFilter
189+
190+ if ( category === null ) {
191+ // "All" button clicked - clear all selections
192+ newSelectedCategories = [ ]
193+ } else if ( isShiftClick ) {
194+ // Shift+click: toggle the category in the selection
195+ if ( currentCategories . includes ( category ) ) {
196+ newSelectedCategories = currentCategories . filter ( c => c !== category )
197+ } else {
198+ newSelectedCategories = [ ...currentCategories , category ]
199+ }
200+ } else {
201+ // Regular click: select only this category
202+ newSelectedCategories = [ category ]
203+ }
204+
205+ // Update query state - URL sync happens automatically
206+ setQuery ( prev => ( { ...prev , categoryFilter : newSelectedCategories } ) )
207+ }
208+
158209 // Don't render until mounted to avoid hydration mismatch
159210 if ( ! mounted ) {
160211 return < div > Loading...</ div >
@@ -216,6 +267,56 @@ export default function ToolsClient({
216267 ) }
217268 </ div >
218269
270+ { /* Category Filter - Only show if categories are available */ }
271+ { currentAvailableCategories . length > 1 && (
272+ < div className = "mb-6" >
273+ < div className = "border-b border-gray-200 dark:border-gray-700" >
274+ < div className = "flex items-center gap-2 mb-2" >
275+ < span className = "text-sm font-medium text-gray-700 dark:text-gray-300" > Categories:</ span >
276+ </ div >
277+ < nav className = "-mb-px flex flex-wrap gap-x-6 gap-y-2" >
278+ < button
279+ onClick = { ( e ) => handleCategoryChange ( null , e . shiftKey ) }
280+ className = { `py-2 px-1 border-b-2 font-medium text-sm ${
281+ query . categoryFilter . length === 0
282+ ? 'border-green-500 text-green-600 dark:text-green-400'
283+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
284+ } `}
285+ >
286+ All Categories
287+ </ button >
288+ { currentAvailableCategories . map ( ( category ) => (
289+ < button
290+ key = { category }
291+ onClick = { ( e ) => handleCategoryChange ( category , e . shiftKey ) }
292+ className = { `py-2 px-1 border-b-2 font-medium text-sm relative ${
293+ query . categoryFilter . includes ( category )
294+ ? 'border-green-500 text-green-600 dark:text-green-400'
295+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
296+ } `}
297+ title = { `Click to select only ${ category } , Shift+click to toggle selection` }
298+ >
299+ { category }
300+ { query . categoryFilter . includes ( category ) && query . categoryFilter . length > 1 && (
301+ < span className = "ml-1 inline-flex items-center justify-center w-4 h-4 text-xs bg-green-500 text-white rounded-full" >
302+ ✓
303+ </ span >
304+ ) }
305+ </ button >
306+ ) ) }
307+ </ nav >
308+ </ div >
309+ { query . categoryFilter . length > 1 && (
310+ < div className = "mt-2 text-sm text-gray-600 dark:text-gray-400" >
311+ Multiple categories active: { query . categoryFilter . join ( ', ' ) }
312+ < span className = "ml-2 text-xs text-gray-500" >
313+ (Shift+click to toggle)
314+ </ span >
315+ </ div >
316+ ) }
317+ </ div >
318+ ) }
319+
219320 { /* Search Bar */ }
220321 < div className = "mb-8" >
221322 < div className = "relative" >
@@ -247,6 +348,7 @@ export default function ToolsClient({
247348 error = { error }
248349 searchQuery = { query . text }
249350 kindFilter = { query . kindFilter }
351+ categoryFilter = { query . categoryFilter }
250352 />
251353
252354 { /* Show total count when not searching */ }
0 commit comments