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
2928interface 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