Skip to content

Commit 579d923

Browse files
feat: make dataset searchable
1 parent a0a0dd3 commit 579d923

18 files changed

+6576
-139
lines changed
Lines changed: 166 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
'use client'
22

3-
import { useRouter, useSearchParams } from 'next/navigation'
4-
import { useEffect, useState } from 'react'
3+
import { useEffect, useRef, useState } from 'react'
4+
import { TierList } from '@/components/TierList'
5+
import { TierListExample } from '@/components/TierListExample'
6+
import { SearchResults } from '@/components/SearchResults'
7+
import { resourceLoader } from '@/utils/resource-loader'
8+
import { SearchQuery, SearchResult } from '@/utils/search-core'
9+
import { DevResource } from '@/types/dev-resource'
10+
import { useSearchQuery } from '@/hooks/useSearchUrlSync'
11+
import { type ToolCategory, TOOL_CATEGORIES } from '@/utils/search-core'
512

613
/*
714
* Faceted Search URL Strategy:
@@ -15,85 +22,137 @@ import { useEffect, useState } from 'react'
1522
* - Extensible for future facets (tags, license, etc.)
1623
*/
1724

18-
const TOOL_CATEGORIES = [
19-
'mcp',
20-
'agents',
21-
'commands',
22-
'settings',
23-
'hooks',
24-
'templates'
25-
] as const
2625

27-
type ToolCategory = typeof TOOL_CATEGORIES[number]
26+
2827

2928
interface ToolsClientProps {
30-
initialCategory?: ToolCategory | null
29+
initialCategoryFilter: ToolCategory[]
30+
searchResults: SearchResult[]
31+
totalCount: number
3132
}
3233

33-
export default function ToolsClient({ initialCategory }: ToolsClientProps) {
34-
const router = useRouter()
35-
const searchParams = useSearchParams()
36-
const [currentCategory, setCurrentCategory] = useState<ToolCategory | null>(initialCategory || null)
37-
const [searchQuery, setSearchQuery] = useState('')
34+
export default function ToolsClient({
35+
initialCategoryFilter,
36+
searchResults,
37+
totalCount
38+
}: ToolsClientProps) {
39+
// Client-side state
40+
const [allResources, setAllResources] = useState<DevResource[] | null>(null)
41+
const [isLoading, setIsLoading] = useState(false)
42+
const [error, setError] = useState<string | null>(null)
43+
const [results, setResults] = useState<SearchResult[]>(searchResults)
3844
const [mounted, setMounted] = useState(false)
39-
const [hasInteracted, setHasInteracted] = useState(false)
45+
46+
// URL-synced search query state
47+
const [query, setQuery] = useSearchQuery({
48+
text: '',
49+
categoryFilter: initialCategoryFilter,
50+
limit: undefined
51+
})
52+
53+
// Debounced search execution (separate concern from URL sync)
54+
const debounceTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
55+
useEffect(() => {
56+
if (!resourceLoader.isSearchReady()) return
57+
58+
// Clear existing timeout
59+
if (debounceTimeoutRef.current) {
60+
clearTimeout(debounceTimeoutRef.current)
61+
}
62+
63+
// Debounce search execution by 300ms
64+
debounceTimeoutRef.current = setTimeout(() => {
65+
const newResults = resourceLoader.search(query)
66+
setResults(newResults)
67+
}, 300)
68+
69+
return () => {
70+
if (debounceTimeoutRef.current) {
71+
clearTimeout(debounceTimeoutRef.current)
72+
}
73+
}
74+
}, [query])
75+
76+
// Load resources lazily on client
77+
useEffect(() => {
78+
let timeoutId: NodeJS.Timeout
79+
80+
const loadResources = async () => {
81+
if (allResources || isLoading) return
82+
83+
try {
84+
setIsLoading(true)
85+
setError(null)
86+
const resources = await resourceLoader.loadResources()
87+
setAllResources(resources)
88+
} catch (err) {
89+
setError(err instanceof Error ? err.message : 'Failed to load resources')
90+
} finally {
91+
setIsLoading(false)
92+
}
93+
}
94+
95+
// Load immediately if user has searched, otherwise after 3 seconds
96+
if (query.text.trim() || query.categoryFilter.length !== initialCategoryFilter.length) {
97+
loadResources()
98+
} else {
99+
timeoutId = setTimeout(loadResources, 3000)
100+
}
101+
102+
return () => {
103+
if (timeoutId) clearTimeout(timeoutId)
104+
}
105+
}, [allResources, isLoading, query ])
106+
107+
// Perform immediate search when resources become available (no debouncing for resource loading)
108+
useEffect(() => {
109+
if (!resourceLoader.isSearchReady()) return
110+
const newResults = resourceLoader.search(query)
111+
setResults(newResults)
112+
}, [allResources]) // Only trigger when resources become available
40113

41114
const categoryDisplayNames: Record<ToolCategory, string> = {
42115
'mcp': 'MCP Servers',
43-
'agents': 'Agents',
44-
'commands': 'Commands',
45-
'settings': 'Settings',
46-
'hooks': 'Hooks',
47-
'templates': 'Templates'
116+
'agent': 'Agents',
117+
'command': 'Commands',
118+
'setting': 'Settings',
119+
'hook': 'Hooks',
120+
'template': 'Templates'
48121
}
49122

50-
// Initialize from URL params after mount
123+
// Simple mount tracker
51124
useEffect(() => {
52-
const queryCategory = searchParams.get('category') as ToolCategory | null
53-
const querySearch = searchParams.get('q') || ''
54-
55-
// If we have query params, we're in interactive mode
56-
if (queryCategory || querySearch) {
57-
setHasInteracted(true)
58-
setCurrentCategory(queryCategory || null)
59-
setSearchQuery(querySearch)
60-
} else {
61-
// Use initial category from path, not yet interactive
62-
setCurrentCategory(initialCategory || null)
63-
setSearchQuery('')
64-
}
65-
66125
setMounted(true)
67-
}, [initialCategory, searchParams])
126+
}, [])
68127

69-
// Handle category changes - switches to faceted search mode
70-
const handleCategoryChange = (category: ToolCategory | null) => {
71-
setCurrentCategory(category)
72-
setHasInteracted(true) // Mark as interactive - switch to query param URLs
128+
// Handle category changes
129+
const handleCategoryChange = (category: ToolCategory | null, isShiftClick = false) => {
130+
let newSelectedCategories: ToolCategory[]
131+
const currentCategories = query.categoryFilter
73132

74-
const newParams = new URLSearchParams()
75-
if (category) newParams.set('category', category)
76-
if (searchQuery) newParams.set('q', searchQuery)
77-
78-
const queryString = newParams.toString()
79-
const newUrl = queryString ? `/tools?${queryString}` : '/tools'
133+
if (category === null) {
134+
// "All" button clicked - clear all selections
135+
newSelectedCategories = []
136+
} else if (isShiftClick) {
137+
// Shift+click: toggle the category in the selection
138+
if (currentCategories.includes(category)) {
139+
newSelectedCategories = currentCategories.filter(c => c !== category)
140+
} else {
141+
newSelectedCategories = [...currentCategories, category]
142+
}
143+
} else {
144+
// Regular click: select only this category
145+
newSelectedCategories = [category]
146+
}
80147

81-
router.push(newUrl, { scroll: false })
148+
// Update query state - URL sync happens automatically
149+
setQuery(prev => ({ ...prev, categoryFilter: newSelectedCategories }))
82150
}
83151

84-
// Handle search changes - switches to faceted search mode
85-
const handleSearchChange = (query: string) => {
86-
setSearchQuery(query)
87-
setHasInteracted(true) // Mark as interactive - switch to query param URLs
88-
89-
const newParams = new URLSearchParams()
90-
if (currentCategory) newParams.set('category', currentCategory)
91-
if (query) newParams.set('q', query)
92-
93-
const queryString = newParams.toString()
94-
const newUrl = queryString ? `/tools?${queryString}` : '/tools'
95-
96-
router.push(newUrl, { scroll: false })
152+
// Handle search text changes
153+
const handleSearchChange = (text: string) => {
154+
// Update query state - URL sync happens automatically
155+
setQuery(prev => ({ ...prev, text }))
97156
}
98157

99158
// Don't render until mounted to avoid hydration mismatch
@@ -117,9 +176,9 @@ export default function ToolsClient({ initialCategory }: ToolsClientProps) {
117176
<div className="border-b border-gray-200 dark:border-gray-700">
118177
<nav className="-mb-px flex space-x-8">
119178
<button
120-
onClick={() => handleCategoryChange(null)}
179+
onClick={(e) => handleCategoryChange(null, e.shiftKey)}
121180
className={`py-2 px-1 border-b-2 font-medium text-sm ${
122-
!currentCategory
181+
query.categoryFilter.length === 0
123182
? 'border-blue-500 text-blue-600 dark:text-blue-400'
124183
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
125184
}`}
@@ -129,18 +188,32 @@ export default function ToolsClient({ initialCategory }: ToolsClientProps) {
129188
{TOOL_CATEGORIES.map((category) => (
130189
<button
131190
key={category}
132-
onClick={() => handleCategoryChange(category)}
133-
className={`py-2 px-1 border-b-2 font-medium text-sm ${
134-
currentCategory === category
191+
onClick={(e) => handleCategoryChange(category, e.shiftKey)}
192+
className={`py-2 px-1 border-b-2 font-medium text-sm relative ${
193+
(query.categoryFilter as ToolCategory[]).includes(category)
135194
? 'border-blue-500 text-blue-600 dark:text-blue-400'
136195
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
137196
}`}
197+
title={`Click to select only ${categoryDisplayNames[category]}, Shift+click to toggle selection`}
138198
>
139199
{categoryDisplayNames[category]}
200+
{(query.categoryFilter).includes(category) && query.categoryFilter.length > 1 && (
201+
<span className="ml-1 inline-flex items-center justify-center w-4 h-4 text-xs bg-blue-500 text-white rounded-full">
202+
203+
</span>
204+
)}
140205
</button>
141206
))}
142207
</nav>
143208
</div>
209+
{query.categoryFilter.length > 1 && (
210+
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
211+
Multiple filters active: {(query.categoryFilter).map(cat => categoryDisplayNames[cat]).join(', ')}
212+
<span className="ml-2 text-xs text-gray-500">
213+
(Shift+click tabs to toggle)
214+
</span>
215+
</div>
216+
)}
144217
</div>
145218

146219
{/* Search Bar */}
@@ -153,36 +226,37 @@ export default function ToolsClient({ initialCategory }: ToolsClientProps) {
153226
</div>
154227
<input
155228
type="text"
156-
value={searchQuery}
229+
value={query.text}
157230
onChange={(e) => handleSearchChange(e.target.value)}
158231
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white dark:bg-gray-800 dark:border-gray-600 placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
159-
placeholder={`Search ${currentCategory ? categoryDisplayNames[currentCategory].toLowerCase() : 'all tools'}...`}
232+
placeholder={`Search ${
233+
query.categoryFilter.length === 0
234+
? 'all tools'
235+
: query.categoryFilter.length === 1
236+
? categoryDisplayNames[(query.categoryFilter as ToolCategory[])[0]].toLowerCase()
237+
: `${query.categoryFilter.length} selected categories`
238+
}...`}
160239
/>
161240
</div>
162241
</div>
163242

164-
{/* Content Area */}
165-
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-8 text-center">
166-
<h2 className="text-xl font-semibold mb-4">
167-
{currentCategory ? `${categoryDisplayNames[currentCategory]} Coming Soon` : 'Tools Coming Soon'}
168-
</h2>
169-
<p className="text-gray-600 dark:text-gray-400 mb-4">
170-
{currentCategory
171-
? `We're building a comprehensive directory of ${categoryDisplayNames[currentCategory].toLowerCase()} for Claude Code.`
172-
: "We're building a comprehensive directory of tools and resources for Claude Code."
173-
}
174-
</p>
175-
{currentCategory === 'mcp' && (
176-
<p className="text-sm text-gray-500 dark:text-gray-500">
177-
This will showcase MCP servers similar to the design you provided, with search, filtering, and easy installation.
178-
</p>
179-
)}
180-
{searchQuery && (
181-
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2">
182-
Searching for: "{searchQuery}"
183-
</p>
184-
)}
185-
</div>
243+
{/* Search Results */}
244+
<SearchResults
245+
results={results}
246+
isLoading={isLoading}
247+
error={error}
248+
searchQuery={query.text}
249+
categoryFilter={query.categoryFilter}
250+
/>
251+
252+
{/* Show total count when not searching */}
253+
{!query.text && !isLoading && !error && (
254+
<div className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
255+
{resourceLoader.isSearchReady() ? resourceLoader.getTotalCount() : totalCount} total resources available
256+
</div>
257+
)}
258+
259+
<TierListExample />
186260
</div>
187261
)
188262
}

0 commit comments

Comments
 (0)