| import React, { useMemo, useEffect } from 'react'; |
| import { Spinner } from '@librechat/client'; |
| import { PermissionBits } from 'librechat-data-provider'; |
| import type t from 'librechat-data-provider'; |
| import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; |
| import { useAgentCategories, useLocalize } from '~/hooks'; |
| import { useInfiniteScroll } from '~/hooks/useInfiniteScroll'; |
| import { useHasData } from './SmartLoader'; |
| import ErrorDisplay from './ErrorDisplay'; |
| import AgentCard from './AgentCard'; |
|
|
| interface AgentGridProps { |
| category: string; |
| searchQuery: string; |
| onSelectAgent: (agent: t.Agent) => void; |
| scrollElementRef?: React.RefObject<HTMLElement>; |
| } |
|
|
| |
| |
| |
| const AgentGrid: React.FC<AgentGridProps> = ({ |
| category, |
| searchQuery, |
| onSelectAgent, |
| scrollElementRef, |
| }) => { |
| const localize = useLocalize(); |
|
|
| |
| const { categories } = useAgentCategories(); |
|
|
| |
| const queryParams = useMemo(() => { |
| const params: { |
| requiredPermission: number; |
| category?: string; |
| search?: string; |
| limit: number; |
| promoted?: 0 | 1; |
| } = { |
| requiredPermission: PermissionBits.VIEW, |
| limit: 6, |
| }; |
|
|
| |
| if (searchQuery) { |
| params.search = searchQuery; |
| |
| if (category !== 'all' && category !== 'promoted') { |
| params.category = category; |
| } |
| } else { |
| |
| if (category === 'promoted') { |
| params.promoted = 1; |
| } else if (category !== 'all') { |
| params.category = category; |
| } |
| |
| } |
|
|
| return params; |
| }, [category, searchQuery]); |
|
|
| |
| const { |
| data, |
| isLoading, |
| error, |
| isFetching, |
| fetchNextPage, |
| hasNextPage, |
| refetch, |
| isFetchingNextPage, |
| } = useMarketplaceAgentsInfiniteQuery(queryParams); |
|
|
| |
| const currentAgents = useMemo(() => { |
| if (!data?.pages) return []; |
| return data.pages.flatMap((page) => page.data || []); |
| }, [data?.pages]); |
|
|
| |
| const hasData = useHasData(data?.pages?.[0]); |
|
|
| |
| const { setScrollElement } = useInfiniteScroll({ |
| hasNextPage, |
| isLoading: isFetching || isFetchingNextPage, |
| fetchNextPage: () => { |
| if (hasNextPage && !isFetching) { |
| fetchNextPage(); |
| } |
| }, |
| threshold: 0.8, |
| throttleMs: 200, |
| }); |
|
|
| |
| useEffect(() => { |
| const scrollElement = scrollElementRef?.current; |
| if (scrollElement) { |
| setScrollElement(scrollElement); |
| } |
| }, [scrollElementRef, setScrollElement]); |
|
|
| |
| |
| |
| const getCategoryDisplayName = (categoryValue: string) => { |
| const categoryData = categories.find((cat) => cat.value === categoryValue); |
| if (categoryData) { |
| return categoryData.label; |
| } |
|
|
| |
| if (categoryValue === 'promoted') { |
| return localize('com_agents_top_picks'); |
| } |
| if (categoryValue === 'all') { |
| return 'All'; |
| } |
|
|
| |
| return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1); |
| }; |
|
|
| |
| const loadingSpinner = ( |
| <div className="flex justify-center py-12"> |
| <Spinner className="h-8 w-8 text-primary" /> |
| </div> |
| ); |
|
|
| |
| if (error) { |
| return ( |
| <ErrorDisplay |
| error={error || 'Unknown error occurred'} |
| onRetry={() => refetch()} |
| context={{ |
| searchQuery, |
| category, |
| }} |
| /> |
| ); |
| } |
|
|
| |
| const mainContent = ( |
| <div |
| className="space-y-6" |
| role="tabpanel" |
| id={`category-panel-${category}`} |
| aria-labelledby={`category-tab-${category}`} |
| aria-live="polite" |
| aria-busy={isLoading && !hasData} |
| > |
| {/* Handle empty results with enhanced accessibility */} |
| {(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? ( |
| <div |
| className="py-12 text-center text-text-secondary" |
| role="status" |
| aria-live="polite" |
| aria-label={ |
| searchQuery |
| ? localize('com_agents_search_empty_heading') |
| : localize('com_agents_empty_state_heading') |
| } |
| > |
| <h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3> |
| </div> |
| ) : ( |
| <> |
| {/* Announcement for screen readers */} |
| <div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true"> |
| {localize('com_agents_grid_announcement', { |
| count: currentAgents?.length || 0, |
| category: getCategoryDisplayName(category), |
| })} |
| </div> |
| |
| {/* Agent grid - 2 per row with proper semantic structure */} |
| {currentAgents && currentAgents.length > 0 && ( |
| <div |
| className="grid grid-cols-1 gap-6 md:grid-cols-2" |
| role="grid" |
| aria-label={localize('com_agents_grid_announcement', { |
| count: currentAgents.length, |
| category: getCategoryDisplayName(category), |
| })} |
| > |
| {currentAgents.map((agent: t.Agent, index: number) => ( |
| <div key={`${agent.id}-${index}`} role="gridcell"> |
| <AgentCard agent={agent} onClick={() => onSelectAgent(agent)} /> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| {/* Loading indicator when fetching more with accessibility */} |
| {isFetchingNextPage && ( |
| <div |
| className="flex justify-center py-8" |
| role="status" |
| aria-live="polite" |
| aria-label={localize('com_agents_loading')} |
| > |
| <Spinner className="h-6 w-6 text-primary" /> |
| <span className="sr-only">{localize('com_agents_loading')}</span> |
| </div> |
| )} |
| |
| {/* End of results indicator */} |
| {!hasNextPage && currentAgents && currentAgents.length > 0 && ( |
| <div className="mt-8 text-center"> |
| <p className="text-sm text-text-secondary"> |
| {localize('com_agents_no_more_results')} |
| </p> |
| </div> |
| )} |
| </> |
| )} |
| </div> |
| ); |
|
|
| if (isLoading || (isFetching && !isFetchingNextPage)) { |
| return loadingSpinner; |
| } |
| return mainContent; |
| }; |
|
|
| export default AgentGrid; |
|
|