mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
refactor: update marketplace components to use unified terminology and improve search functionality
This commit is contained in:
parent
984992d0fd
commit
b241122cf7
@ -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} />
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
66
web/app/components/plugins/marketplace/category-switch.tsx
Normal file
66
web/app/components/plugins/marketplace/category-switch.tsx
Normal 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
|
||||||
@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -43,7 +43,7 @@ const CardWrapperComponent = ({
|
|||||||
if (showInstallButton) {
|
if (showInstallButton) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
key={plugin.name}
|
key={plugin.name}
|
||||||
|
|||||||
213
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
213
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal 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
|
||||||
55
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
55
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal 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
|
||||||
@ -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}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1360,19 +1361,20 @@ describe('Accessibility', () => {
|
|||||||
expect(headings.length).toBeGreaterThan(0)
|
expect(headings.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
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}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1381,18 +1383,18 @@ describe('Accessibility', () => {
|
|||||||
expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer')
|
expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have proper grid layout for cards', () => {
|
it('should have proper grid layout for cards', () => {
|
||||||
const plugins = createMockPluginList(4)
|
const plugins = createMockPluginList(4)
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -1,134 +1,82 @@
|
|||||||
'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,
|
|
||||||
showInstallButton,
|
|
||||||
cardContainerClassName,
|
|
||||||
cardRender,
|
|
||||||
}: ListWithCollectionProps) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const locale = useLocale()
|
|
||||||
const onMoreClick = useMarketplaceMoreClick()
|
|
||||||
|
|
||||||
const renderPluginCard = (plugin: Plugin) => {
|
const ListWithCollection = (props: ListWithCollectionProps) => {
|
||||||
if (cardRender)
|
const { variant, cardContainerClassName } = props
|
||||||
return cardRender(plugin)
|
|
||||||
|
if (variant === 'plugins') {
|
||||||
|
const {
|
||||||
|
collections,
|
||||||
|
collectionItemsMap,
|
||||||
|
showInstallButton,
|
||||||
|
cardRender,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const renderPluginCard = (plugin: Plugin) => {
|
||||||
|
if (cardRender)
|
||||||
|
return cardRender(plugin)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardWrapper
|
||||||
|
plugin={plugin}
|
||||||
|
showInstallButton={showInstallButton}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper
|
<CollectionList
|
||||||
plugin={plugin}
|
collections={collections}
|
||||||
showInstallButton={showInstallButton}
|
collectionItemsMap={collectionItemsMap}
|
||||||
|
itemKeyField="plugin_id"
|
||||||
|
renderCard={renderPluginCard}
|
||||||
|
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.partners]}
|
||||||
|
cardContainerClassName={cardContainerClassName}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPartnersCarousel = (collection: MarketplaceCollection, plugins: Plugin[]) => {
|
const { collections, collectionItemsMap } = props
|
||||||
// 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 (
|
const renderTemplateCard = (template: Template) => (
|
||||||
<Carousel
|
<TemplateCard template={template} />
|
||||||
className={cardContainerClassName}
|
)
|
||||||
showNavigation={plugins.length > 8}
|
|
||||||
showPagination={plugins.length > 8}
|
|
||||||
autoPlay={plugins.length > 8}
|
|
||||||
autoPlayInterval={5000}
|
|
||||||
>
|
|
||||||
{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[]) => {
|
|
||||||
// Other collections: responsive grid
|
|
||||||
const displayPlugins = plugins.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">
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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,
|
const isPluginView = creationType === 'plugins'
|
||||||
} = marketplaceData
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!isPluginView) {
|
||||||
|
const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
|
||||||
|
if (templates !== undefined) {
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
variant="templates"
|
||||||
|
items={templates}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListWithCollection
|
||||||
|
variant="templates"
|
||||||
|
collections={templateCollections || []}
|
||||||
|
collectionItemsMap={templateCollectionTemplatesMap || {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
|
||||||
|
if (plugins !== undefined) {
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
variant="plugins"
|
||||||
|
items={plugins}
|
||||||
|
showInstallButton={showInstallButton}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Templates view
|
|
||||||
if (creationType === 'templates') {
|
|
||||||
const {
|
|
||||||
templateCollections,
|
|
||||||
templateCollectionTemplatesMap,
|
|
||||||
templates,
|
|
||||||
isSearchMode: isTemplateSearchMode,
|
|
||||||
} = marketplaceData
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ListWithCollection
|
||||||
style={{ scrollbarGutter: 'stable' }}
|
variant="plugins"
|
||||||
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
collections={pluginCollections || []}
|
||||||
>
|
collectionItemsMap={pluginCollectionPluginsMap || {}}
|
||||||
{
|
showInstallButton={showInstallButton}
|
||||||
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
|
<div
|
||||||
style={{ scrollbarGutter: 'stable' }}
|
style={{ scrollbarGutter: 'stable' }}
|
||||||
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
||||||
>
|
>
|
||||||
{plugins && !isSearchMode && (
|
{isLoading && page === 1 && (
|
||||||
<div className="mb-4 flex items-center pt-3">
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
|
<Loading />
|
||||||
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
|
||||||
<SortDropdown />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSearchMode && (
|
{(!isLoading || page > 1) && renderContent()}
|
||||||
<div className="mb-4 flex items-center justify-between pt-3">
|
{isFetchingNextPage && <Loading className="my-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) && (
|
|
||||||
<List
|
|
||||||
marketplaceCollections={marketplaceCollections || []}
|
|
||||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
|
||||||
plugins={plugins}
|
|
||||||
showInstallButton={showInstallButton}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
isFetchingNextPage && (
|
|
||||||
<Loading className="my-3" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
||||||
@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
|
||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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,66 +73,119 @@ 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' })}
|
{plugins.map((plugin) => {
|
||||||
</div>
|
const title = getValueFromI18nObject(plugin.label) || plugin.name
|
||||||
<div className="flex flex-col">
|
const description = getValueFromI18nObject(plugin.brief) || ''
|
||||||
{plugins.map((plugin) => {
|
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
|
||||||
const title = getValueFromI18nObject(plugin.label) || plugin.name
|
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
|
||||||
const description = getValueFromI18nObject(plugin.brief) || ''
|
const author = plugin.org || plugin.author || ''
|
||||||
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
|
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
|
||||||
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
|
const categoryNode = (
|
||||||
const author = plugin.org || plugin.author || ''
|
<div className="flex items-center gap-1">
|
||||||
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
|
{TypeIcon && <TypeIcon className="h-4 w-4 text-text-tertiary" />}
|
||||||
return (
|
<span>{categoryLabel}</span>
|
||||||
<a
|
</div>
|
||||||
key={`${plugin.org}/${plugin.name}`}
|
)
|
||||||
className={cn(
|
return (
|
||||||
'flex gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover',
|
<DropdownItem
|
||||||
)}
|
key={`${plugin.org}/${plugin.name}`}
|
||||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||||
>
|
icon={(
|
||||||
<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">
|
<IconBox shape="rounded-lg">
|
||||||
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
|
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
|
||||||
</div>
|
</IconBox>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
)}
|
||||||
<div className="system-sm-medium truncate text-text-primary">{title}</div>
|
>
|
||||||
{!!description && (
|
<div className="system-sm-medium truncate text-text-primary">{title}</div>
|
||||||
<div className="system-xs-regular truncate text-text-tertiary">{description}</div>
|
{!!description && (
|
||||||
)}
|
<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">
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<ItemMeta
|
||||||
{TypeIcon && <TypeIcon className="h-4 w-4 text-text-tertiary" />}
|
items={[
|
||||||
<span className="system-xs-regular">{categoryLabel}</span>
|
categoryNode,
|
||||||
</div>
|
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
|
||||||
<span className="system-xs-regular">·</span>
|
installLabel,
|
||||||
<span className="system-xs-regular">
|
]}
|
||||||
{t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author })}
|
/>
|
||||||
</span>
|
</DropdownItem>
|
||||||
<span className="system-xs-regular">·</span>
|
)
|
||||||
<span className="system-xs-regular">{installLabel}</span>
|
})}
|
||||||
</div>
|
</DropdownSection>
|
||||||
</div>
|
)}
|
||||||
</a>
|
|
||||||
)
|
{templates.length > 0 && (
|
||||||
})}
|
<DropdownSection title={t('templates', { ns: 'plugin' })}>
|
||||||
</div>
|
{templates.map(template => (
|
||||||
</div>
|
<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>
|
||||||
|
{!!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>
|
||||||
|
|||||||
@ -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
|
||||||
266
web/app/components/plugins/marketplace/search-page/index.tsx
Normal file
266
web/app/components/plugins/marketplace/search-page/index.tsx
Normal 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
|
||||||
@ -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' | ''
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
|
||||||
collectionTemplates: uniqueTemplates,
|
|
||||||
collectionTemplatesTotal: uniqueTemplates.length,
|
|
||||||
}
|
|
||||||
}, [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,
|
|
||||||
page: templatesQuery.data?.pages.length || 1,
|
|
||||||
isLoading: templatesQuery.isLoading,
|
|
||||||
isFetchingNextPage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSearchMode,
|
|
||||||
templateCollections: templateCollectionsQuery.data?.templateCollections,
|
templateCollections: templateCollectionsQuery.data?.templateCollections,
|
||||||
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
|
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
|
||||||
templates: collectionTemplates,
|
templates: templatesQuery.data?.pages.flatMap(page => page.templates).map(mapTemplateDetailToTemplate),
|
||||||
templatesTotal: collectionTemplatesTotal,
|
templatesTotal: templatesQuery.data?.pages[0]?.total,
|
||||||
page: 1,
|
page: templatesQuery.data?.pages.length || 1,
|
||||||
isLoading: templateCollectionsQuery.isLoading,
|
isLoading: templateCollectionsQuery.isLoading || templatesQuery.isLoading,
|
||||||
isFetchingNextPage: false,
|
isFetchingNextPage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginsMarketplaceData = ReturnType<typeof usePluginsMarketplaceData>
|
||||||
|
type TemplatesMarketplaceData = ReturnType<typeof useTemplatesMarketplaceData>
|
||||||
|
type MarketplaceData
|
||||||
|
= ({ creationType: 'plugins' } & PluginsMarketplaceData)
|
||||||
|
| ({ creationType: 'templates' } & TemplatesMarketplaceData)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
}>(),
|
||||||
|
)
|
||||||
|
|||||||
@ -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 = {
|
||||||
collections: collectionsContract,
|
plugins: {
|
||||||
collectionPlugins: collectionPluginsContract,
|
collections: collectionsContract,
|
||||||
searchAdvanced: searchAdvancedContract,
|
collectionPlugins: collectionPluginsContract,
|
||||||
|
searchAdvanced: searchAdvancedContract,
|
||||||
|
getPublisherPlugins: getPublisherPluginsContract,
|
||||||
|
},
|
||||||
|
searchUnified: searchUnifiedContract,
|
||||||
templateCollections: {
|
templateCollections: {
|
||||||
list: templateCollectionsContract,
|
list: templateCollectionsContract,
|
||||||
create: createTemplateCollectionContract,
|
create: createTemplateCollectionContract,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "插件",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user