refactor: update marketplace components to use unified terminology and improve search functionality

This commit is contained in:
yessenia 2026-02-10 16:38:29 +08:00
parent 984992d0fd
commit b241122cf7
45 changed files with 1971 additions and 966 deletions

View File

@ -11,7 +11,7 @@ const Icon = (
ref, ref,
...props ...props
}: React.SVGProps<SVGSVGElement> & { }: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>> ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
}, },
) => <IconBase {...props} ref={ref} data={data as IconData} /> ) => <IconBase {...props} ref={ref} data={data as IconData} />

View File

@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
{ {
!isAllPluginsLoading && !collapse && ( !isAllPluginsLoading && !collapse && (
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={allPlugins} plugins={allPlugins}
showInstallButton showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2" cardContainerClassName="grid grid-cols-2 gap-2"

View File

@ -63,8 +63,8 @@ const InstallFromMarketplace = ({
{ {
!isAllPluginsLoading && !collapse && ( !isAllPluginsLoading && !collapse && (
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={allPlugins} plugins={allPlugins}
showInstallButton showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2" cardContainerClassName="grid grid-cols-2 gap-2"

View File

@ -2,7 +2,8 @@ import type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs' import { useQueryState } from 'nuqs'
import { useCallback } from 'react' import { useCallback } from 'react'
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import { DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import type { SearchTab } from './search-params'
import { marketplaceSearchParamsParsers } from './search-params' import { marketplaceSearchParamsParsers } from './search-params'
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT) const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
@ -16,16 +17,26 @@ export function useSetMarketplaceSort() {
return useSetAtom(marketplaceSortAtom) return useSetAtom(marketplaceSortAtom)
} }
export function useSearchPluginText() { export function useSearchText() {
return useQueryState('q', marketplaceSearchParamsParsers.q) return useQueryState('q', marketplaceSearchParamsParsers.q)
} }
export function useActivePluginType() { export function useActivePluginCategory() {
return useQueryState('category', marketplaceSearchParamsParsers.category) const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
return [getValidatedPluginCategory(category), setCategory] as const
}
export function useActiveTemplateCategory() {
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
return [getValidatedTemplateCategory(category), setCategory] as const
} }
export function useFilterPluginTags() { export function useFilterPluginTags() {
return useQueryState('tags', marketplaceSearchParamsParsers.tags) return useQueryState('tags', marketplaceSearchParamsParsers.tags)
} }
export function useSearchTab() {
return useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
}
/** /**
* Not all categories have collections, so we need to * Not all categories have collections, so we need to
* force the search mode for those categories. * force the search mode for those categories.
@ -33,23 +44,24 @@ export function useFilterPluginTags() {
export const searchModeAtom = atom<true | null>(null) export const searchModeAtom = atom<true | null>(null)
export function useMarketplaceSearchMode() { export function useMarketplaceSearchMode() {
const [searchPluginText] = useSearchPluginText() // const [searchText] = useSearchText()
const [filterPluginTags] = useFilterPluginTags() const [searchTab] = useSearchTab()
const [activePluginType] = useActivePluginType() // const [filterPluginTags] = useFilterPluginTags()
const [activePluginCategory] = useActivePluginCategory()
const searchMode = useAtomValue(searchModeAtom) const searchMode = useAtomValue(searchModeAtom)
const isSearchMode = !!searchPluginText const isSearchMode = searchTab
|| filterPluginTags.length > 0 || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
return isSearchMode return isSearchMode
} }
export function useMarketplaceMoreClick() { export function useMarketplaceMoreClick() {
const [,setQ] = useSearchPluginText() const [, setQ] = useSearchText()
const [, setSearchTab] = useSearchTab()
const setSort = useSetAtom(marketplaceSortAtom) const setSort = useSetAtom(marketplaceSortAtom)
const setSearchMode = useSetAtom(searchModeAtom) const setSearchMode = useSetAtom(searchModeAtom)
return useCallback((searchParams?: SearchParamsFromCollection) => { return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
if (!searchParams) if (!searchParams)
return return
setQ(searchParams?.query || '') setQ(searchParams?.query || '')
@ -58,5 +70,7 @@ export function useMarketplaceMoreClick() {
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
}) })
setSearchMode(true) setSearchMode(true)
}, [setQ, setSort, setSearchMode]) if (searchTab)
setSearchTab(searchTab)
}, [setQ, setSearchTab, setSort, setSearchMode])
} }

View File

@ -0,0 +1,66 @@
'use client'
import { cn } from '@/utils/classnames'
export type CategoryOption = {
value: string
text: string
icon: React.ReactNode | null
}
type CategorySwitchProps = {
className?: string
variant?: 'default' | 'hero'
options: CategoryOption[]
activeValue: string
onChange: (value: string) => void
}
const CategorySwitch = ({
className,
variant = 'default',
options,
activeValue,
onChange,
}: CategorySwitchProps) => {
const isHeroVariant = variant === 'hero'
const getItemClassName = (isActive: boolean) => {
if (isHeroVariant) {
return cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
isActive
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
: 'hover:bg-state-base-hover',
)
}
return cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
)
}
return (
<div className={cn(
'flex shrink-0 items-center space-x-2',
!isHeroVariant && 'justify-center bg-background-body py-3',
className,
)}
>
{
options.map(option => (
<div
key={option.value}
className={getItemClassName(activeValue === option.value)}
onClick={() => onChange(option.value)}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}
export default CategorySwitch

View File

@ -7,8 +7,10 @@ export const DEFAULT_SORT = {
export const SCROLL_BOTTOM_THRESHOLD = 100 export const SCROLL_BOTTOM_THRESHOLD = 100
export const CATEGORY_ALL = 'all'
export const PLUGIN_TYPE_SEARCH_MAP = { export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all', all: CATEGORY_ALL,
model: PluginCategoryEnum.model, model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool, tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent, agent: PluginCategoryEnum.agent,
@ -28,3 +30,27 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
PLUGIN_TYPE_SEARCH_MAP.tool, PLUGIN_TYPE_SEARCH_MAP.tool,
], ],
) )
export const TEMPLATE_CATEGORY_MAP = {
all: CATEGORY_ALL,
marketing: 'marketing',
sales: 'sales',
support: 'support',
operations: 'operations',
it: 'it',
knowledge: 'knowledge',
design: 'design',
} as const
export type ActiveTemplateCategory = typeof TEMPLATE_CATEGORY_MAP[keyof typeof TEMPLATE_CATEGORY_MAP]
export function getValidatedPluginCategory(category: string): ActivePluginType {
const key = (category in PLUGIN_TYPE_SEARCH_MAP ? category : CATEGORY_ALL) as keyof typeof PLUGIN_TYPE_SEARCH_MAP
return PLUGIN_TYPE_SEARCH_MAP[key]
}
export function getValidatedTemplateCategory(category: string): ActiveTemplateCategory {
const key = (category in TEMPLATE_CATEGORY_MAP ? category : CATEGORY_ALL) as keyof typeof TEMPLATE_CATEGORY_MAP
return TEMPLATE_CATEGORY_MAP[key]
}

View File

@ -7,7 +7,8 @@ import { useEffect, useLayoutEffect, useRef } from 'react'
import marketPlaceBg from '@/public/marketplace/hero-bg.jpg' import marketPlaceBg from '@/public/marketplace/hero-bg.jpg'
import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg' import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import PluginTypeSwitch from '../plugin-type-switch' import PluginCategorySwitch from '../plugin-category-switch'
import TemplateCategorySwitch from '../template-category-switch'
import { useMarketplaceData } from '../state' import { useMarketplaceData } from '../state'
type DescriptionProps = { type DescriptionProps = {
@ -167,9 +168,15 @@ export const Description = ({
</h2> </h2>
</motion.div> </motion.div>
{/* Plugin type switch tabs - always visible */} {/* Category switch tabs - Plugin or Template based on creationType */}
<motion.div style={{ marginTop: tabsMarginTop }}> <motion.div style={{ marginTop: tabsMarginTop }}>
<PluginTypeSwitch variant="hero" /> {isTemplatesView
? (
<TemplateCategorySwitch variant="hero" />
)
: (
<PluginCategorySwitch variant="hero" />
)}
</motion.div> </motion.div>
</div> </div>
</motion.div> </motion.div>

View File

@ -3,7 +3,7 @@ import type {
} from '../types' } from '../types'
import type { import type {
CollectionsAndPluginsSearchParams, CollectionsAndPluginsSearchParams,
MarketplaceCollection, PluginCollection,
PluginsSearchParams, PluginsSearchParams,
} from './types' } from './types'
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
@ -31,8 +31,8 @@ import {
*/ */
export const useMarketplaceCollectionsAndPlugins = () => { export const useMarketplaceCollectionsAndPlugins = () => {
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>() const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>() const [pluginCollectionsOverride, setPluginCollections] = useState<PluginCollection[]>()
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>() const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const { const {
data, data,
@ -54,10 +54,10 @@ export const useMarketplaceCollectionsAndPlugins = () => {
const isLoading = !!queryParams && (isFetching || isPending) const isLoading = !!queryParams && (isFetching || isPending)
return { return {
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections, pluginCollections: pluginCollectionsOverride ?? data?.marketplaceCollections,
setMarketplaceCollections, setPluginCollections,
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, pluginCollectionPluginsMap: pluginCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
setMarketplaceCollectionPluginsMap, setPluginCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins, queryMarketplaceCollectionsAndPlugins,
isLoading, isLoading,
isSuccess, isSuccess,

View File

@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createLoader } from 'nuqs/server' import { createLoader } from 'nuqs/server'
import { getQueryClientServer } from '@/context/query-client-server' import { getQueryClientServer } from '@/context/query-client-server'
import { marketplaceQuery } from '@/service/client' import { marketplaceQuery } from '@/service/client'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import { getValidatedPluginCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceSearchParamsParsers } from './search-params' import { marketplaceSearchParamsParsers } from './search-params'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceTemplateCollectionsAndTemplates } from './utils'
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js // The server side logic should move to marketplace's codebase so that we can get rid of Next.js
@ -15,16 +15,26 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) {
} }
const loadSearchParams = createLoader(marketplaceSearchParamsParsers) const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params = await loadSearchParams(searchParams) const params = await loadSearchParams(searchParams)
const queryClient = getQueryClientServer()
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { if (params.creationType === 'templates') {
await queryClient.prefetchQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
})
return dehydrate(queryClient)
}
const pluginCategory = getValidatedPluginCategory(params.category)
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)) {
return return
} }
const queryClient = getQueryClientServer() const collectionsParams = getCollectionsParams(pluginCategory)
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }), queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
}) })
return dehydrate(queryClient) return dehydrate(queryClient)
} }

View File

@ -610,8 +610,8 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
expect(result.current.isLoading).toBe(false) expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false) expect(result.current.isSuccess).toBe(false)
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
expect(result.current.setMarketplaceCollections).toBeDefined() expect(result.current.setPluginCollections).toBeDefined()
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() expect(result.current.setPluginCollectionPluginsMap).toBeDefined()
}) })
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
@ -621,34 +621,34 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
}) })
it('should provide setMarketplaceCollections function', async () => { it('should provide setPluginCollections function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollections).toBe('function') expect(typeof result.current.setPluginCollections).toBe('function')
}) })
it('should provide setMarketplaceCollectionPluginsMap function', async () => { it('should provide setPluginCollectionPluginsMap function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function')
}) })
it('should return marketplaceCollections from data or override', async () => { it('should return pluginCollections from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
// Initial state // Initial state
expect(result.current.marketplaceCollections).toBeUndefined() expect(result.current.pluginCollections).toBeUndefined()
}) })
it('should return marketplaceCollectionPluginsMap from data or override', async () => { it('should return pluginCollectionPluginsMap from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
// Initial state // Initial state
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() expect(result.current.pluginCollectionPluginsMap).toBeUndefined()
}) })
}) })

View File

@ -2,7 +2,7 @@ import type { SearchParams } from 'nuqs'
import { TanstackQueryInitializer } from '@/context/query-client' import { TanstackQueryInitializer } from '@/context/query-client'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { HydrateQueryClient } from './hydration-server' import { HydrateQueryClient } from './hydration-server'
import ListWrapper from './list/list-wrapper' import MarketplaceContent from './marketplace-content'
import MarketplaceHeader from './marketplace-header' import MarketplaceHeader from './marketplace-header'
type MarketplaceProps = { type MarketplaceProps = {
@ -28,7 +28,7 @@ const Marketplace = async ({
<TanstackQueryInitializer> <TanstackQueryInitializer>
<HydrateQueryClient searchParams={searchParams}> <HydrateQueryClient searchParams={searchParams}>
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} /> <MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
<ListWrapper <MarketplaceContent
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
/> />
</HydrateQueryClient> </HydrateQueryClient>

View File

@ -0,0 +1,213 @@
'use client'
import type { SearchTab } from '../search-params'
import type { SearchParamsFromCollection } from '../types'
import type { Locale } from '@/i18n-config/language'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import { getItemKeyByField } from '../utils'
import Empty from '../empty'
import Carousel from './carousel'
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
export const GRID_DISPLAY_LIMIT = 8
export const CAROUSEL_COLUMN_CLASS = 'flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]'
/** Collection name key that triggers carousel display (plugins: partners, templates: featured) */
export const CAROUSEL_COLLECTION_NAMES = {
partners: 'partners',
featured: 'featured',
} as const
export type BaseCollection = {
name: string
label: Record<string, string>
description: Record<string, string>
searchable?: boolean
search_params?: { query?: string, sort_by?: string, sort_order?: string }
}
type ViewMoreButtonProps = {
searchParams?: SearchParamsFromCollection
searchTab?: SearchTab
}
function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
const { t } = useTranslation()
const onMoreClick = useMarketplaceMoreClick()
return (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
onClick={() => onMoreClick(searchParams, searchTab)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)
}
export { ViewMoreButton }
type CollectionHeaderProps<TCollection extends BaseCollection> = {
collection: TCollection
itemsLength: number
locale: Locale
carouselCollectionNames: string[]
viewMore: React.ReactNode
}
function CollectionHeader<TCollection extends BaseCollection>({
collection,
itemsLength,
locale,
carouselCollectionNames,
viewMore,
}: CollectionHeaderProps<TCollection>) {
const showViewMore = collection.searchable
&& (carouselCollectionNames.includes(collection.name) || itemsLength > GRID_DISPLAY_LIMIT)
return (
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">
{collection.label[getLanguage(locale)]}
</div>
<div className="system-xs-regular text-text-tertiary">
{collection.description[getLanguage(locale)]}
</div>
</div>
{showViewMore && viewMore}
</div>
)
}
export { CarouselCollection, CollectionHeader }
type CarouselCollectionProps<TItem> = {
items: TItem[]
getItemKey: (item: TItem) => string
renderCard: (item: TItem) => React.ReactNode
cardContainerClassName?: string
}
function CarouselCollection<TItem>({
items,
getItemKey,
renderCard,
cardContainerClassName,
}: CarouselCollectionProps<TItem>) {
const rows: TItem[][] = []
for (let i = 0; i < items.length; i += 2)
rows.push(items.slice(i, i + 2))
return (
<Carousel
className={cardContainerClassName}
showNavigation={items.length > 8}
showPagination={items.length > 8}
autoPlay={items.length > 8}
autoPlayInterval={5000}
>
{rows.map((columnItems, idx) => (
<div
key={columnItems[0] ? getItemKey(columnItems[0]) : idx}
className={CAROUSEL_COLUMN_CLASS}
style={{ scrollSnapAlign: 'start' }}
>
{columnItems.map(item => (
<div key={getItemKey(item)}>{renderCard(item)}</div>
))}
</div>
))}
</Carousel>
)
}
type CollectionListProps<TItem, TCollection extends BaseCollection> = {
collections: TCollection[]
collectionItemsMap: Record<string, TItem[]>
/** Field name to use as item key (e.g. 'plugin_id', 'template_id'). */
itemKeyField: keyof TItem
renderCard: (item: TItem) => React.ReactNode
/** Collection names that use carousel layout (e.g. ['partners'], ['featured']). */
carouselCollectionNames: string[]
/** Search tab for ViewMoreButton (e.g. 'templates' for template collections). */
viewMoreSearchTab?: SearchTab
gridClassName?: string
cardContainerClassName?: string
emptyClassName?: string
}
function CollectionList<TItem, TCollection extends BaseCollection>({
collections,
collectionItemsMap,
itemKeyField,
renderCard,
carouselCollectionNames,
viewMoreSearchTab,
gridClassName = GRID_CLASS,
cardContainerClassName,
emptyClassName,
}: CollectionListProps<TItem, TCollection>) {
const locale = useLocale()
const collectionsWithItems = collections.filter((collection) => {
return collectionItemsMap[collection.name]?.length
})
if (collectionsWithItems.length === 0) {
return <Empty className={emptyClassName} />
}
return (
<>
{
collectionsWithItems.map((collection) => {
const items = collectionItemsMap[collection.name]
const isCarouselCollection = carouselCollectionNames.includes(collection.name)
return (
<div
key={collection.name}
className="py-3"
>
<CollectionHeader
collection={collection}
itemsLength={items.length}
locale={locale}
carouselCollectionNames={carouselCollectionNames}
viewMore={<ViewMoreButton searchParams={collection.search_params} searchTab={viewMoreSearchTab} />}
/>
{isCarouselCollection
? (
<CarouselCollection
items={items}
getItemKey={(item) => getItemKeyByField(item, itemKeyField)}
renderCard={renderCard}
cardContainerClassName={cardContainerClassName}
/>
)
: (
<div className={cn(gridClassName, cardContainerClassName)}>
{items.slice(0, GRID_DISPLAY_LIMIT).map(item => (
<div key={getItemKeyByField(item, itemKeyField)}>
{renderCard(item)}
</div>
))}
</div>
)}
</div>
)
})
}
</>
)
}
export default CollectionList

View File

@ -0,0 +1,55 @@
'use client'
import type { Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import Empty from '../empty'
import CardWrapper from './card-wrapper'
import { GRID_CLASS } from './collection-list'
import TemplateCard from './template-card'
type PluginsVariant = {
variant: 'plugins'
items: Plugin[]
showInstallButton?: boolean
}
type TemplatesVariant = {
variant: 'templates'
items: Template[]
}
type FlatListProps = PluginsVariant | TemplatesVariant
const FlatList = (props: FlatListProps) => {
if (!props.items.length)
return <Empty />
if (props.variant === 'plugins') {
const { items, showInstallButton } = props
return (
<div className={GRID_CLASS}>
{items.map(plugin => (
<CardWrapper
key={`${plugin.org}/${plugin.name}`}
plugin={plugin}
showInstallButton={showInstallButton}
/>
))}
</div>
)
}
const { items } = props
return (
<div className={GRID_CLASS}>
{items.map(template => (
<TemplateCard
key={template.template_id}
template={template}
/>
))}
</div>
)
}
export default FlatList

View File

@ -1,4 +1,4 @@
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { PluginCollection, SearchParamsFromCollection } from '../types'
import type { Plugin } from '@/app/components/plugins/types' import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -36,8 +36,8 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
mockMarketplaceData: { mockMarketplaceData: {
plugins: undefined as Plugin[] | undefined, plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0, pluginsTotal: 0,
marketplaceCollections: undefined as MarketplaceCollection[] | undefined, pluginCollections: undefined as PluginCollection[] | undefined,
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined, pluginCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
isLoading: false, isLoading: false,
page: 1, page: 1,
}, },
@ -207,7 +207,7 @@ const createMockPluginList = (count: number): Plugin[] =>
label: { 'en-US': `Plugin ${i}` }, label: { 'en-US': `Plugin ${i}` },
})) }))
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({ const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
name: `collection-${Math.random().toString(36).substring(7)}`, name: `collection-${Math.random().toString(36).substring(7)}`,
label: { 'en-US': 'Test Collection' }, label: { 'en-US': 'Test Collection' },
description: { 'en-US': 'Test collection description' }, description: { 'en-US': 'Test collection description' },
@ -219,7 +219,7 @@ const createMockCollection = (overrides?: Partial<MarketplaceCollection>): Marke
...overrides, ...overrides,
}) })
const createMockCollectionList = (count: number): MarketplaceCollection[] => const createMockCollectionList = (count: number): PluginCollection[] =>
Array.from({ length: count }, (_, i) => Array.from({ length: count }, (_, i) =>
createMockCollection({ createMockCollection({
name: `collection-${i}`, name: `collection-${i}`,
@ -232,8 +232,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] =>
// ================================ // ================================
describe('List', () => { describe('List', () => {
const defaultProps = { const defaultProps = {
marketplaceCollections: [] as MarketplaceCollection[], pluginCollections: [] as PluginCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>, pluginCollectionPluginsMap: {} as Record<string, Plugin[]>,
plugins: undefined, plugins: undefined,
showInstallButton: false, showInstallButton: false,
cardContainerClassName: '', cardContainerClassName: '',
@ -267,8 +267,8 @@ describe('List', () => {
render( render(
<List <List
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} pluginCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} pluginCollectionPluginsMap={pluginsMap}
/>, />,
) )
@ -313,8 +313,8 @@ describe('List', () => {
render( render(
<List <List
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} pluginCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} pluginCollectionPluginsMap={pluginsMap}
plugins={[]} plugins={[]}
/>, />,
) )
@ -425,12 +425,12 @@ describe('List', () => {
// Edge Cases Tests // Edge Cases Tests
// ================================ // ================================
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle empty marketplaceCollections', () => { it('should handle empty pluginCollections', () => {
render( render(
<List <List
{...defaultProps} {...defaultProps}
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
/>, />,
) )
@ -447,8 +447,8 @@ describe('List', () => {
render( render(
<List <List
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} pluginCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} pluginCollectionPluginsMap={pluginsMap}
plugins={undefined} plugins={undefined}
/>, />,
) )
@ -495,12 +495,12 @@ describe('List', () => {
// ================================ // ================================
describe('ListWithCollection', () => { describe('ListWithCollection', () => {
const defaultProps = { const defaultProps = {
marketplaceCollections: [] as MarketplaceCollection[], variant: 'plugins' as const,
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>, collections: [] as PluginCollection[],
collectionItemsMap: {} as Record<string, Plugin[]>,
showInstallButton: false, showInstallButton: false,
cardContainerClassName: '', cardContainerClassName: '',
cardRender: undefined, cardRender: undefined,
onMoreClick: undefined,
} }
beforeEach(() => { beforeEach(() => {
@ -527,8 +527,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -547,8 +547,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -567,8 +567,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -583,19 +583,19 @@ describe('ListWithCollection', () => {
describe('View More Button', () => { describe('View More Button', () => {
it('should render View More button when collection is searchable', () => { it('should render View More button when collection is searchable', () => {
const collections = [createMockCollection({ const collections = [createMockCollection({
name: 'collection-0', name: 'partners',
searchable: true, searchable: true,
search_params: { query: 'test' }, search_params: { query: 'test' },
})] })]
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), partners: createMockPluginList(1),
} }
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -614,8 +614,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -625,19 +625,19 @@ describe('ListWithCollection', () => {
it('should call moreClick hook with search_params when View More is clicked', () => { it('should call moreClick hook with search_params when View More is clicked', () => {
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
const collections = [createMockCollection({ const collections = [createMockCollection({
name: 'collection-0', name: 'partners',
searchable: true, searchable: true,
search_params: searchParams, search_params: searchParams,
})] })]
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), partners: createMockPluginList(1),
} }
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -668,8 +668,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
cardRender={customCardRender} cardRender={customCardRender}
/>, />,
) )
@ -692,8 +692,8 @@ describe('ListWithCollection', () => {
const { container } = render( const { container } = render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
cardContainerClassName="custom-container" cardContainerClassName="custom-container"
/>, />,
) )
@ -710,8 +710,8 @@ describe('ListWithCollection', () => {
const { container } = render( const { container } = render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
showInstallButton={true} showInstallButton={true}
/>, />,
) )
@ -729,8 +729,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={[]} collections={[]}
marketplaceCollectionPluginsMap={{}} collectionItemsMap={{}}
/>, />,
) )
@ -745,8 +745,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -763,8 +763,8 @@ describe('ListWithCollection', () => {
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} collections={collections}
marketplaceCollectionPluginsMap={pluginsMap} collectionItemsMap={pluginsMap}
/>, />,
) )
@ -783,8 +783,8 @@ describe('ListWrapper', () => {
// Reset mock data // Reset mock data
mockMarketplaceData.plugins = undefined mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.marketplaceCollections = undefined mockMarketplaceData.pluginCollections = undefined
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined mockMarketplaceData.pluginCollectionPluginsMap = undefined
mockMarketplaceData.isLoading = false mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1 mockMarketplaceData.page = 1
}) })
@ -861,8 +861,8 @@ describe('ListWrapper', () => {
describe('List Rendering Logic', () => { describe('List Rendering Logic', () => {
it('should render collections when not loading', () => { it('should render collections when not loading', () => {
mockMarketplaceData.isLoading = false mockMarketplaceData.isLoading = false
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = { mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
@ -874,8 +874,8 @@ describe('ListWrapper', () => {
it('should render List when loading but page > 1', () => { it('should render List when loading but page > 1', () => {
mockMarketplaceData.isLoading = true mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2 mockMarketplaceData.page = 2
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = { mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
@ -899,13 +899,13 @@ describe('ListWrapper', () => {
}) })
it('should show View More button and call moreClick hook', () => { it('should show View More button and call moreClick hook', () => {
mockMarketplaceData.marketplaceCollections = [createMockCollection({ mockMarketplaceData.pluginCollections = [createMockCollection({
name: 'collection-0', name: 'partners',
searchable: true, searchable: true,
search_params: { query: 'test' }, search_params: { query: 'test' },
})] })]
mockMarketplaceData.marketplaceCollectionPluginsMap = { mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1), partners: createMockPluginList(1),
} }
render(<ListWrapper />) render(<ListWrapper />)
@ -973,8 +973,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
/>, />,
) )
@ -991,8 +991,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
/>, />,
) )
@ -1010,8 +1010,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={plugins} plugins={plugins}
/>, />,
) )
@ -1030,8 +1030,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={true} showInstallButton={true}
/>, />,
@ -1050,8 +1050,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={true} showInstallButton={true}
/>, />,
@ -1071,8 +1071,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={true} showInstallButton={true}
/>, />,
@ -1089,8 +1089,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={true} showInstallButton={true}
/>, />,
@ -1105,8 +1105,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={true} showInstallButton={true}
/>, />,
@ -1121,8 +1121,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={true} showInstallButton={true}
/>, />,
@ -1147,8 +1147,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={false} showInstallButton={false}
/>, />,
@ -1167,8 +1167,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
showInstallButton={false} showInstallButton={false}
/>, />,
@ -1182,8 +1182,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
/>, />,
) )
@ -1205,8 +1205,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
/>, />,
) )
@ -1222,8 +1222,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
/>, />,
) )
@ -1239,8 +1239,8 @@ describe('CardWrapper (via List integration)', () => {
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={[plugin]} plugins={[plugin]}
/>, />,
) )
@ -1261,8 +1261,8 @@ describe('Combined Workflows', () => {
mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.isLoading = false mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1 mockMarketplaceData.page = 1
mockMarketplaceData.marketplaceCollections = undefined mockMarketplaceData.pluginCollections = undefined
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined mockMarketplaceData.pluginCollectionPluginsMap = undefined
}) })
it('should transition from loading to showing collections', async () => { it('should transition from loading to showing collections', async () => {
@ -1275,8 +1275,8 @@ describe('Combined Workflows', () => {
// Simulate loading complete // Simulate loading complete
mockMarketplaceData.isLoading = false mockMarketplaceData.isLoading = false
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = { mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
@ -1287,8 +1287,8 @@ describe('Combined Workflows', () => {
}) })
it('should transition from collections to search results', async () => { it('should transition from collections to search results', async () => {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = { mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
@ -1350,8 +1350,9 @@ describe('Accessibility', () => {
const { container } = render( const { container } = render(
<ListWithCollection <ListWithCollection
marketplaceCollections={collections} variant="plugins"
marketplaceCollectionPluginsMap={pluginsMap} collections={collections}
collectionItemsMap={pluginsMap}
/>, />,
) )
@ -1362,17 +1363,18 @@ describe('Accessibility', () => {
it('should have clickable View More button', () => { it('should have clickable View More button', () => {
const collections = [createMockCollection({ const collections = [createMockCollection({
name: 'collection-0', name: 'partners',
searchable: true, searchable: true,
})] })]
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), partners: createMockPluginList(1),
} }
render( render(
<ListWithCollection <ListWithCollection
marketplaceCollections={collections} variant="plugins"
marketplaceCollectionPluginsMap={pluginsMap} collections={collections}
collectionItemsMap={pluginsMap}
/>, />,
) )
@ -1386,13 +1388,13 @@ describe('Accessibility', () => {
const { container } = render( const { container } = render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={plugins} plugins={plugins}
/>, />,
) )
const grid = container.querySelector('.grid-cols-4') const grid = container.querySelector('.grid')
expect(grid).toBeInTheDocument() expect(grid).toBeInTheDocument()
}) })
}) })
@ -1411,8 +1413,8 @@ describe('Performance', () => {
const startTime = performance.now() const startTime = performance.now()
render( render(
<List <List
marketplaceCollections={[]} pluginCollections={[]}
marketplaceCollectionPluginsMap={{}} pluginCollectionPluginsMap={{}}
plugins={plugins} plugins={plugins}
/>, />,
) )
@ -1432,8 +1434,9 @@ describe('Performance', () => {
const startTime = performance.now() const startTime = performance.now()
render( render(
<ListWithCollection <ListWithCollection
marketplaceCollections={collections} variant="plugins"
marketplaceCollectionPluginsMap={pluginsMap} collections={collections}
collectionItemsMap={pluginsMap}
/>, />,
) )
const endTime = performance.now() const endTime = performance.now()

View File

@ -1,14 +1,16 @@
'use client' 'use client'
import type { Plugin } from '../../types' import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types' import type { PluginCollection } from '../types'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Empty from '../empty' import Empty from '../empty'
import CardWrapper from './card-wrapper' import CardWrapper from './card-wrapper'
import { GRID_CLASS } from './collection-list'
import ListWithCollection from './list-with-collection' import ListWithCollection from './list-with-collection'
type ListProps = { type ListProps = {
marketplaceCollections: MarketplaceCollection[] pluginCollections: PluginCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]> pluginCollectionPluginsMap: Record<string, Plugin[]>
plugins?: Plugin[] plugins?: Plugin[]
showInstallButton?: boolean showInstallButton?: boolean
cardContainerClassName?: string cardContainerClassName?: string
@ -16,8 +18,8 @@ type ListProps = {
emptyClassName?: string emptyClassName?: string
} }
const List = ({ const List = ({
marketplaceCollections, pluginCollections,
marketplaceCollectionPluginsMap, pluginCollectionPluginsMap,
plugins, plugins,
showInstallButton, showInstallButton,
cardContainerClassName, cardContainerClassName,
@ -29,8 +31,9 @@ const List = ({
{ {
!plugins && ( !plugins && (
<ListWithCollection <ListWithCollection
marketplaceCollections={marketplaceCollections} variant="plugins"
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap} collections={pluginCollections}
collectionItemsMap={pluginCollectionPluginsMap}
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
cardContainerClassName={cardContainerClassName} cardContainerClassName={cardContainerClassName}
cardRender={cardRender} cardRender={cardRender}
@ -39,11 +42,7 @@ const List = ({
} }
{ {
plugins && !!plugins.length && ( plugins && !!plugins.length && (
<div className={cn( <div className={cn(GRID_CLASS, cardContainerClassName)}>
'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
cardContainerClassName,
)}
>
{ {
plugins.map((plugin) => { plugins.map((plugin) => {
if (cardRender) if (cardRender)

View File

@ -1,35 +1,41 @@
'use client' 'use client'
import type { MarketplaceCollection } from '../types' import type { PluginCollection, Template, TemplateCollection } from '../types'
import type { Plugin } from '@/app/components/plugins/types' import type { Plugin } from '@/app/components/plugins/types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import { useMarketplaceMoreClick } from '../atoms'
import CardWrapper from './card-wrapper' import CardWrapper from './card-wrapper'
import Carousel from './carousel' import CollectionList, { CAROUSEL_COLLECTION_NAMES } from './collection-list'
import TemplateCard from './template-card'
type ListWithCollectionProps = { type BaseProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
cardContainerClassName?: string cardContainerClassName?: string
}
type PluginsVariant = BaseProps & {
variant: 'plugins'
collections: PluginCollection[]
collectionItemsMap: Record<string, Plugin[]>
showInstallButton?: boolean
cardRender?: (plugin: Plugin) => React.JSX.Element | null cardRender?: (plugin: Plugin) => React.JSX.Element | null
} }
const PARTNERS_COLLECTION_NAME = 'partners' type TemplatesVariant = BaseProps & {
const GRID_DISPLAY_LIMIT = 8 // show up to 8 items variant: 'templates'
collections: TemplateCollection[]
collectionItemsMap: Record<string, Template[]>
}
const ListWithCollection = ({ type ListWithCollectionProps = PluginsVariant | TemplatesVariant
marketplaceCollections,
marketplaceCollectionPluginsMap, const ListWithCollection = (props: ListWithCollectionProps) => {
const { variant, cardContainerClassName } = props
if (variant === 'plugins') {
const {
collections,
collectionItemsMap,
showInstallButton, showInstallButton,
cardContainerClassName,
cardRender, cardRender,
}: ListWithCollectionProps) => { } = props
const { t } = useTranslation()
const locale = useLocale()
const onMoreClick = useMarketplaceMoreClick()
const renderPluginCard = (plugin: Plugin) => { const renderPluginCard = (plugin: Plugin) => {
if (cardRender) if (cardRender)
@ -43,92 +49,34 @@ const ListWithCollection = ({
) )
} }
const renderPartnersCarousel = (collection: MarketplaceCollection, plugins: Plugin[]) => {
// Partners collection: 2-row carousel with auto-play
const rows: Plugin[][] = []
for (let i = 0; i < plugins.length; i += 2) {
// Group plugins in pairs (2 per column)
rows.push(plugins.slice(i, i + 2))
}
return ( return (
<Carousel <CollectionList
className={cardContainerClassName} collections={collections}
showNavigation={plugins.length > 8} collectionItemsMap={collectionItemsMap}
showPagination={plugins.length > 8} itemKeyField="plugin_id"
autoPlay={plugins.length > 8} renderCard={renderPluginCard}
autoPlayInterval={5000} carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.partners]}
> cardContainerClassName={cardContainerClassName}
{rows.map(columnPlugins => ( />
<div
key={`column-${columnPlugins[0]?.plugin_id}`}
className="flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]"
style={{ scrollSnapAlign: 'start' }}
>
{columnPlugins.map(plugin => (
<div key={plugin.plugin_id}>
{renderPluginCard(plugin)}
</div>
))}
</div>
))}
</Carousel>
) )
} }
const renderGridCollection = (collection: MarketplaceCollection, plugins: Plugin[]) => { const { collections, collectionItemsMap } = props
// Other collections: responsive grid
const displayPlugins = plugins.slice(0, GRID_DISPLAY_LIMIT)
return ( const renderTemplateCard = (template: Template) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <TemplateCard template={template} />
{displayPlugins.map(plugin => (
<div key={plugin.plugin_id}>
{renderPluginCard(plugin)}
</div>
))}
</div>
) )
}
return ( return (
<> <CollectionList
{ collections={collections}
marketplaceCollections.filter((collection) => { collectionItemsMap={collectionItemsMap}
return marketplaceCollectionPluginsMap[collection.name]?.length itemKeyField="template_id"
}).map((collection) => { renderCard={renderTemplateCard}
const plugins = marketplaceCollectionPluginsMap[collection.name] carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
const isPartnersCollection = collection.name === PARTNERS_COLLECTION_NAME viewMoreSearchTab="templates"
const showViewMore = collection.searchable && (isPartnersCollection || plugins.length > GRID_DISPLAY_LIMIT) cardContainerClassName={cardContainerClassName}
/>
return (
<div
key={collection.name}
className="py-3"
>
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{showViewMore && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
onClick={() => onMoreClick(collection.search_params)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)}
</div>
{isPartnersCollection
? renderPartnersCarousel(collection, plugins)
: renderGridCollection(collection, plugins)}
</div>
)
})
}
</>
) )
} }

View File

@ -0,0 +1,84 @@
import type { Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ListWrapper from './list-wrapper'
const { mockMarketplaceData } = vi.hoisted(() => ({
mockMarketplaceData: {
creationType: 'plugins' as 'plugins' | 'templates',
isLoading: false,
page: 1,
isFetchingNextPage: false,
pluginCollections: [],
pluginCollectionPluginsMap: {},
plugins: undefined as Plugin[] | undefined,
templateCollections: [],
templateCollectionTemplatesMap: {},
templates: undefined as Template[] | undefined,
},
}))
vi.mock('../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading-component">Loading</div>,
}))
vi.mock('./flat-list', () => ({
default: ({ variant, items }: { variant: 'plugins' | 'templates', items: unknown[] }) => (
<div data-testid={`flat-list-${variant}`}>
{items.length}
</div>
),
}))
vi.mock('./list-with-collection', () => ({
default: ({ variant }: { variant: 'plugins' | 'templates' }) => (
<div data-testid={`collection-list-${variant}`}>collection</div>
),
}))
describe('ListWrapper flat rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMarketplaceData.creationType = 'plugins'
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
mockMarketplaceData.isFetchingNextPage = false
mockMarketplaceData.plugins = undefined
mockMarketplaceData.templates = undefined
})
it('renders plugin flat list when plugin items exist', () => {
mockMarketplaceData.creationType = 'plugins'
mockMarketplaceData.plugins = [{ org: 'o', name: 'p' } as Plugin]
render(<ListWrapper />)
expect(screen.getByTestId('flat-list-plugins')).toBeInTheDocument()
expect(screen.queryByTestId('collection-list-plugins')).not.toBeInTheDocument()
})
it('renders template flat list when template items exist', () => {
mockMarketplaceData.creationType = 'templates'
mockMarketplaceData.templates = [{ template_id: 't1' } as Template]
render(<ListWrapper />)
expect(screen.getByTestId('flat-list-templates')).toBeInTheDocument()
expect(screen.queryByTestId('collection-list-templates')).not.toBeInTheDocument()
})
it('renders template collection list when templates are undefined', () => {
mockMarketplaceData.creationType = 'templates'
mockMarketplaceData.templates = undefined
render(<ListWrapper />)
expect(screen.getByTestId('collection-list-templates')).toBeInTheDocument()
expect(screen.queryByTestId('flat-list-templates')).not.toBeInTheDocument()
})
})

View File

@ -1,163 +1,74 @@
'use client' 'use client'
import type { ActivePluginType } from '../constants'
import { useTranslation } from '#i18n'
import { useState } from 'react'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import SegmentedControl from '@/app/components/base/segmented-control'
import CategoriesFilter from '../../plugin-page/filter-management/category-filter'
import TagFilter from '../../plugin-page/filter-management/tag-filter'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode } from '../atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state' import { useMarketplaceData } from '../state'
import List from './index' import FlatList from './flat-list'
import TemplateList from './template-list' import ListWithCollection from './list-with-collection'
import TemplateSearchList from './template-search-list'
type ListWrapperProps = { type ListWrapperProps = {
showInstallButton?: boolean showInstallButton?: boolean
} }
type SearchScope = 'all' | 'plugins' | 'creators'
const searchScopeOptionKeys = [
{ value: 'all', textKey: 'marketplace.searchFilterAll' },
{ value: 'plugins', textKey: 'marketplace.searchFilterPlugins' },
{ value: 'creators', textKey: 'marketplace.searchFilterCreators' },
] as const satisfies ReadonlyArray<{ value: SearchScope, textKey: 'marketplace.searchFilterAll' | 'marketplace.searchFilterPlugins' | 'marketplace.searchFilterCreators' }>
const ListWrapper = ({
showInstallButton,
}: ListWrapperProps) => {
const { t } = useTranslation()
const isSearchMode = useMarketplaceSearchMode()
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const [searchScope, setSearchScope] = useState<SearchScope>('all')
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
const marketplaceData = useMarketplaceData() const marketplaceData = useMarketplaceData()
const { const { creationType, isLoading, page, isFetchingNextPage } = marketplaceData
creationType,
isLoading,
} = marketplaceData
// Templates view const isPluginView = creationType === 'plugins'
if (creationType === 'templates') {
const { const renderContent = () => {
templateCollections, if (!isPluginView) {
templateCollectionTemplatesMap, const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
templates, if (templates !== undefined) {
isSearchMode: isTemplateSearchMode,
} = marketplaceData
return ( return (
<div <FlatList
style={{ scrollbarGutter: 'stable' }} variant="templates"
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2" items={templates}
>
{
isLoading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)
}
{
!isLoading && (
isTemplateSearchMode
? (
<TemplateSearchList templates={templates || []} />
)
: (
<TemplateList
templateCollections={templateCollections || []}
templateCollectionTemplatesMap={templateCollectionTemplatesMap || {}}
/> />
) )
)
} }
</div>
)
}
// Plugins view (default)
const {
plugins,
pluginsTotal,
marketplaceCollections,
marketplaceCollectionPluginsMap,
isFetchingNextPage,
page,
} = marketplaceData
const pluginsCount = pluginsTotal || 0
const searchScopeOptions: Array<{ value: SearchScope, text: string, count: number }> = searchScopeOptionKeys.map(option => ({
value: option.value,
text: t(option.textKey, { ns: 'plugin' }),
count: option.value === 'creators' ? 0 : pluginsCount,
}))
return ( return (
<div <ListWithCollection
style={{ scrollbarGutter: 'stable' }} variant="templates"
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2" collections={templateCollections || []}
> collectionItemsMap={templateCollectionTemplatesMap || {}}
{plugins && !isSearchMode && (
<div className="mb-4 flex items-center pt-3">
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
<SortDropdown />
</div>
)}
{isSearchMode && (
<div className="mb-4 flex items-center justify-between pt-3">
<div className="flex items-center gap-2">
<SegmentedControl
size="large"
activeState="accentLight"
value={searchScope}
onChange={(value) => {
setSearchScope(value as SearchScope)
}}
options={searchScopeOptions}
/> />
<CategoriesFilter
value={activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? [] : [activePluginType]}
onChange={(categories) => {
if (categories.length === 0) {
handleActivePluginTypeChange(PLUGIN_TYPE_SEARCH_MAP.all)
return
}
handleActivePluginTypeChange(categories[categories.length - 1] as ActivePluginType)
}}
/>
<TagFilter
value={filterPluginTags}
onChange={handleFilterPluginTagsChange}
/>
</div>
<SortDropdown />
</div>
)}
{
isLoading && page === 1 && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
) )
} }
{
(!isLoading || page > 1) && ( const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
<List if (plugins !== undefined) {
marketplaceCollections={marketplaceCollections || []} return (
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} <FlatList
plugins={plugins} variant="plugins"
items={plugins}
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
/> />
) )
} }
{
isFetchingNextPage && ( return (
<Loading className="my-3" /> <ListWithCollection
variant="plugins"
collections={pluginCollections || []}
collectionItemsMap={pluginCollectionPluginsMap || {}}
showInstallButton={showInstallButton}
/>
) )
} }
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
{isLoading && page === 1 && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)}
{(!isLoading || page > 1) && renderContent()}
{isFetchingNextPage && <Loading className="my-3" />}
</div> </div>
) )
} }

View File

@ -1,129 +0,0 @@
'use client'
import type { Template, TemplateCollection } from '../types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import Empty from '../empty'
import Carousel from './carousel'
import TemplateCard from './template-card'
type TemplateListProps = {
templateCollections: TemplateCollection[]
templateCollectionTemplatesMap: Record<string, Template[]>
cardContainerClassName?: string
}
const FEATURED_COLLECTION_NAME = 'featured'
const GRID_DISPLAY_LIMIT = 8
const TemplateList = ({
templateCollections,
templateCollectionTemplatesMap,
cardContainerClassName,
}: TemplateListProps) => {
const { t } = useTranslation()
const locale = useLocale()
const renderTemplateCard = (template: Template) => {
return (
<TemplateCard
key={template.template_id}
template={template}
/>
)
}
const renderFeaturedCarousel = (collection: TemplateCollection, templates: Template[]) => {
// Featured collection: 2-row carousel with auto-play
const rows: Template[][] = []
for (let i = 0; i < templates.length; i += 2) {
rows.push(templates.slice(i, i + 2))
}
return (
<Carousel
className={cardContainerClassName}
showNavigation={templates.length > 8}
showPagination={templates.length > 8}
autoPlay={templates.length > 8}
autoPlayInterval={5000}
>
{rows.map(columnTemplates => (
<div
key={`column-${columnTemplates[0]?.template_id}`}
className="flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]"
style={{ scrollSnapAlign: 'start' }}
>
{columnTemplates.map(template => (
<div key={template.template_id}>
{renderTemplateCard(template)}
</div>
))}
</div>
))}
</Carousel>
)
}
const renderGridCollection = (collection: TemplateCollection, templates: Template[]) => {
const displayTemplates = templates.slice(0, GRID_DISPLAY_LIMIT)
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{displayTemplates.map(template => (
<div key={template.template_id}>
{renderTemplateCard(template)}
</div>
))}
</div>
)
}
const collectionsWithTemplates = templateCollections.filter((collection) => {
return templateCollectionTemplatesMap[collection.name]?.length
})
if (collectionsWithTemplates.length === 0) {
return <Empty />
}
return (
<>
{
collectionsWithTemplates.map((collection) => {
const templates = templateCollectionTemplatesMap[collection.name]
const isFeaturedCollection = collection.name === FEATURED_COLLECTION_NAME
const showViewMore = collection.searchable && (isFeaturedCollection || templates.length > GRID_DISPLAY_LIMIT)
return (
<div
key={collection.name}
className="py-3"
>
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{showViewMore && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)}
</div>
{isFeaturedCollection
? renderFeaturedCarousel(collection, templates)
: renderGridCollection(collection, templates)}
</div>
)
})
}
</>
)
}
export default TemplateList

View File

@ -1,27 +0,0 @@
'use client'
import type { Template } from '../types'
import Empty from '../empty'
import TemplateCard from './template-card'
type TemplateSearchListProps = {
templates: Template[]
}
const TemplateSearchList = ({ templates }: TemplateSearchListProps) => {
if (templates.length === 0) {
return <Empty />
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{templates.map(template => (
<div key={template.template_id}>
<TemplateCard template={template} />
</div>
))}
</div>
)
}
export default TemplateSearchList

View File

@ -0,0 +1,19 @@
'use client'
import { useSearchTab } from './atoms'
import ListWrapper from './list/list-wrapper'
import SearchPage from './search-page'
type MarketplaceContentProps = {
showInstallButton?: boolean
}
const MarketplaceContent = ({ showInstallButton }: MarketplaceContentProps) => {
const [searchTab] = useSearchTab()
if (searchTab)
return <SearchPage />
return <ListWrapper showInstallButton={showInstallButton} />
}
export default MarketplaceContent

View File

@ -1,9 +1,8 @@
'use client' 'use client'
import { useMarketplaceSearchMode } from './atoms' import { useSearchTab } from './atoms'
import { Description } from './description' import { Description } from './description'
import SearchResultsHeader from './search-results-header' import SearchResultsHeader from './search-results-header'
import { useMarketplaceData } from './state'
type MarketplaceHeaderProps = { type MarketplaceHeaderProps = {
descriptionClassName?: string descriptionClassName?: string
@ -11,14 +10,10 @@ type MarketplaceHeaderProps = {
} }
const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => { const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => {
const { creationType, isSearchMode: templatesSearchMode } = useMarketplaceData() const [searchTab] = useSearchTab()
const pluginsSearchMode = useMarketplaceSearchMode()
// Use templates search mode when viewing templates, otherwise use plugins search mode if (searchTab)
const isSearchMode = creationType === 'templates' ? templatesSearchMode : pluginsSearchMode return <SearchResultsHeader marketplaceNav={marketplaceNav} />
if (isSearchMode)
return <SearchResultsHeader />
return <Description className={descriptionClassName} marketplaceNav={marketplaceNav} /> return <Description className={descriptionClassName} marketplaceNav={marketplaceNav} />
} }

View File

@ -0,0 +1,99 @@
'use client'
import type { ActivePluginType } from './constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { RiArchive2Line } from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
import { searchModeAtom, useActivePluginCategory } from './atoms'
import CategorySwitch from './category-switch'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons'
type PluginTypeSwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
}
const PluginCategorySwitch = ({
className,
variant = 'default',
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
const setSearchMode = useSetAtom(searchModeAtom)
const isHeroVariant = variant === 'hero'
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle),
},
]
const handleChange = (value: string) => {
handleActivePluginCategoryChange(value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) {
setSearchMode(null)
}
}
return (
<CategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activePluginCategory}
onChange={handleChange}
/>
)
}
export default PluginCategorySwitch

View File

@ -1,128 +0,0 @@
'use client'
import type { ActivePluginType } from './constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import {
RiApps2Line,
RiArchive2Line,
} from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { cn } from '@/utils/classnames'
import { searchModeAtom, useActivePluginType } from './atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons'
type PluginTypeSwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const PluginTypeSwitch = ({
className,
variant = 'default',
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const setSearchMode = useSetAtom(searchModeAtom)
const isHeroVariant = variant === 'hero'
const getTypeIcon = (value: ActivePluginType) => {
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
return isHeroVariant ? <RiApps2Line className="mr-1.5 h-4 w-4" /> : null
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
}
const options: Array<{
value: ActivePluginType
text: string
icon: React.ReactNode | null
}> = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle),
},
]
const getItemClassName = (isActive: boolean) => {
if (isHeroVariant) {
return cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
isActive
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
: 'hover:bg-state-base-hover',
)
}
return cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
)
}
return (
<div className={cn(
'flex shrink-0 items-center space-x-2',
!isHeroVariant && 'justify-center bg-background-body py-3',
className,
)}
>
{
options.map(option => (
<div
key={option.value}
className={getItemClassName(activePluginType === option.value)}
onClick={() => {
handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
setSearchMode(null)
}
}}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}
export default PluginTypeSwitch

View File

@ -1,32 +1,37 @@
import type { PluginsSearchParams, TemplateSearchParams } from './types' import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams, UnifiedSearchParams } from './types'
import type { MarketPlaceInputs } from '@/contract/router' import type { MarketPlaceInputs } from '@/contract/router'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client' import { marketplaceQuery } from '@/service/client'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates } from './utils' import { getMarketplaceCollectionsAndPlugins, getMarketplaceCreators, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates, getMarketplaceUnifiedSearch } from './utils'
export function useMarketplaceCollectionsAndPlugins( export function useMarketplaceCollectionsAndPlugins(
collectionsParams: MarketPlaceInputs['collections']['query'], collectionsParams: MarketPlaceInputs['plugins']['collections']['query'],
options?: { enabled?: boolean },
) { ) {
return useQuery({ return useQuery({
queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }), queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
enabled: options?.enabled !== false,
}) })
} }
export function useMarketplaceTemplateCollectionsAndTemplates( export function useMarketplaceTemplateCollectionsAndTemplates(
query?: { page?: number, page_size?: number, condition?: string }, query?: { page?: number, page_size?: number, condition?: string },
options?: { enabled?: boolean },
) { ) {
return useQuery({ return useQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }), queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }),
queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }), queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }),
enabled: options?.enabled !== false,
}) })
} }
export function useMarketplacePlugins( export function useMarketplacePlugins(
queryParams: PluginsSearchParams | undefined, queryParams: PluginsSearchParams | undefined,
options?: { enabled?: boolean },
) { ) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: marketplaceQuery.searchAdvanced.queryKey({ queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
input: { input: {
body: queryParams!, body: queryParams!,
params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
@ -39,12 +44,13 @@ export function useMarketplacePlugins(
return loaded < (lastPage.total || 0) ? nextPage : undefined return loaded < (lastPage.total || 0) ? nextPage : undefined
}, },
initialPageParam: 1, initialPageParam: 1,
enabled: queryParams !== undefined, enabled: options?.enabled !== false && queryParams !== undefined,
}) })
} }
export function useMarketplaceTemplates( export function useMarketplaceTemplates(
queryParams: TemplateSearchParams | undefined, queryParams: TemplateSearchParams | undefined,
options?: { enabled?: boolean },
) { ) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({ queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
@ -59,6 +65,38 @@ export function useMarketplaceTemplates(
return loaded < (lastPage.total || 0) ? nextPage : undefined return loaded < (lastPage.total || 0) ? nextPage : undefined
}, },
initialPageParam: 1, initialPageParam: 1,
enabled: options?.enabled !== false && queryParams !== undefined,
})
}
export function useMarketplaceCreators(
queryParams: CreatorSearchParams | undefined,
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({
input: {
body: queryParams!,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: queryParams !== undefined,
})
}
export function useMarketplaceUnifiedSearch(
queryParams: UnifiedSearchParams | undefined,
) {
return useQuery({
queryKey: marketplaceQuery.searchUnified.queryKey({
input: { body: queryParams! },
}),
queryFn: ({ signal }) => getMarketplaceUnifiedSearch(queryParams, signal),
enabled: queryParams !== undefined, enabled: queryParams !== undefined,
}) })
} }

View File

@ -56,19 +56,19 @@ vi.mock('@/hooks/use-i18n', () => ({
// Mock marketplace state hooks // Mock marketplace state hooks
const { const {
mockSearchPluginText, mockSearchText,
mockHandleSearchPluginTextChange, mockHandleSearchTextChange,
mockFilterPluginTags, mockFilterPluginTags,
mockHandleFilterPluginTagsChange, mockHandleFilterPluginTagsChange,
mockActivePluginType, mockActivePluginCategory,
mockSortValue, mockSortValue,
} = vi.hoisted(() => { } = vi.hoisted(() => {
return { return {
mockSearchPluginText: '', mockSearchText: '',
mockHandleSearchPluginTextChange: vi.fn(), mockHandleSearchTextChange: vi.fn(),
mockFilterPluginTags: [] as string[], mockFilterPluginTags: [] as string[],
mockHandleFilterPluginTagsChange: vi.fn(), mockHandleFilterPluginTagsChange: vi.fn(),
mockActivePluginType: 'all', mockActivePluginCategory: 'all',
mockSortValue: { mockSortValue: {
sortBy: 'install_count', sortBy: 'install_count',
sortOrder: 'DESC', sortOrder: 'DESC',
@ -77,13 +77,23 @@ const {
}) })
vi.mock('../atoms', () => ({ vi.mock('../atoms', () => ({
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
useActivePluginType: () => [mockActivePluginType, vi.fn()], useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()],
useMarketplaceSortValue: () => mockSortValue, useMarketplaceSortValue: () => mockSortValue,
searchModeAtom: {}, searchModeAtom: {},
})) }))
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils')
return {
...actual,
mapUnifiedPluginToPlugin: (item: Plugin) => item,
mapUnifiedTemplateToTemplate: (item: unknown) => item,
mapUnifiedCreatorToCreator: (item: unknown) => item,
}
})
// Mock useTags hook // Mock useTags hook
const mockTags: Tag[] = [ const mockTags: Tag[] = [
{ name: 'agent', label: 'Agent' }, { name: 'agent', label: 'Agent' },
@ -118,8 +128,15 @@ vi.mock('@/app/components/plugins/hooks', () => ({
let mockDropdownPlugins: Plugin[] = [] let mockDropdownPlugins: Plugin[] = []
vi.mock('../query', () => ({ vi.mock('../query', () => ({
useMarketplacePlugins: () => ({ useMarketplaceUnifiedSearch: () => ({
data: { pages: [{ plugins: mockDropdownPlugins }] }, data: {
plugins: { items: mockDropdownPlugins, total: mockDropdownPlugins.length },
templates: { items: [], total: 0 },
creators: { items: [], total: 0 },
organizations: { items: [], total: 0 },
page: 1,
page_size: 5,
},
isLoading: false, isLoading: false,
}), }),
})) }))
@ -548,6 +565,8 @@ describe('SearchDropdown', () => {
<SearchDropdown <SearchDropdown
query="dropbox" query="dropbox"
plugins={[createPlugin()]} plugins={[createPlugin()]}
templates={[]}
creators={[]}
onShowAll={vi.fn()} onShowAll={vi.fn()}
/>, />,
) )
@ -566,6 +585,8 @@ describe('SearchDropdown', () => {
<SearchDropdown <SearchDropdown
query="dropbox" query="dropbox"
plugins={[createPlugin()]} plugins={[createPlugin()]}
templates={[]}
creators={[]}
onShowAll={onShowAll} onShowAll={onShowAll}
/>, />,
) )
@ -615,7 +636,7 @@ describe('SearchBoxWrapper', () => {
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } }) fireEvent.change(input, { target: { value: 'new search' } })
expect(mockHandleSearchPluginTextChange).not.toHaveBeenCalled() expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
}) })
it('should commit search when pressing Enter', () => { it('should commit search when pressing Enter', () => {
@ -625,7 +646,7 @@ describe('SearchBoxWrapper', () => {
fireEvent.change(input, { target: { value: 'new search' } }) fireEvent.change(input, { target: { value: 'new search' } })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search')
}) })
}) })

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { PluginsSearchParams } from '../types' import type { UnifiedSearchParams } from '../types'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { useDebounce } from 'ahooks' import { useDebounce } from 'ahooks'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
@ -14,14 +14,10 @@ import {
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { import {
searchModeAtom, searchModeAtom,
useActivePluginType, useSearchText,
useFilterPluginTags,
useMarketplaceSortValue,
useSearchPluginText,
} from '../atoms' } from '../atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' import { useMarketplaceUnifiedSearch } from '../query'
import { useMarketplacePlugins } from '../query' import { mapUnifiedCreatorToCreator, mapUnifiedPluginToPlugin, mapUnifiedTemplateToTemplate } from '../utils'
import { getMarketplaceListFilterType } from '../utils'
import SearchDropdown from './search-dropdown' import SearchDropdown from './search-dropdown'
type SearchBoxWrapperProps = { type SearchBoxWrapperProps = {
@ -33,41 +29,44 @@ const SearchBoxWrapper = ({
inputClassName, inputClassName,
}: SearchBoxWrapperProps) => { }: SearchBoxWrapperProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() const [searchText, handleSearchTextChange] = useSearchText()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginType] = useActivePluginType()
const sort = useMarketplaceSortValue()
const setSearchMode = useSetAtom(searchModeAtom) const setSearchMode = useSetAtom(searchModeAtom)
const committedSearch = searchPluginText || '' const committedSearch = searchText || ''
const [draftSearch, setDraftSearch] = useState(committedSearch) const [draftSearch, setDraftSearch] = useState(committedSearch)
const [isFocused, setIsFocused] = useState(false) const [isFocused, setIsFocused] = useState(false)
const [isHoveringDropdown, setIsHoveringDropdown] = useState(false) const [isHoveringDropdown, setIsHoveringDropdown] = useState(false)
const debouncedDraft = useDebounce(draftSearch, { wait: 300 }) const debouncedDraft = useDebounce(draftSearch, { wait: 300 })
const hasDraft = !!debouncedDraft.trim() const hasDraft = !!debouncedDraft.trim()
const dropdownQueryParams = useMemo(() => { const dropdownQueryParams = useMemo((): UnifiedSearchParams | undefined => {
if (!hasDraft) if (!hasDraft)
return undefined return undefined
const filterType = getMarketplaceListFilterType(activePluginType) as PluginsSearchParams['type']
return { return {
query: debouncedDraft.trim(), query: debouncedDraft.trim(),
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, scope: ['plugins', 'templates', 'creators'],
tags: filterPluginTags, page_size: 5,
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
type: filterType,
page_size: 3,
} }
}, [activePluginType, debouncedDraft, filterPluginTags, hasDraft, sort.sortBy, sort.sortOrder]) }, [debouncedDraft, hasDraft])
const dropdownQuery = useMarketplacePlugins(dropdownQueryParams) const dropdownQuery = useMarketplaceUnifiedSearch(dropdownQueryParams)
const dropdownPlugins = dropdownQuery.data?.pages[0]?.plugins || [] const dropdownPlugins = useMemo(
() => (dropdownQuery.data?.plugins.items || []).map(mapUnifiedPluginToPlugin),
[dropdownQuery.data?.plugins.items],
)
const dropdownTemplates = useMemo(
() => (dropdownQuery.data?.templates.items || []).map(mapUnifiedTemplateToTemplate),
[dropdownQuery.data?.templates.items],
)
const dropdownCreators = useMemo(
() => (dropdownQuery.data?.creators.items || []).map(mapUnifiedCreatorToCreator),
[dropdownQuery.data?.creators.items],
)
const handleSubmit = () => { const handleSubmit = () => {
const trimmed = draftSearch.trim() const trimmed = draftSearch.trim()
if (!trimmed) if (!trimmed)
return return
handleSearchPluginTextChange(trimmed) handleSearchTextChange(trimmed)
setSearchMode(true) setSearchMode(true)
setIsFocused(false) setIsFocused(false)
} }
@ -119,6 +118,8 @@ const SearchBoxWrapper = ({
<SearchDropdown <SearchDropdown
query={debouncedDraft.trim()} query={debouncedDraft.trim()}
plugins={dropdownPlugins} plugins={dropdownPlugins}
templates={dropdownTemplates}
creators={dropdownCreators}
onShowAll={handleSubmit} onShowAll={handleSubmit}
isLoading={dropdownQuery.isLoading} isLoading={dropdownQuery.isLoading}
/> />

View File

@ -1,16 +1,62 @@
import type { Creator, Template } from '../../types'
import type { Plugin } from '@/app/components/plugins/types' import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { RiArrowRightLine } from '@remixicon/react' import { RiArrowRightLine } from '@remixicon/react'
import { Fragment } from 'react'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { useCategories } from '@/app/components/plugins/hooks' import { useCategories } from '@/app/components/plugins/hooks'
import { useRenderI18nObject } from '@/hooks/use-i18n' import { useRenderI18nObject } from '@/hooks/use-i18n'
import { cn } from '@/utils/classnames'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons' import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
import { getPluginDetailLinkInMarketplace } from '../../utils' import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace } from '../../utils'
import { getMarketplaceUrl } from '@/utils/var'
const DROPDOWN_PANEL = 'w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
const ICON_BOX_BASE = 'flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
const DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => (
<div className="p-1">
<div className="system-xs-semibold-uppercase px-3 pb-2 pt-3 text-text-primary">{title}</div>
<div className="flex flex-col">{children}</div>
</div>
)
const DropdownItem = ({ href, icon, children }: {
href: string
icon: React.ReactNode
children: React.ReactNode
}) => (
<a className="flex gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover" href={href}>
{icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">{children}</div>
</a>
)
const IconBox = ({ shape, className, children }: {
shape: 'rounded-lg' | 'rounded-full'
className?: string
children: React.ReactNode
}) => (
<div className={`${ICON_BOX_BASE} ${shape} ${className ?? ''}`}>
{children}
</div>
)
const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => (
<div className="flex items-center gap-1.5 pt-0.5 text-text-tertiary">
{items.filter(Boolean).map((item, i) => (
<Fragment key={i}>
{i > 0 && <span className="system-xs-regular">·</span>}
{typeof item === 'string' ? <span className="system-xs-regular">{item}</span> : item}
</Fragment>
))}
</div>
)
type SearchDropdownProps = { type SearchDropdownProps = {
query: string query: string
plugins: Plugin[] plugins: Plugin[]
templates: Template[]
creators: Creator[]
onShowAll: () => void onShowAll: () => void
isLoading?: boolean isLoading?: boolean
} }
@ -18,6 +64,8 @@ type SearchDropdownProps = {
const SearchDropdown = ({ const SearchDropdown = ({
query, query,
plugins, plugins,
templates,
creators,
onShowAll, onShowAll,
isLoading = false, isLoading = false,
}: SearchDropdownProps) => { }: SearchDropdownProps) => {
@ -25,20 +73,19 @@ const SearchDropdown = ({
const getValueFromI18nObject = useRenderI18nObject() const getValueFromI18nObject = useRenderI18nObject()
const { categoriesMap } = useCategories(true) const { categoriesMap } = useCategories(true)
const hasResults = plugins.length > 0 || templates.length > 0 || creators.length > 0
return ( return (
<div className="w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm"> <div className={DROPDOWN_PANEL}>
<div className="flex flex-col"> <div className="flex flex-col">
{isLoading && !plugins.length && ( {isLoading && !hasResults && (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center py-6">
<Loading /> <Loading />
</div> </div>
)} )}
{!!plugins.length && (
<div className="p-1"> {plugins.length > 0 && (
<div className="system-xs-semibold-uppercase px-3 pb-2 pt-3 text-text-primary"> <DropdownSection title={t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}>
{t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}
</div>
<div className="flex flex-col">
{plugins.map((plugin) => { {plugins.map((plugin) => {
const title = getValueFromI18nObject(plugin.label) || plugin.name const title = getValueFromI18nObject(plugin.label) || plugin.name
const description = getValueFromI18nObject(plugin.brief) || '' const description = getValueFromI18nObject(plugin.brief) || ''
@ -46,45 +93,99 @@ const SearchDropdown = ({
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 }) const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
const author = plugin.org || plugin.author || '' const author = plugin.org || plugin.author || ''
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category] const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
return ( const categoryNode = (
<a <div className="flex items-center gap-1">
key={`${plugin.org}/${plugin.name}`} {TypeIcon && <TypeIcon className="h-4 w-4 text-text-tertiary" />}
className={cn( <span>{categoryLabel}</span>
'flex gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover',
)}
href={getPluginDetailLinkInMarketplace(plugin)}
>
<div className="flex h-7 w-7 items-center justify-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> )
return (
<DropdownItem
key={`${plugin.org}/${plugin.name}`}
href={getPluginDetailLinkInMarketplace(plugin)}
icon={(
<IconBox shape="rounded-lg">
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
</IconBox>
)}
>
<div className="system-sm-medium truncate text-text-primary">{title}</div> <div className="system-sm-medium truncate text-text-primary">{title}</div>
{!!description && ( {!!description && (
<div className="system-xs-regular truncate text-text-tertiary">{description}</div> <div className="system-xs-regular truncate text-text-tertiary">{description}</div>
)} )}
<div className="flex items-center gap-1.5 pt-0.5 text-text-tertiary"> <ItemMeta
<div className="flex items-center gap-1"> items={[
{TypeIcon && <TypeIcon className="h-4 w-4 text-text-tertiary" />} categoryNode,
<span className="system-xs-regular">{categoryLabel}</span> t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
</div> installLabel,
<span className="system-xs-regular">·</span> ]}
<span className="system-xs-regular"> />
{t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author })} </DropdownItem>
</span>
<span className="system-xs-regular">·</span>
<span className="system-xs-regular">{installLabel}</span>
</div>
</div>
</a>
) )
})} })}
</DropdownSection>
)}
{templates.length > 0 && (
<DropdownSection title={t('templates', { ns: 'plugin' })}>
{templates.map(template => (
<DropdownItem
key={template.template_id}
href={getMarketplaceUrl(`/templates/${template.template_id}`)}
icon={(
<IconBox shape="rounded-lg" className="text-base">
{template.icon || '📄'}
</IconBox>
)}
>
<div className="system-sm-medium truncate text-text-primary">{template.name}</div>
<ItemMeta
items={[
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.author }),
...(template.tags.length > 0
? [<span className="system-xs-regular truncate">{template.tags.join(', ')}</span>]
: []),
]}
/>
</DropdownItem>
))}
</DropdownSection>
)}
{creators.length > 0 && (
<DropdownSection title={t('marketplace.searchFilterCreators', { ns: 'plugin' })}>
{creators.map(creator => (
<DropdownItem
key={creator.unique_handle}
href={getMarketplaceUrl(`/creators/${creator.unique_handle}`)}
icon={(
<IconBox shape="rounded-full">
<img
className="h-full w-full object-cover"
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={creator.display_name}
/>
</IconBox>
)}
>
<div className="flex items-center gap-1.5">
<span className="system-sm-medium truncate text-text-primary">{creator.display_name}</span>
<span className="system-xs-regular text-text-tertiary">
@
{creator.unique_handle}
</span>
</div> </div>
</div> {!!creator.description && (
<div className="system-xs-regular truncate text-text-tertiary">{creator.description}</div>
)}
</DropdownItem>
))}
</DropdownSection>
)} )}
</div> </div>
<div className="border-t border-divider-subtle p-1"> <div className="border-t border-divider-subtle p-1">
<button <button
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left" className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-state-base-hover"
onClick={onShowAll} onClick={onShowAll}
type="button" type="button"
> >
@ -95,7 +196,7 @@ const SearchDropdown = ({
<span className="system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1.5 py-0.5 text-text-tertiary group-hover:hidden"> <span className="system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1.5 py-0.5 text-text-tertiary group-hover:hidden">
{t('marketplace.searchDropdown.enter', { ns: 'plugin' })} {t('marketplace.searchDropdown.enter', { ns: 'plugin' })}
</span> </span>
<RiArrowRightLine className="hidden h-5 w-5 text-text-tertiary group-hover:block" /> <RiArrowRightLine className="hidden h-[18px] w-[18px] text-text-accent group-hover:block" />
</span> </span>
</button> </button>
</div> </div>

View File

@ -0,0 +1,60 @@
'use client'
import type { Creator } from '../types'
import { getCreatorAvatarUrl } from '../utils'
import { useTranslation } from '#i18n'
import { getMarketplaceUrl } from '@/utils/var'
type CreatorCardProps = {
creator: Creator
}
const CreatorCard = ({ creator }: CreatorCardProps) => {
const { t } = useTranslation()
const href = getMarketplaceUrl(`/creators/${creator.unique_handle}`)
const displayName = creator.display_name || creator.name
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-2 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg p-4 transition-colors hover:bg-state-base-hover"
>
<div className="flex items-center gap-3">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-full border border-components-panel-border-subtle bg-background-default-dodge">
<img
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="system-md-medium truncate text-text-primary">{displayName}</div>
<div className="system-sm-regular text-text-tertiary">
@
{creator.unique_handle}
</div>
</div>
</div>
{!!creator.description && (
<div className="system-sm-regular line-clamp-2 text-text-secondary">
{creator.description}
</div>
)}
{(creator.plugin_count !== undefined || creator.template_count !== undefined) && (
<div className="system-xs-regular text-text-tertiary">
{creator.plugin_count || 0}
{' '}
{t('plugins', { ns: 'plugin' }).toLowerCase()}
{' · '}
{creator.template_count || 0}
{' '}
{t('templates', { ns: 'plugin' }).toLowerCase()}
</div>
)}
</a>
)
}
export default CreatorCard

View File

@ -0,0 +1,266 @@
'use client'
import type { SearchTab } from '../search-params'
import type { Creator, PluginsSearchParams, Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import Loading from '@/app/components/base/loading'
import SegmentedControl from '@/app/components/base/segmented-control'
import { useMarketplaceSortValue, useSearchTab, useSearchText } from '../atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import Empty from '../empty'
import { useMarketplaceContainerScroll } from '../hooks'
import CardWrapper from '../list/card-wrapper'
import TemplateCard from '../list/template-card'
import { useMarketplaceCreators, useMarketplacePlugins, useMarketplaceTemplates } from '../query'
import SortDropdown from '../sort-dropdown'
import { getPluginFilterType, mapTemplateDetailToTemplate } from '../utils'
import CreatorCard from './creator-card'
const PAGE_SIZE = 40
const ZERO_WIDTH_SPACE = '\u200B'
type SortValue = { sortBy: string, sortOrder: string }
function mapSortForTemplates(sort: SortValue): { sort_by: string, sort_order: string } {
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { sort_by: sortBy, sort_order: sort.sortOrder }
}
function mapSortForCreators(sort: SortValue): { sort_by: string, sort_order: string } {
const sortBy = sort.sortBy === 'install_count' ? 'created_at' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { sort_by: sortBy, sort_order: sort.sortOrder }
}
const SearchPage = () => {
const { t } = useTranslation()
const [searchText] = useSearchText()
const debouncedQuery = useDebounce(searchText, { wait: 500 })
const [searchTabParam, setSearchTab] = useSearchTab()
const searchTab = (searchTabParam || 'all') as SearchTab
const sort = useMarketplaceSortValue()
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
const pluginsParams = useMemo(() => {
if (!hasQuery)
return undefined
return {
query,
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
type: getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all),
} as PluginsSearchParams
}, [hasQuery, query, searchTab, sort])
const templatesParams = useMemo(() => {
if (!hasQuery)
return undefined
const { sort_by, sort_order } = mapSortForTemplates(sort)
return {
query,
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
sort_by,
sort_order,
}
}, [hasQuery, query, searchTab, sort])
const creatorsParams = useMemo(() => {
if (!hasQuery)
return undefined
const { sort_by, sort_order } = mapSortForCreators(sort)
return {
query,
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
sort_by,
sort_order,
}
}, [hasQuery, query, searchTab, sort])
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
const fetchCreators = searchTab === 'all' || searchTab === 'creators'
const pluginsQuery = useMarketplacePlugins(fetchPlugins ? pluginsParams : undefined)
const templatesQuery = useMarketplaceTemplates(fetchTemplates ? templatesParams : undefined)
const creatorsQuery = useMarketplaceCreators(fetchCreators ? creatorsParams : undefined)
const plugins = pluginsQuery.data?.pages.flatMap(p => p.plugins) ?? []
const pluginsTotal = pluginsQuery.data?.pages[0]?.total ?? 0
const templates = useMemo(
() => (templatesQuery.data?.pages.flatMap(p => p.templates) ?? []).map(mapTemplateDetailToTemplate),
[templatesQuery.data],
)
const templatesTotal = templatesQuery.data?.pages[0]?.total ?? 0
const creators = creatorsQuery.data?.pages.flatMap(p => p.creators) ?? []
const creatorsTotal = creatorsQuery.data?.pages[0]?.total ?? 0
const handleScrollLoadMore = useCallback(() => {
if (searchTab === 'plugins' && pluginsQuery.hasNextPage && !pluginsQuery.isFetching)
pluginsQuery.fetchNextPage()
else if (searchTab === 'templates' && templatesQuery.hasNextPage && !templatesQuery.isFetching)
templatesQuery.fetchNextPage()
else if (searchTab === 'creators' && creatorsQuery.hasNextPage && !creatorsQuery.isFetching)
creatorsQuery.fetchNextPage()
}, [searchTab, pluginsQuery, templatesQuery, creatorsQuery])
useMarketplaceContainerScroll(handleScrollLoadMore)
const tabOptions = [
{ value: 'all', text: t('marketplace.searchFilterAll', { ns: 'plugin' }), count: pluginsTotal + templatesTotal + creatorsTotal },
{ value: 'templates', text: t('templates', { ns: 'plugin' }), count: templatesTotal },
{ value: 'plugins', text: t('plugins', { ns: 'plugin' }), count: pluginsTotal },
{ value: 'creators', text: t('marketplace.searchFilterCreators', { ns: 'plugin' }), count: creatorsTotal },
]
const isLoading = (fetchPlugins && pluginsQuery.isLoading)
|| (fetchTemplates && templatesQuery.isLoading)
|| (fetchCreators && creatorsQuery.isLoading)
const isFetchingNextPage = pluginsQuery.isFetchingNextPage
|| templatesQuery.isFetchingNextPage
|| creatorsQuery.isFetchingNextPage
const renderPluginsSection = (items: Plugin[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(plugin => (
<CardWrapper key={`${plugin.org}/${plugin.name}`} plugin={plugin} showInstallButton={false} />
))}
</div>
)
}
const renderTemplatesSection = (items: Template[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(template => (
<div key={template.template_id}>
<TemplateCard template={template} />
</div>
))}
</div>
)
}
const renderCreatorsSection = (items: Creator[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(creator => (
<CreatorCard key={creator.unique_handle} creator={creator} />
))}
</div>
)
}
const renderAllTab = () => (
<div className="flex flex-col gap-8 py-4">
{templates.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('templates', { ns: 'plugin' })}
</h3>
{renderTemplatesSection(templates, 6)}
</section>
)}
{plugins.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('plugins', { ns: 'plugin' })}
</h3>
{renderPluginsSection(plugins, 6)}
</section>
)}
{creators.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('marketplace.searchFilterCreators', { ns: 'plugin' })}
</h3>
{renderCreatorsSection(creators, 6)}
</section>
)}
{!isLoading && plugins.length === 0 && templates.length === 0 && creators.length === 0 && (
<Empty />
)}
</div>
)
const renderPluginsTab = () => {
if (plugins.length === 0 && !pluginsQuery.isLoading)
return <Empty />
return (
<div className="py-4">
{renderPluginsSection(plugins)}
</div>
)
}
const renderTemplatesTab = () => {
if (templates.length === 0 && !templatesQuery.isLoading)
return <Empty />
return (
<div className="py-4">
{renderTemplatesSection(templates)}
</div>
)
}
const renderCreatorsTab = () => {
if (creators.length === 0 && !creatorsQuery.isLoading)
return <Empty />
return (
<div className="py-4">
{renderCreatorsSection(creators)}
</div>
)
}
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
<div className="mb-4 flex items-center justify-between pt-3">
<SegmentedControl
size="large"
activeState="accentLight"
value={searchTab}
onChange={v => setSearchTab(v as SearchTab)}
options={tabOptions}
/>
<SortDropdown />
</div>
{isLoading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)}
{!isLoading && (
<>
{searchTab === 'all' && renderAllTab()}
{searchTab === 'plugins' && renderPluginsTab()}
{searchTab === 'templates' && renderTemplatesTab()}
{searchTab === 'creators' && renderCreatorsTab()}
</>
)}
{isFetchingNextPage && <Loading className="my-3" />}
</div>
)
}
export default SearchPage

View File

@ -1,12 +1,13 @@
import type { ActivePluginType } from './constants'
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
export type CreationType = 'plugins' | 'templates' export type CreationType = 'plugins' | 'templates'
export const marketplaceSearchParamsParsers = { export const marketplaceSearchParamsParsers = {
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }), q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
creationType: parseAsStringEnum<CreationType>(['plugins', 'templates']).withDefault('plugins').withOptions({ history: 'replace' }), creationType: parseAsStringEnum<CreationType>(['plugins', 'templates']).withDefault('plugins').withOptions({ history: 'replace' }),
searchTab: parseAsStringEnum<SearchTab>(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }),
} }
export type SearchTab = 'all' | 'plugins' | 'templates' | 'creators' | ''

View File

@ -1,25 +1,29 @@
'use client' 'use client'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { useSearchPluginText } from './atoms' import { useSearchText } from './atoms'
const SearchResultsHeader = () => { type SearchResultsHeaderProps = {
marketplaceNav?: React.ReactNode
}
const SearchResultsHeader = ({ marketplaceNav }: SearchResultsHeaderProps) => {
const { t } = useTranslation('plugin') const { t } = useTranslation('plugin')
const [searchPluginText] = useSearchPluginText() const [searchText] = useSearchText()
return ( return (
<div className="px-12 py-4"> <div className="relative px-7 py-4">
<div className="flex items-center gap-1 system-xs-regular text-text-tertiary"> {marketplaceNav}
<div className="system-xs-regular mt-8 flex items-center gap-1 px-5 text-text-tertiary ">
<span>{t('marketplace.searchBreadcrumbMarketplace')}</span> <span>{t('marketplace.searchBreadcrumbMarketplace')}</span>
<span className="text-text-quaternary">/</span> <span className="text-text-quaternary">/</span>
<span>{t('marketplace.searchBreadcrumbSearch')}</span> <span>{t('marketplace.searchBreadcrumbSearch')}</span>
</div> </div>
<div className="mt-2 flex items-end gap-2"> <div className="mt-2 flex items-end gap-2 px-5 ">
<div className="title-4xl-semi-bold text-text-primary"> <div className="title-4xl-semi-bold text-text-primary">
{t('marketplace.searchResultsFor')} {t('marketplace.searchResultsFor')}
</div> </div>
<div className="relative title-4xl-semi-bold text-saas-dify-blue-accessible"> <div className="title-4xl-semi-bold relative text-saas-dify-blue-accessible">
<span className="relative z-10">{searchPluginText || ''}</span> <span className="relative z-10">{searchText || ''}</span>
<span className="absolute bottom-0 left-0 right-0 h-3 bg-saas-dify-blue-accessible opacity-10" /> <span className="absolute bottom-0 left-0 right-0 h-3 bg-saas-dify-blue-accessible opacity-10" />
</div> </div>
</div> </div>

View File

@ -2,24 +2,31 @@ import type { PluginsSearchParams, TemplateSearchParams } from './types'
import { useDebounce } from 'ahooks' import { useDebounce } from 'ahooks'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' import { useActivePluginCategory, useActiveTemplateCategory, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchText } from './atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants' import { CATEGORY_ALL } from './constants'
import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
import { getCollectionsParams, getMarketplaceListFilterType, mapTemplateDetailToTemplate } from './utils' import { getCollectionsParams, getPluginFilterType, mapTemplateDetailToTemplate } from './utils'
const getCategory = (category: string) => {
if (category === CATEGORY_ALL)
return undefined
return category
}
/** /**
* Hook for plugins marketplace data * Hook for plugins marketplace data
* Only fetches plugins-related data * Only fetches plugins-related data
*/ */
export function usePluginsMarketplaceData() { export function usePluginsMarketplaceData(enabled = true) {
const [searchPluginTextOriginal] = useSearchPluginText() const [searchTextOriginal] = useSearchText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) const searchText = useDebounce(searchTextOriginal, { wait: 500 })
const [filterPluginTags] = useFilterPluginTags() const [filterPluginTags] = useFilterPluginTags()
const [activePluginType] = useActivePluginType() const [activePluginCategory] = useActivePluginCategory()
const collectionsQuery = useMarketplaceCollectionsAndPlugins( const pluginsCollectionsQuery = useMarketplaceCollectionsAndPlugins(
getCollectionsParams(activePluginType), getCollectionsParams(activePluginCategory),
{ enabled },
) )
const sort = useMarketplaceSortValue() const sort = useMarketplaceSortValue()
@ -28,16 +35,16 @@ export function usePluginsMarketplaceData() {
if (!isSearchMode) if (!isSearchMode)
return undefined return undefined
return { return {
query: searchPluginText, query: searchText,
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, category: getCategory(activePluginCategory),
tags: filterPluginTags, tags: filterPluginTags,
sort_by: sort.sortBy, sort_by: sort.sortBy,
sort_order: sort.sortOrder, sort_order: sort.sortOrder,
type: getMarketplaceListFilterType(activePluginType), type: getPluginFilterType(activePluginCategory),
} }
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) }, [isSearchMode, searchText, activePluginCategory, filterPluginTags, sort])
const pluginsQuery = useMarketplacePlugins(queryParams) const pluginsQuery = useMarketplacePlugins(queryParams, { enabled })
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
const handlePageChange = useCallback(() => { const handlePageChange = useCallback(() => {
@ -49,12 +56,12 @@ export function usePluginsMarketplaceData() {
useMarketplaceContainerScroll(handlePageChange) useMarketplaceContainerScroll(handlePageChange)
return { return {
marketplaceCollections: collectionsQuery.data?.marketplaceCollections, pluginCollections: pluginsCollectionsQuery.data?.marketplaceCollections,
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, pluginCollectionPluginsMap: pluginsCollectionsQuery.data?.marketplaceCollectionPluginsMap,
plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
pluginsTotal: pluginsQuery.data?.pages[0]?.total, pluginsTotal: pluginsQuery.data?.pages[0]?.total,
page: pluginsQuery.data?.pages.length || 1, page: pluginsQuery.data?.pages.length || 1,
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, isLoading: pluginsCollectionsQuery.isLoading || pluginsQuery.isLoading,
isFetchingNextPage, isFetchingNextPage,
} }
} }
@ -63,20 +70,20 @@ export function usePluginsMarketplaceData() {
* Hook for templates marketplace data * Hook for templates marketplace data
* Only fetches templates-related data * Only fetches templates-related data
*/ */
export function useTemplatesMarketplaceData() { export function useTemplatesMarketplaceData(enabled = true) {
// Reuse existing atoms for search and sort // Reuse existing atoms for search and sort
const [searchTextOriginal] = useSearchPluginText() const [searchTextOriginal] = useSearchText()
const searchText = useDebounce(searchTextOriginal, { wait: 500 }) const searchText = useDebounce(searchTextOriginal, { wait: 500 })
const [activeCategory] = useActivePluginType() const [activeTemplateCategory] = useActiveTemplateCategory()
// Template collections query (for non-search mode) // Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates() const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
// Sort value // Sort value
const sort = useMarketplaceSortValue() const sort = useMarketplaceSortValue()
// Search mode: when there's search text or non-default category // Search mode: when there's search text or non-default category
const isSearchMode = !!searchText || (activeCategory !== PLUGIN_TYPE_SEARCH_MAP.all) const isSearchMode = useMarketplaceSearchMode()
// Build query params for search mode // Build query params for search mode
const queryParams = useMemo((): TemplateSearchParams | undefined => { const queryParams = useMemo((): TemplateSearchParams | undefined => {
@ -84,14 +91,14 @@ export function useTemplatesMarketplaceData() {
return undefined return undefined
return { return {
query: searchText, query: searchText,
categories: activeCategory === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : [activeCategory], categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
sort_by: sort.sortBy, sort_by: sort.sortBy,
sort_order: sort.sortOrder, sort_order: sort.sortOrder,
} }
}, [isSearchMode, searchText, activeCategory, sort]) }, [isSearchMode, searchText, activeTemplateCategory, sort])
// Templates search query (for search mode) // Templates search query (for search mode)
const templatesQuery = useMarketplaceTemplates(queryParams) const templatesQuery = useMarketplaceTemplates(queryParams, { enabled })
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery
// Pagination handler // Pagination handler
@ -103,93 +110,42 @@ export function useTemplatesMarketplaceData() {
// Scroll pagination // Scroll pagination
useMarketplaceContainerScroll(handlePageChange) useMarketplaceContainerScroll(handlePageChange)
// Compute flat templates list from collection map (for non-search mode)
const { collectionTemplates, collectionTemplatesTotal } = useMemo(() => {
const templateCollectionTemplatesMap = templateCollectionsQuery.data?.templateCollectionTemplatesMap
if (!templateCollectionTemplatesMap) {
return { collectionTemplates: undefined, collectionTemplatesTotal: 0 }
}
const allTemplates = Object.values(templateCollectionTemplatesMap).flat()
// Deduplicate templates by template_id
const uniqueTemplates = allTemplates.filter(
(template, index, self) => index === self.findIndex(t => t.template_id === template.template_id),
)
return { return {
collectionTemplates: uniqueTemplates, templateCollections: templateCollectionsQuery.data?.templateCollections,
collectionTemplatesTotal: uniqueTemplates.length, templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
} templates: templatesQuery.data?.pages.flatMap(page => page.templates).map(mapTemplateDetailToTemplate),
}, [templateCollectionsQuery.data?.templateCollectionTemplatesMap])
const searchTemplates = useMemo(() => {
const rawTemplates = templatesQuery.data?.pages.flatMap(page => page.templates) || []
return rawTemplates.map(mapTemplateDetailToTemplate)
}, [templatesQuery.data])
// Return search results when in search mode, otherwise return collection data
if (isSearchMode) {
return {
isSearchMode,
templateCollections: undefined,
templateCollectionTemplatesMap: undefined,
templates: searchTemplates,
templatesTotal: templatesQuery.data?.pages[0]?.total, templatesTotal: templatesQuery.data?.pages[0]?.total,
page: templatesQuery.data?.pages.length || 1, page: templatesQuery.data?.pages.length || 1,
isLoading: templatesQuery.isLoading, isLoading: templateCollectionsQuery.isLoading || templatesQuery.isLoading,
isFetchingNextPage, isFetchingNextPage,
} }
} }
return { type PluginsMarketplaceData = ReturnType<typeof usePluginsMarketplaceData>
isSearchMode, type TemplatesMarketplaceData = ReturnType<typeof useTemplatesMarketplaceData>
templateCollections: templateCollectionsQuery.data?.templateCollections, type MarketplaceData
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap, = ({ creationType: 'plugins' } & PluginsMarketplaceData)
templates: collectionTemplates, | ({ creationType: 'templates' } & TemplatesMarketplaceData)
templatesTotal: collectionTemplatesTotal,
page: 1,
isLoading: templateCollectionsQuery.isLoading,
isFetchingNextPage: false,
}
}
/** /**
* Main hook that routes to appropriate data based on creationType * Main hook that routes to appropriate data based on creationType
* Returns either plugins or templates data based on URL parameter * Returns either plugins or templates data based on URL parameter
*/ */
export function useMarketplaceData() { export function useMarketplaceData(): MarketplaceData {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const creationType = (searchParams.get('creationType') || 'plugins') as 'plugins' | 'templates' const creationType = (searchParams.get('creationType') || 'plugins') as 'plugins' | 'templates'
const pluginsData = usePluginsMarketplaceData() const pluginsData = usePluginsMarketplaceData(creationType === 'plugins')
const templatesData = useTemplatesMarketplaceData() const templatesData = useTemplatesMarketplaceData(creationType === 'templates')
if (creationType === 'templates') { if (creationType === 'templates') {
return { return {
creationType, creationType,
isSearchMode: templatesData.isSearchMode, ...templatesData,
// Templates-specific fields
templateCollections: templatesData.templateCollections,
templateCollectionTemplatesMap: templatesData.templateCollectionTemplatesMap,
templates: templatesData.templates,
templatesTotal: templatesData.templatesTotal,
page: templatesData.page,
isLoading: templatesData.isLoading,
isFetchingNextPage: templatesData.isFetchingNextPage,
} }
} }
// Default: plugins
return { return {
creationType, creationType,
isSearchMode: false, // plugins uses useMarketplaceSearchMode separately ...pluginsData,
// Plugins-specific fields
marketplaceCollections: pluginsData.marketplaceCollections,
marketplaceCollectionPluginsMap: pluginsData.marketplaceCollectionPluginsMap,
plugins: pluginsData.plugins,
pluginsTotal: pluginsData.pluginsTotal,
page: pluginsData.page,
isLoading: pluginsData.isLoading,
isFetchingNextPage: pluginsData.isFetchingNextPage,
} }
} }

View File

@ -0,0 +1,78 @@
'use client'
import { useTranslation } from '#i18n'
import { RiFileList3Line } from '@remixicon/react'
import { useActiveTemplateCategory } from './atoms'
import CategorySwitch from './category-switch'
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from './constants'
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
type TemplateCategorySwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const TemplateCategorySwitch = ({
className,
variant = 'default',
}: TemplateCategorySwitchProps) => {
const { t } = useTranslation()
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
const isHeroVariant = variant === 'hero'
const options = [
{
value: CATEGORY_ALL,
text: t('marketplace.templateCategory.all', { ns: 'plugin' }),
icon: isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
},
{
value: TEMPLATE_CATEGORY_MAP.marketing,
text: t('marketplace.templateCategory.marketing', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.sales,
text: t('marketplace.templateCategory.sales', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.support,
text: t('marketplace.templateCategory.support', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.operations,
text: t('marketplace.templateCategory.operations', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.it,
text: t('marketplace.templateCategory.it', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.knowledge,
text: t('marketplace.templateCategory.knowledge', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.design,
text: t('marketplace.templateCategory.design', { ns: 'plugin' }),
icon: null,
},
]
return (
<CategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
)
}
export default TemplateCategorySwitch

View File

@ -6,7 +6,7 @@ export type SearchParamsFromCollection = {
sort_order?: string sort_order?: string
} }
export type MarketplaceCollection = { export type PluginCollection = {
name: string name: string
label: Record<string, string> label: Record<string, string>
description: Record<string, string> description: Record<string, string>
@ -18,7 +18,7 @@ export type MarketplaceCollection = {
} }
export type MarketplaceCollectionsResponse = { export type MarketplaceCollectionsResponse = {
collections: MarketplaceCollection[] collections: PluginCollection[]
total: number total: number
} }
@ -184,3 +184,52 @@ export type TemplateSearchParams = {
sort_order?: string sort_order?: string
languages?: string[] languages?: string[]
} }
// Unified search types
export type UnifiedSearchScope = 'creators' | 'organizations' | 'plugins' | 'templates'
export type UnifiedSearchParams = {
query: string
scope?: UnifiedSearchScope[]
page?: number
page_size?: number
}
// Plugin item shape from /search/unified (superset of Plugin with index_id)
export type UnifiedPluginItem = Plugin & {
index_id: string
}
// Template item shape from /search/unified (differs from TemplateDetail)
export type UnifiedTemplateItem = {
id: string
index_id: string
template_name: string
icon: string
icon_file_key: string
categories: string[]
overview: string
readme: string
partner_link: string
publisher_handle: string
publisher_type: 'individual' | 'organization'
status: string
usage_count: number
created_at: string
updated_at: string
}
// Creator item shape from /search/unified (superset of Creator with index_id)
export type UnifiedCreatorItem = Creator & {
index_id: string
}
export type UnifiedSearchResponse = {
data: {
creators: { items: UnifiedCreatorItem[], total: number }
organizations: { items: unknown[], total: number }
plugins: { items: UnifiedPluginItem[], total: number }
templates: { items: UnifiedTemplateItem[], total: number }
}
}

View File

@ -1,12 +1,19 @@
import type { ActivePluginType } from './constants' import type { ActivePluginType } from './constants'
import type { import type {
CollectionsAndPluginsSearchParams, CollectionsAndPluginsSearchParams,
MarketplaceCollection, Creator,
CreatorSearchParams,
PluginCollection,
PluginsSearchParams, PluginsSearchParams,
Template, Template,
TemplateCollection, TemplateCollection,
TemplateDetail, TemplateDetail,
TemplateSearchParams, TemplateSearchParams,
UnifiedCreatorItem,
UnifiedPluginItem,
UnifiedSearchParams,
UnifiedSearchResponse,
UnifiedTemplateItem,
} from '@/app/components/plugins/marketplace/types' } from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types' import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -21,12 +28,21 @@ type MarketplaceFetchOptions = {
signal?: AbortSignal signal?: AbortSignal
} }
/** Get a string key from an item by field name (e.g. plugin_id, template_id). */
export function getItemKeyByField<T>(item: T, field: keyof T): string {
return String((item as Record<string, unknown>)[field as string])
}
export const getPluginIconInMarketplace = (plugin: Plugin) => { export const getPluginIconInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle') if (plugin.type === 'bundle')
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon` return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
} }
export const getCreatorAvatarUrl = (uniqueHandle: string) => {
return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar`
}
export const getFormattedPlugin = (bundle: Plugin): Plugin => { export const getFormattedPlugin = (bundle: Plugin): Plugin => {
if (bundle.type === 'bundle') { if (bundle.type === 'bundle') {
return { return {
@ -63,7 +79,7 @@ export const getMarketplacePluginsByCollectionId = async (
let plugins: Plugin[] = [] let plugins: Plugin[] = []
try { try {
const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({ const marketplaceCollectionPluginsDataJson = await marketplaceClient.plugins.collectionPlugins({
params: { params: {
collectionId, collectionId,
}, },
@ -85,10 +101,10 @@ export const getMarketplaceCollectionsAndPlugins = async (
query?: CollectionsAndPluginsSearchParams, query?: CollectionsAndPluginsSearchParams,
options?: MarketplaceFetchOptions, options?: MarketplaceFetchOptions,
) => { ) => {
let marketplaceCollections: MarketplaceCollection[] = [] let pluginCollections: PluginCollection[] = []
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} let pluginCollectionPluginsMap: Record<string, Plugin[]> = {}
try { try {
const marketplaceCollectionsDataJson = await marketplaceClient.collections({ const collectionsDataJson = await marketplaceClient.plugins.collections({
query: { query: {
...query, ...query,
page: 1, page: 1,
@ -97,22 +113,22 @@ export const getMarketplaceCollectionsAndPlugins = async (
}, { }, {
signal: options?.signal, signal: options?.signal,
}) })
marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] pluginCollections = collectionsDataJson.data?.collections || []
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => { await Promise.all(pluginCollections.map(async (collection: PluginCollection) => {
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
marketplaceCollectionPluginsMap[collection.name] = plugins pluginCollectionPluginsMap[collection.name] = plugins
})) }))
} }
// eslint-disable-next-line unused-imports/no-unused-vars // eslint-disable-next-line unused-imports/no-unused-vars
catch (e) { catch (e) {
marketplaceCollections = [] pluginCollections = []
marketplaceCollectionPluginsMap = {} pluginCollectionPluginsMap = {}
} }
return { return {
marketplaceCollections, marketplaceCollections: pluginCollections,
marketplaceCollectionPluginsMap, marketplaceCollectionPluginsMap: pluginCollectionPluginsMap,
} }
} }
@ -202,7 +218,7 @@ export const getMarketplacePlugins = async (
} = queryParams } = queryParams
try { try {
const res = await marketplaceClient.searchAdvanced({ const res = await marketplaceClient.plugins.searchAdvanced({
params: { params: {
kind: type === 'bundle' ? 'bundles' : 'plugins', kind: type === 'bundle' ? 'bundles' : 'plugins',
}, },
@ -235,7 +251,7 @@ export const getMarketplacePlugins = async (
} }
} }
export const getMarketplaceListCondition = (pluginType: string) => { export const getPluginCondition = (pluginType: string) => {
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
return `category=${pluginType}` return `category=${pluginType}`
@ -248,7 +264,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
return '' return ''
} }
export const getMarketplaceListFilterType = (category: ActivePluginType) => { export const getPluginFilterType = (category: ActivePluginType) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all) if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined return undefined
@ -264,8 +280,8 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
} }
return { return {
category, category,
condition: getMarketplaceListCondition(category), condition: getPluginCondition(category),
type: getMarketplaceListFilterType(category), type: getPluginFilterType(category),
} }
} }
@ -326,3 +342,192 @@ export const getMarketplaceTemplates = async (
} }
} }
} }
export const getMarketplaceCreators = async (
queryParams: CreatorSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
): Promise<{
creators: Creator[]
total: number
page: number
page_size: number
}> => {
if (!queryParams) {
return {
creators: [],
total: 0,
page: 1,
page_size: 40,
}
}
const {
query,
sort_by,
sort_order,
categories,
page_size = 40,
} = queryParams
try {
const res = await marketplaceClient.creators.searchAdvanced({
body: {
page: pageParam,
page_size,
query,
sort_by,
sort_order,
categories,
},
}, { signal })
const creators = (res.data?.creators || []).map((c: Creator) => ({
...c,
display_name: c.display_name || c.name,
display_email: c.display_email ?? '',
social_links: c.social_links ?? [],
}))
return {
creators,
total: res.data?.total || 0,
page: pageParam,
page_size,
}
}
catch {
return {
creators: [],
total: 0,
page: pageParam,
page_size,
}
}
}
/**
* Map unified search plugin item to Plugin type
*/
export function mapUnifiedPluginToPlugin(item: UnifiedPluginItem): Plugin {
return {
type: item.type,
org: item.org,
name: item.name,
plugin_id: item.plugin_id,
version: item.latest_version,
latest_version: item.latest_version,
latest_package_identifier: item.latest_package_identifier,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${item.org}/${item.name}/icon`,
verified: item.verification?.authorized_category === 'langgenius',
label: item.label,
brief: item.brief,
description: item.brief,
introduction: '',
repository: item.repository || '',
category: item.category as PluginCategoryEnum,
install_count: item.install_count,
endpoint: { settings: [] },
tags: item.tags || [],
badges: item.badges || [],
verification: item.verification,
from: 'marketplace',
}
}
/**
* Map unified search template item to Template type
*/
export function mapUnifiedTemplateToTemplate(item: UnifiedTemplateItem): Template {
const descriptionText = item.overview || item.readme || ''
return {
template_id: item.id,
name: item.template_name,
description: {
en_US: descriptionText,
zh_Hans: descriptionText,
},
icon: item.icon || '',
tags: item.categories || [],
author: item.publisher_handle || '',
created_at: item.created_at,
updated_at: item.updated_at,
}
}
/**
* Map unified search creator item to Creator type
*/
export function mapUnifiedCreatorToCreator(item: UnifiedCreatorItem): Creator {
return {
email: item.email || '',
name: item.name || '',
display_name: item.display_name || item.name || '',
unique_handle: item.unique_handle || '',
display_email: '',
description: item.description || '',
avatar: item.avatar || '',
social_links: [],
status: item.status || 'active',
plugin_count: item.plugin_count,
template_count: item.template_count,
created_at: '',
updated_at: '',
}
}
/**
* Fetch unified search results
*/
export const getMarketplaceUnifiedSearch = async (
queryParams: UnifiedSearchParams | undefined,
signal?: AbortSignal,
): Promise<UnifiedSearchResponse['data'] & { page: number, page_size: number }> => {
if (!queryParams || !queryParams.query.trim()) {
return {
creators: { items: [], total: 0 },
organizations: { items: [], total: 0 },
plugins: { items: [], total: 0 },
templates: { items: [], total: 0 },
page: 1,
page_size: queryParams?.page_size || 10,
}
}
const {
query,
scope,
page = 1,
page_size = 10,
} = queryParams
try {
const res = await marketplaceClient.searchUnified({
body: {
query,
scope,
page,
page_size,
},
}, { signal })
return {
creators: res.data?.creators || { items: [], total: 0 },
organizations: res.data?.organizations || { items: [], total: 0 },
plugins: res.data?.plugins || { items: [], total: 0 },
templates: res.data?.templates || { items: [], total: 0 },
page,
page_size,
}
}
catch {
return {
creators: { items: [], total: 0 },
organizations: { items: [], total: 0 },
plugins: { items: [], total: 0 },
templates: { items: [], total: 0 },
page,
page_size,
}
}
}

View File

@ -9,11 +9,11 @@ import {
useMarketplaceCollectionsAndPlugins, useMarketplaceCollectionsAndPlugins,
useMarketplacePlugins, useMarketplacePlugins,
} from '@/app/components/plugins/marketplace/hooks' } from '@/app/components/plugins/marketplace/hooks'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useAllToolProviders } from '@/service/use-tools' import { useAllToolProviders } from '@/service/use-tools'
export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => { export const useMarketplace = (searchText: string, filterPluginTags: string[]) => {
const { data: toolProvidersData, isSuccess } = useAllToolProviders() const { data: toolProvidersData, isSuccess } = useAllToolProviders()
const exclude = useMemo(() => { const exclude = useMemo(() => {
if (isSuccess) if (isSuccess)
@ -21,8 +21,8 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
}, [isSuccess, toolProvidersData]) }, [isSuccess, toolProvidersData])
const { const {
isLoading, isLoading,
marketplaceCollections, pluginCollections,
marketplaceCollectionPluginsMap, pluginCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins, queryMarketplaceCollectionsAndPlugins,
} = useMarketplaceCollectionsAndPlugins() } = useMarketplaceCollectionsAndPlugins()
const { const {
@ -35,19 +35,19 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
hasNextPage, hasNextPage,
page: pluginsPage, page: pluginsPage,
} = useMarketplacePlugins() } = useMarketplacePlugins()
const searchPluginTextRef = useRef(searchPluginText) const searchTextRef = useRef(searchText)
const filterPluginTagsRef = useRef(filterPluginTags) const filterPluginTagsRef = useRef(filterPluginTags)
useEffect(() => { useEffect(() => {
searchPluginTextRef.current = searchPluginText searchTextRef.current = searchText
filterPluginTagsRef.current = filterPluginTags filterPluginTagsRef.current = filterPluginTags
}, [searchPluginText, filterPluginTags]) }, [searchText, filterPluginTags])
useEffect(() => { useEffect(() => {
if ((searchPluginText || filterPluginTags.length) && isSuccess) { if ((searchText || filterPluginTags.length) && isSuccess) {
if (searchPluginText) { if (searchText) {
queryPluginsWithDebounced({ queryPluginsWithDebounced({
category: PluginCategoryEnum.tool, category: PluginCategoryEnum.tool,
query: searchPluginText, query: searchText,
tags: filterPluginTags, tags: filterPluginTags,
exclude, exclude,
type: 'plugin', type: 'plugin',
@ -56,7 +56,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
} }
queryPlugins({ queryPlugins({
category: PluginCategoryEnum.tool, category: PluginCategoryEnum.tool,
query: searchPluginText, query: searchText,
tags: filterPluginTags, tags: filterPluginTags,
exclude, exclude,
type: 'plugin', type: 'plugin',
@ -66,14 +66,14 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
if (isSuccess) { if (isSuccess) {
queryMarketplaceCollectionsAndPlugins({ queryMarketplaceCollectionsAndPlugins({
category: PluginCategoryEnum.tool, category: PluginCategoryEnum.tool,
condition: getMarketplaceListCondition(PluginCategoryEnum.tool), condition: getPluginCondition(PluginCategoryEnum.tool),
exclude, exclude,
type: 'plugin', type: 'plugin',
}) })
resetPlugins() resetPlugins()
} }
} }
}, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess]) }, [searchText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
const handleScroll = useCallback((e: Event) => { const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement const target = e.target as HTMLDivElement
@ -83,17 +83,17 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
clientHeight, clientHeight,
} = target } = target
if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) { if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) {
const searchPluginText = searchPluginTextRef.current const searchText = searchTextRef.current
const filterPluginTags = filterPluginTagsRef.current const filterPluginTags = filterPluginTagsRef.current
if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length)) if (hasNextPage && (!!searchText || !!filterPluginTags.length))
fetchNextPage() fetchNextPage()
} }
}, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins]) }, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins])
return { return {
isLoading: isLoading || isPluginsLoading, isLoading: isLoading || isPluginsLoading,
marketplaceCollections, pluginCollections,
marketplaceCollectionPluginsMap, pluginCollectionPluginsMap,
plugins, plugins,
handleScroll, handleScroll,
page: Math.max(pluginsPage || 0, 1), page: Math.max(pluginsPage || 0, 1),

View File

@ -15,8 +15,8 @@ import Marketplace from './index'
const listRenderSpy = vi.fn() const listRenderSpy = vi.fn()
vi.mock('@/app/components/plugins/marketplace/list', () => ({ vi.mock('@/app/components/plugins/marketplace/list', () => ({
default: (props: { default: (props: {
marketplaceCollections: unknown[] pluginCollections: unknown[]
marketplaceCollectionPluginsMap: Record<string, unknown[]> pluginCollectionPluginsMap: Record<string, unknown[]>
plugins?: unknown[] plugins?: unknown[]
showInstallButton?: boolean showInstallButton?: boolean
}) => { }) => {
@ -90,8 +90,8 @@ const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({ const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({
isLoading: false, isLoading: false,
marketplaceCollections: [], pluginCollections: [],
marketplaceCollectionPluginsMap: {}, pluginCollectionPluginsMap: {},
plugins: [], plugins: [],
handleScroll: vi.fn(), handleScroll: vi.fn(),
page: 1, page: 1,
@ -110,7 +110,7 @@ describe('Marketplace', () => {
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 }) const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
render( render(
<Marketplace <Marketplace
searchPluginText="" searchText=""
filterPluginTags={[]} filterPluginTags={[]}
isMarketplaceArrowVisible={false} isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()} showMarketplacePanel={vi.fn()}
@ -131,7 +131,7 @@ describe('Marketplace', () => {
}) })
render( render(
<Marketplace <Marketplace
searchPluginText="" searchText=""
filterPluginTags={[]} filterPluginTags={[]}
isMarketplaceArrowVisible={false} isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()} showMarketplacePanel={vi.fn()}
@ -156,7 +156,7 @@ describe('Marketplace', () => {
const showMarketplacePanel = vi.fn() const showMarketplacePanel = vi.fn()
const { container } = render( const { container } = render(
<Marketplace <Marketplace
searchPluginText="vector" searchText="vector"
filterPluginTags={['tag-a', 'tag-b']} filterPluginTags={['tag-a', 'tag-b']}
isMarketplaceArrowVisible isMarketplaceArrowVisible
showMarketplacePanel={showMarketplacePanel} showMarketplacePanel={showMarketplacePanel}
@ -199,8 +199,8 @@ describe('useMarketplace', () => {
}) => { }) => {
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({ mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
isLoading: overrides?.isLoading ?? false, isLoading: overrides?.isLoading ?? false,
marketplaceCollections: [], pluginCollections: [],
marketplaceCollectionPluginsMap: {}, pluginCollectionPluginsMap: {},
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins, queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
}) })
mockUseMarketplacePlugins.mockReturnValue({ mockUseMarketplacePlugins.mockReturnValue({

View File

@ -11,14 +11,14 @@ import List from '@/app/components/plugins/marketplace/list'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
type MarketplaceProps = { type MarketplaceProps = {
searchPluginText: string searchText: string
filterPluginTags: string[] filterPluginTags: string[]
isMarketplaceArrowVisible: boolean isMarketplaceArrowVisible: boolean
showMarketplacePanel: () => void showMarketplacePanel: () => void
marketplaceContext: ReturnType<typeof useMarketplace> marketplaceContext: ReturnType<typeof useMarketplace>
} }
const Marketplace = ({ const Marketplace = ({
searchPluginText, searchText,
filterPluginTags, filterPluginTags,
isMarketplaceArrowVisible, isMarketplaceArrowVisible,
showMarketplacePanel, showMarketplacePanel,
@ -29,8 +29,8 @@ const Marketplace = ({
const { theme } = useTheme() const { theme } = useTheme()
const { const {
isLoading, isLoading,
marketplaceCollections, pluginCollections,
marketplaceCollectionPluginsMap, pluginCollectionPluginsMap,
plugins, plugins,
page, page,
} = marketplaceContext } = marketplaceContext
@ -79,7 +79,7 @@ const Marketplace = ({
</span> </span>
{t('operation.in', { ns: 'common' })} {t('operation.in', { ns: 'common' })}
<a <a
href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })} href={getMarketplaceUrl('', { language: locale, q: searchText, tags: filterPluginTags.join(','), theme })}
className="system-sm-medium ml-1 flex items-center text-text-accent" className="system-sm-medium ml-1 flex items-center text-text-accent"
target="_blank" target="_blank"
> >
@ -100,8 +100,8 @@ const Marketplace = ({
{ {
(!isLoading || page > 1) && ( (!isLoading || page > 1) && (
<List <List
marketplaceCollections={marketplaceCollections || []} pluginCollections={pluginCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} pluginCollectionPluginsMap={pluginCollectionPluginsMap || {}}
plugins={plugins} plugins={plugins}
showInstallButton showInstallButton
/> />

View File

@ -199,7 +199,7 @@ const ProviderList = () => {
<div ref={toolListTailRef} /> <div ref={toolListTailRef} />
{enable_marketplace && activeTab === 'builtin' && ( {enable_marketplace && activeTab === 'builtin' && (
<Marketplace <Marketplace
searchPluginText={keywords} searchText={keywords}
filterPluginTags={tagFilterValue} filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible} isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel} showMarketplacePanel={showMarketplacePanel}

View File

@ -14,6 +14,8 @@ import type {
TemplateDetail, TemplateDetail,
TemplateSearchParams, TemplateSearchParams,
TemplatesListResponse, TemplatesListResponse,
UnifiedSearchParams,
UnifiedSearchResponse,
} from '@/app/components/plugins/marketplace/types' } from '@/app/components/plugins/marketplace/types'
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import { type } from '@orpc/contract' import { type } from '@orpc/contract'
@ -366,6 +368,20 @@ export const searchTemplatesAdvancedContract = base
}>(), }>(),
) )
export const searchUnifiedContract = base
.route({
path: '/search/unified',
method: 'POST',
})
.input(
type<{
body: UnifiedSearchParams
}>(),
)
.output(
type<UnifiedSearchResponse>(),
)
export const getPublisherTemplatesContract = base export const getPublisherTemplatesContract = base
.route({ .route({
path: '/templates/publisher/{uniqueHandle}', path: '/templates/publisher/{uniqueHandle}',
@ -387,3 +403,25 @@ export const getPublisherTemplatesContract = base
data?: TemplatesListResponse data?: TemplatesListResponse
}>(), }>(),
) )
export const getPublisherPluginsContract = base
.route({
path: '/plugins/publisher/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
query?: {
page?: number
page_size?: number
}
}>(),
)
.output(
type<{
data?: PluginsFromMarketplaceResponse
}>(),
)

View File

@ -30,6 +30,7 @@ import {
getCollectionTemplatesContract, getCollectionTemplatesContract,
getCreatorAvatarContract, getCreatorAvatarContract,
getCreatorByHandleContract, getCreatorByHandleContract,
getPublisherPluginsContract,
getPublisherTemplatesContract, getPublisherTemplatesContract,
getTemplateByIdContract, getTemplateByIdContract,
getTemplateCollectionContract, getTemplateCollectionContract,
@ -39,15 +40,20 @@ import {
searchCreatorsAdvancedContract, searchCreatorsAdvancedContract,
searchTemplatesAdvancedContract, searchTemplatesAdvancedContract,
searchTemplatesBasicContract, searchTemplatesBasicContract,
searchUnifiedContract,
syncCreatorAvatarContract, syncCreatorAvatarContract,
syncCreatorProfileContract, syncCreatorProfileContract,
templateCollectionsContract, templateCollectionsContract,
} from './marketplace' } from './marketplace'
export const marketplaceRouterContract = { export const marketplaceRouterContract = {
plugins: {
collections: collectionsContract, collections: collectionsContract,
collectionPlugins: collectionPluginsContract, collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract, searchAdvanced: searchAdvancedContract,
getPublisherPlugins: getPublisherPluginsContract,
},
searchUnified: searchUnifiedContract,
templateCollections: { templateCollections: {
list: templateCollectionsContract, list: templateCollectionsContract,
create: createTemplateCollectionContract, create: createTemplateCollectionContract,

View File

@ -223,6 +223,14 @@
"marketplace.sortOption.recentlyUpdated": "Recently Updated", "marketplace.sortOption.recentlyUpdated": "Recently Updated",
"marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.", "marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.",
"marketplace.templatesHeroTitle": "Create. Remix. Deploy.", "marketplace.templatesHeroTitle": "Create. Remix. Deploy.",
"marketplace.templateCategory.all": "All",
"marketplace.templateCategory.marketing": "Marketing",
"marketplace.templateCategory.sales": "Sales",
"marketplace.templateCategory.support": "Support",
"marketplace.templateCategory.operations": "Operations",
"marketplace.templateCategory.it": "IT",
"marketplace.templateCategory.knowledge": "Knowledge",
"marketplace.templateCategory.design": "Design",
"marketplace.verifiedTip": "Verified by Dify", "marketplace.verifiedTip": "Verified by Dify",
"marketplace.viewMore": "View more", "marketplace.viewMore": "View more",
"metadata.title": "Plugins", "metadata.title": "Plugins",

View File

@ -223,6 +223,14 @@
"marketplace.sortOption.recentlyUpdated": "最近更新", "marketplace.sortOption.recentlyUpdated": "最近更新",
"marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。", "marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。",
"marketplace.templatesHeroTitle": "创建。复刻。部署。", "marketplace.templatesHeroTitle": "创建。复刻。部署。",
"marketplace.templateCategory.all": "全部",
"marketplace.templateCategory.marketing": "营销",
"marketplace.templateCategory.sales": "销售",
"marketplace.templateCategory.support": "支持",
"marketplace.templateCategory.operations": "运营",
"marketplace.templateCategory.it": "IT",
"marketplace.templateCategory.knowledge": "知识",
"marketplace.templateCategory.design": "设计",
"marketplace.verifiedTip": "此插件由 Dify 认证", "marketplace.verifiedTip": "此插件由 Dify 认证",
"marketplace.viewMore": "查看更多", "marketplace.viewMore": "查看更多",
"metadata.title": "插件", "metadata.title": "插件",