Skip to content

Commit 9adbe65

Browse files
feat: add category filters
1 parent 306dc0a commit 9adbe65

File tree

5 files changed

+203
-25
lines changed

5 files changed

+203
-25
lines changed

app/tools/[[...kind]]/ToolsClient.tsx

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { TierList } from '@/components/TierList'
55
import { TierListExample } from '@/components/TierListExample'
66
import { SearchResults } from '@/components/SearchResults'
77
import { resourceLoader } from '@/utils/resource-loader'
8-
import { SearchQuery, SearchResult } from '@/utils/search-core'
8+
import { SearchQuery, SearchResult, getAvailableCategories } from '@/utils/search-core'
99
import { DevResource } from '@/types/dev-resource'
1010
import { useSearchQuery } from '@/hooks/useSearchUrlSync'
1111
import { type ToolKind, TOOL_KINDS } from '@/utils/search-core'
@@ -27,12 +27,14 @@ import { type ToolKind, TOOL_KINDS } from '@/utils/search-core'
2727

2828
interface ToolsClientProps {
2929
initialKindFilter: ToolKind[]
30+
availableCategories: string[]
3031
searchResults: SearchResult[]
3132
totalCount: number
3233
}
3334

3435
export 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 */}

app/tools/[[...kind]]/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createFuseInstance,
77
SearchResult,
88
performSearch,
9+
getAvailableCategories,
910
type ToolKind,
1011
} from "@/utils/search-core";
1112

@@ -101,16 +102,22 @@ export default async function ToolsPage({ params }: ToolsPageProps) {
101102
const searchResults = performSearch(data, fuse, {
102103
text: "",
103104
kindFilter,
105+
categoryFilter: [],
104106
limit: 20,
105107
});
106108

109+
// Get available categories for the current kind filter
110+
const availableCategories = getAvailableCategories(data, kindFilter);
111+
107112
console.log("got these params", resolvedParams);
108113
console.log("loaded", data.length, "tools, showing", searchResults.length, "results");
114+
console.log("available categories for", kindFilter, ":", availableCategories);
109115

110116
return (
111117
<Suspense fallback={<div>Loading...</div>}>
112118
<ToolsClient
113119
initialKindFilter={kindFilter}
120+
availableCategories={availableCategories}
114121
searchResults={searchResults}
115122
totalCount={data.length}
116123
/>

components/SearchResults.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface SearchResultsProps {
1515
error: string | null
1616
searchQuery: string
1717
kindFilter: ToolKind[]
18+
categoryFilter: string[]
1819
}
1920

2021
export function SearchResults({
@@ -23,6 +24,7 @@ export function SearchResults({
2324
error,
2425
searchQuery,
2526
kindFilter,
27+
categoryFilter,
2628
}: SearchResultsProps) {
2729
const pageSize = useResponsivePageSize()
2830
const {
@@ -42,6 +44,7 @@ export function SearchResults({
4244
error,
4345
searchQuery,
4446
kindFilter,
47+
categoryFilter,
4548
searchQueryTrimmed: searchQuery.trim(),
4649
currentPage,
4750
totalPages,
@@ -83,17 +86,28 @@ export function SearchResults({
8386

8487
if (results.length === 0) {
8588
const hasKindFilter = kindFilter.length > 0
89+
const hasCategoryFilter = categoryFilter.length > 0
8690
const kindText = hasKindFilter
8791
? kindFilter.length === 1
8892
? kindFilter[0]
8993
: kindFilter.join(', ')
9094
: ''
95+
const categoryText = hasCategoryFilter
96+
? categoryFilter.length === 1
97+
? categoryFilter[0]
98+
: categoryFilter.join(', ')
99+
: ''
100+
101+
const filterText = [
102+
hasKindFilter ? `kinds: ${kindText}` : '',
103+
hasCategoryFilter ? `categories: ${categoryText}` : ''
104+
].filter(Boolean).join(', ')
91105

92106
return (
93107
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-8 text-center">
94108
<h2 className="text-xl font-semibold mb-4">No results found</h2>
95109
<p className="text-gray-600 dark:text-gray-400 mb-4">
96-
No resources found for "{searchQuery}"{hasKindFilter ? ` in ${kindText}` : ''}.
110+
No resources found for "{searchQuery}"{filterText ? ` in ${filterText}` : ''}.
97111
</p>
98112
<p className="text-sm text-gray-500 dark:text-gray-500">
99113
Try adjusting your search terms or browse all resources.
@@ -103,11 +117,22 @@ export function SearchResults({
103117
}
104118

105119
const hasKindFilter = kindFilter.length > 0
120+
const hasCategoryFilter = categoryFilter.length > 0
106121
const kindText = hasKindFilter
107122
? kindFilter.length === 1
108123
? kindFilter[0]
109124
: kindFilter.join(', ')
110125
: ''
126+
const categoryText = hasCategoryFilter
127+
? categoryFilter.length === 1
128+
? categoryFilter[0]
129+
: categoryFilter.join(', ')
130+
: ''
131+
132+
const filterText = [
133+
hasKindFilter ? kindText : '',
134+
hasCategoryFilter ? categoryText : ''
135+
].filter(Boolean).join(' > ')
111136

112137
return (
113138
<div className="space-y-4">
@@ -117,12 +142,12 @@ export function SearchResults({
117142
// When there's a search query, show "X results for 'query'"
118143
<>
119144
{results.length} result{results.length !== 1 ? 's' : ''} for "{searchQuery}"
120-
{hasKindFilter && ` in ${kindText}`}
145+
{filterText && ` in ${filterText}`}
121146
</>
122147
) : (
123-
// When there's no search query but category filters, show "X CategoryName Resources"
148+
// When there's no search query but filters, show "X FilterName Resources"
124149
<>
125-
{results.length} {kindText} Resource{results.length !== 1 ? 's' : ''}
150+
{results.length} {filterText || 'All'} Resource{results.length !== 1 ? 's' : ''}
126151
</>
127152
)}
128153
</h2>

hooks/useSearchUrlSync.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export function searchQueryToUrl(query: SearchQuery, basePath = '/tools'): strin
3030
params.set('kind', query.kindFilter.join(','))
3131
}
3232

33+
if (query.categoryFilter.length > 0) {
34+
params.set('category', query.categoryFilter.join(','))
35+
}
36+
3337
if (query.text.trim()) {
3438
params.set('q', query.text.trim())
3539
}
@@ -59,9 +63,13 @@ export function urlToSearchQuery(url: string): SearchQuery {
5963
}
6064
}
6165

66+
// Parse category filter - no validation needed since categories are dynamic
67+
const categoryFilter: string[] = params.get('category')?.split(',').filter(Boolean) || []
68+
6269
return {
6370
text: params.get('q') || '',
6471
kindFilter,
72+
categoryFilter,
6573
limit: undefined
6674
}
6775
}
@@ -89,7 +97,7 @@ export function useSearchQuery(initialQuery: SearchQuery): [SearchQuery, (query:
8997

9098
// Only become URL controlled if URL has meaningful search params
9199
// This preserves SEO-friendly URLs like /tools/agents
92-
if (urlQuery.text || urlQuery.kindFilter.length > 0) {
100+
if (urlQuery.text || urlQuery.kindFilter.length > 0 || urlQuery.categoryFilter.length > 0) {
93101
setQueryState(urlQuery)
94102
setIsUrlControlled(true)
95103
}

0 commit comments

Comments
 (0)